test: add some tests for page filter (#2593)

This commit is contained in:
3720
2023-05-30 16:39:14 +08:00
committed by GitHub
parent 395414c336
commit 8c5a1e2de3
9 changed files with 221 additions and 105 deletions

View File

@@ -5,7 +5,7 @@ import type {
View, View,
} from '@affine/component/page-list'; } from '@affine/component/page-list';
import { import {
filterByView, filterByFilterList,
PageList, PageList,
PageListTrashView, PageListTrashView,
} from '@affine/component/page-list'; } from '@affine/component/page-list';
@@ -92,10 +92,10 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
if (!view) { if (!view) {
return true; return true;
} }
return filterByView(view, { return filterByFilterList(view.filterList, {
Favorite: !!pageMeta.favorite, Favorite: !!pageMeta.favorite,
Created: pageMeta.createDate, Created: pageMeta.createDate,
Updated: pageMeta.updatedDate, Updated: pageMeta.updatedDate ?? pageMeta.createDate,
}); });
}), }),
[pageMetas, listType, view] [pageMetas, listType, view]

View File

@@ -0,0 +1,160 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { assertExists } from '@blocksuite/global/utils';
import { render } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, test } from 'vitest';
import { Condition } from '../filter/condition';
import { tBoolean, tDate } from '../filter/logical/custom-type';
import type {
Filter,
FilterMatcherDataType,
LiteralValue,
Ref,
VariableMap,
} from '../filter/vars';
import { filterMatcher, toLiteral } from '../filter/vars';
import { filterByFilterList } from '../use-all-page-setting';
const ref = (name: keyof VariableMap): Ref => {
return {
type: 'ref',
name,
};
};
const mockVariableMap = (vars: Partial<VariableMap>): VariableMap => {
return {
Created: 0,
Updated: 0,
Favorite: false,
...vars,
};
};
const filter = (
matcherData: FilterMatcherDataType,
left: Ref,
args: LiteralValue[]
): Filter => {
return {
type: 'filter',
left,
funcName: matcherData.name,
args: args.map(toLiteral),
};
};
describe('match filter', () => {
test('boolean variable will match `is` filter', () => {
const is = filterMatcher
.allMatchedData(tBoolean.create())
.find(v => v.name === 'is');
expect(is?.name).toBe('is');
});
test('Date variable will match `before` filter', () => {
const before = filterMatcher
.allMatchedData(tDate.create())
.find(v => v.name === 'before');
expect(before?.name).toBe('before');
});
});
describe('eval filter', () => {
test('before', async () => {
const before = filterMatcher.findData(v => v.name === 'before');
assertExists(before);
const filter1 = filter(before, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(before, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(before, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('after', async () => {
const after = filterMatcher.findData(v => v.name === 'after');
assertExists(after);
const filter1 = filter(after, ref('Created'), [
new Date(2023, 5, 28).getTime(),
]);
const filter2 = filter(after, ref('Created'), [
new Date(2023, 5, 30).getTime(),
]);
const filter3 = filter(after, ref('Created'), [
new Date(2023, 5, 29).getTime(),
]);
const varMap = mockVariableMap({
Created: new Date(2023, 5, 29).getTime(),
});
expect(filterByFilterList([filter1], varMap)).toBe(true);
expect(filterByFilterList([filter2], varMap)).toBe(false);
expect(filterByFilterList([filter3], varMap)).toBe(false);
});
test('is', async () => {
const is = filterMatcher.findData(v => v.name === 'is');
assertExists(is);
const filter1 = filter(is, ref('Favorite'), [false]);
const filter2 = filter(is, ref('Favorite'), [true]);
const varMap = mockVariableMap({
Favorite: true,
});
expect(filterByFilterList([filter1], varMap)).toBe(false);
expect(filterByFilterList([filter2], varMap)).toBe(true);
});
});
describe('render filter', () => {
test('boolean condition value change', async () => {
const is = filterMatcher.match(tBoolean.create());
assertExists(is);
const Wrapper = () => {
const [value, onChange] = useState(filter(is, ref('Favorite'), [true]));
return <Condition value={value} onChange={onChange} />;
};
const result = render(<Wrapper />);
const dom = await result.findByText('true');
dom.click();
await result.findByText('false');
result.unmount();
});
test('date condition function change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
assertExists(dateFunction);
const Wrapper = () => {
const [value, onChange] = useState(
filter(dateFunction, ref('Created'), [new Date(2023, 5, 29).getTime()])
);
return <Condition value={value} onChange={onChange} />;
};
const result = render(<Wrapper />);
const dom = await result.findByTestId('filter-name');
dom.click();
await result.findByTestId('filter-name');
result.unmount();
});
test('date condition variable change', async () => {
const dateFunction = filterMatcher.match(tDate.create());
assertExists(dateFunction);
const Wrapper = () => {
const [value, onChange] = useState(
filter(dateFunction, ref('Created'), [new Date(2023, 5, 29).getTime()])
);
return <Condition value={value} onChange={onChange} />;
};
const result = render(<Wrapper />);
const dom = await result.findByTestId('variable-name');
dom.click();
await result.findByTestId('variable-name');
result.unmount();
});
});

View File

@@ -7,6 +7,7 @@ import { renderHook } from '@testing-library/react';
import { RESET } from 'jotai/utils'; import { RESET } from 'jotai/utils';
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
import { createDefaultFilter, vars } from '../filter/vars';
import { useAllPageSetting } from '../use-all-page-setting'; import { useAllPageSetting } from '../use-all-page-setting';
test('useAllPageSetting', async () => { test('useAllPageSetting', async () => {
@@ -15,32 +16,12 @@ test('useAllPageSetting', async () => {
expect(settingHook.result.current.savedViews).toEqual([]); expect(settingHook.result.current.savedViews).toEqual([]);
settingHook.result.current.setCurrentView(view => ({ settingHook.result.current.setCurrentView(view => ({
...view, ...view,
filterList: [ filterList: [createDefaultFilter(vars[0])],
{
type: 'filter',
left: {
type: 'ref',
name: 'Created',
},
funcName: 'Create',
args: [],
},
],
})); }));
settingHook.rerender(); settingHook.rerender();
const nextView = settingHook.result.current.currentView; const nextView = settingHook.result.current.currentView;
expect(nextView).not.toBe(prevView); expect(nextView).not.toBe(prevView);
expect(nextView.filterList).toEqual([ expect(nextView.filterList).toEqual([createDefaultFilter(vars[0])]);
{
type: 'filter',
left: {
type: 'ref',
name: 'Created',
},
funcName: 'Create',
args: [],
},
]);
settingHook.result.current.setCurrentView(RESET); settingHook.result.current.setCurrentView(RESET);
await settingHook.result.current.createView({ await settingHook.result.current.createView({
...settingHook.result.current.currentView, ...settingHook.result.current.currentView,

View File

@@ -5,7 +5,7 @@ import { Menu, MenuItem } from '../../../ui/menu';
import { literalMatcher } from './literal-matcher'; import { literalMatcher } from './literal-matcher';
import type { TFunction, TType } from './logical/typesystem'; import type { TFunction, TType } from './logical/typesystem';
import type { Filter, Literal } from './vars'; import type { Filter, Literal } from './vars';
import { ChangeFilterMenu, filterMatcher, vars } from './vars'; import { filterMatcher, VariableSelect, vars } from './vars';
export const Condition = ({ export const Condition = ({
value, value,
@@ -31,15 +31,20 @@ export const Condition = ({
> >
<Menu <Menu
trigger="click" trigger="click"
content={<ChangeFilterMenu selected={[]} onSelect={onChange} />} content={<VariableSelect selected={[]} onSelect={onChange} />}
> >
<div>{ast.left.name}</div> <div data-testid="variable-name">{ast.left.name}</div>
</Menu> </Menu>
<Menu <Menu
trigger="click" trigger="click"
content={<FunctionSelect value={value} onChange={onChange} />} content={<FunctionSelect value={value} onChange={onChange} />}
> >
<div style={{ marginLeft: 4, color: 'gray' }}>{ast.funcName}</div> <div
style={{ marginLeft: 4, color: 'gray' }}
data-testid="filter-name"
>
{ast.funcName}
</div>
</Menu> </Menu>
{args} {args}
</div> </div>

View File

@@ -37,7 +37,7 @@ literalMatcher.register(tDate.create(), {
onChange={e => { onChange={e => {
onChange({ onChange({
type: 'literal', type: 'literal',
value: dayjs(e.target.value, 'YYYY-MM-DD'), value: dayjs(e.target.value, 'YYYY-MM-DD').millisecond(),
}); });
}} }}
/> />

View File

@@ -34,14 +34,7 @@ export class Matcher<Data, Type extends TType = TType> {
} }
allMatchedData(type: TType): Data[] { allMatchedData(type: TType): Data[] {
const match = this._match ?? typesystem.isSubtype.bind(typesystem); return this.allMatched(type).map(v => v.data);
const result: Data[] = [];
for (const t of this.list) {
if (match(t.type, type)) {
result.push(t.data);
}
}
return result;
} }
findData(f: (data: Data) => boolean): Data | undefined { findData(f: (data: Data) => boolean): Data | undefined {

View File

@@ -267,7 +267,6 @@ export class Typesystem {
template: TFunction template: TFunction
): TFunction { ): TFunction {
const ctx = { ...context }; const ctx = { ...context };
console.log(template);
template.args.forEach((arg, i) => { template.args.forEach((arg, i) => {
const realArg = realArgs[i]; const realArg = realArgs[i];
if (realArg) { if (realArg) {

View File

@@ -18,10 +18,19 @@ export type Filter = {
funcName: string; funcName: string;
args: Literal[]; args: Literal[];
}; };
export type LiteralValue =
| number
| string
| boolean
| { [K: string]: LiteralValue }
| Array<LiteralValue>;
export const toLiteral = (value: LiteralValue): Literal => ({
type: 'literal',
value,
});
export type Literal = { export type Literal = {
type: 'literal'; type: 'literal';
value: unknown; value: LiteralValue;
}; };
export type FilterVariable = { export type FilterVariable = {
@@ -45,7 +54,9 @@ export const variableDefineMap = {
// type: tBoolean.create(), // type: tBoolean.create(),
// }, // },
} as const; } as const;
export type VariableMap = { [K in keyof typeof variableDefineMap]: unknown }; export type VariableMap = {
[K in keyof typeof variableDefineMap]: LiteralValue;
};
export const vars: FilterVariable[] = Object.entries(variableDefineMap).map( export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
([key, value]) => ({ ([key, value]) => ({
name: key as keyof VariableMap, name: key as keyof VariableMap,
@@ -53,7 +64,7 @@ export const vars: FilterVariable[] = Object.entries(variableDefineMap).map(
}) })
); );
const createDefaultFilter = (variable: FilterVariable): Filter => { export const createDefaultFilter = (variable: FilterVariable): Filter => {
const data = filterMatcher.match(variable.type); const data = filterMatcher.match(variable.type);
if (!data) { if (!data) {
throw new Error('No matching function found'); throw new Error('No matching function found');
@@ -74,7 +85,7 @@ export const CreateFilterMenu = ({
onChange: (value: Filter[]) => void; onChange: (value: Filter[]) => void;
}) => { }) => {
return ( return (
<ChangeFilterMenu <VariableSelect
selected={value} selected={value}
onSelect={filter => { onSelect={filter => {
onChange([...value, filter]); onChange([...value, filter]);
@@ -83,7 +94,7 @@ export const CreateFilterMenu = ({
); );
}; };
export const ChangeFilterMenu = ({ export const VariableSelect = ({
onSelect, onSelect,
}: { }: {
selected: Filter[]; selected: Filter[];
@@ -107,22 +118,22 @@ export const ChangeFilterMenu = ({
); );
}; };
export const filterMatcher = new Matcher< export type FilterMatcherDataType = {
{ name: string;
name: string; defaultArgs: () => LiteralValue[];
defaultArgs: () => unknown[]; render?: (props: { ast: Filter }) => ReactNode;
render?: (props: { ast: Filter }) => ReactNode; impl: (...args: LiteralValue[]) => boolean;
impl: (...args: unknown[]) => boolean; };
}, export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>(
TFunction (type, target) => {
>((type, target) => { const staticType = typesystem.subst(
const staticType = typesystem.subst( Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []),
Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []), type
type );
); const firstArg = staticType.args[0];
const firstArg = staticType.args[0]; return firstArg && typesystem.isSubtype(firstArg, target);
return firstArg && typesystem.isSubtype(firstArg, target); }
}); );
filterMatcher.register( filterMatcher.register(
tFunction({ tFunction({
@@ -143,13 +154,13 @@ filterMatcher.register(
{ {
name: 'after', name: 'after',
defaultArgs: () => { defaultArgs: () => {
return [dayjs().subtract(1, 'day').endOf('day')]; return [dayjs().subtract(1, 'day').endOf('day').millisecond()];
}, },
impl: (date, target) => { impl: (date, target) => {
if (typeof date !== 'number' || !dayjs.isDayjs(target)) { if (typeof date !== 'number' || typeof target !== 'number') {
throw new Error('argument type error'); throw new Error('argument type error');
} }
return dayjs(date).isAfter(target.endOf('day')); return dayjs(date).isAfter(dayjs(target).endOf('day'));
}, },
} }
); );
@@ -158,12 +169,12 @@ filterMatcher.register(
tFunction({ args: [tDate.create(), tDate.create()], rt: tBoolean.create() }), tFunction({ args: [tDate.create(), tDate.create()], rt: tBoolean.create() }),
{ {
name: 'before', name: 'before',
defaultArgs: () => [dayjs().endOf('day')], defaultArgs: () => [dayjs().endOf('day').millisecond()],
impl: (date, target) => { impl: (date, target) => {
if (typeof date !== 'number' || !dayjs.isDayjs(target)) { if (typeof date !== 'number' || typeof target !== 'number') {
throw new Error('argument type error'); throw new Error('argument type error');
} }
return dayjs(date).isBefore(target.startOf('day')); return dayjs(date).isBefore(dayjs(target).startOf('day'));
}, },
} }
); );

View File

@@ -1,4 +1,3 @@
import dayjs from 'dayjs';
import type { DBSchema } from 'idb'; import type { DBSchema } from 'idb';
import { openDB } from 'idb'; import { openDB } from 'idb';
import type { IDBPDatabase } from 'idb/build/entry'; import type { IDBPDatabase } from 'idb/build/entry';
@@ -10,7 +9,6 @@ import { NIL } from 'uuid';
import { evalFilterList } from './filter'; import { evalFilterList } from './filter';
import type { Filter, VariableMap } from './filter/vars'; import type { Filter, VariableMap } from './filter/vars';
import type { Literal } from './filter/vars';
export type View = { export type View = {
id: string; id: string;
@@ -18,13 +16,7 @@ export type View = {
filterList: Filter[]; filterList: Filter[];
}; };
type PersistenceView = Omit<View, 'filterList'> & { type PersistenceView = View;
filterList: (Omit<Filter, 'args'> & {
args: (Omit<Literal, 'value'> & {
value: string;
})[];
})[];
};
export interface PageViewDBV1 extends DBSchema { export interface PageViewDBV1 extends DBSchema {
view: { view: {
@@ -58,17 +50,7 @@ export const useAllPageSetting = () => {
fetcher: async () => { fetcher: async () => {
const db = await pageViewDBPromise; const db = await pageViewDBPromise;
const t = db.transaction('view').objectStore('view'); const t = db.transaction('view').objectStore('view');
const all = await t.getAll(); return await t.getAll();
return all.map(view => ({
...view,
filterList: view.filterList.map(filter => ({
...filter,
args: filter.args.map(arg => ({
...arg,
value: dayjs(arg.value),
})),
})),
}));
}, },
suspense: true, suspense: true,
fallbackData: [], fallbackData: [],
@@ -85,22 +67,7 @@ export const useAllPageSetting = () => {
} }
const db = await pageViewDBPromise; const db = await pageViewDBPromise;
const t = db.transaction('view', 'readwrite').objectStore('view'); const t = db.transaction('view', 'readwrite').objectStore('view');
const persistenceView: PersistenceView = { await t.put(view);
...view,
filterList: view.filterList.map(filter => {
return {
...filter,
args: filter.args.map(arg => {
return {
type: arg.type,
// @ts-expect-error
value: arg.value.toString(),
};
}),
};
}),
};
await t.put(persistenceView);
await mutate(); await mutate();
}, },
[mutate] [mutate]
@@ -115,5 +82,5 @@ export const useAllPageSetting = () => {
setCurrentView, setCurrentView,
}; };
}; };
export const filterByView = (view: View, varMap: VariableMap) => export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
evalFilterList(view.filterList, varMap); evalFilterList(filterList, varMap);