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,
} from '@affine/component/page-list';
import {
filterByView,
filterByFilterList,
PageList,
PageListTrashView,
} from '@affine/component/page-list';
@@ -92,10 +92,10 @@ export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
if (!view) {
return true;
}
return filterByView(view, {
return filterByFilterList(view.filterList, {
Favorite: !!pageMeta.favorite,
Created: pageMeta.createDate,
Updated: pageMeta.updatedDate,
Updated: pageMeta.updatedDate ?? pageMeta.createDate,
});
}),
[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 { expect, test } from 'vitest';
import { createDefaultFilter, vars } from '../filter/vars';
import { useAllPageSetting } from '../use-all-page-setting';
test('useAllPageSetting', async () => {
@@ -15,32 +16,12 @@ test('useAllPageSetting', async () => {
expect(settingHook.result.current.savedViews).toEqual([]);
settingHook.result.current.setCurrentView(view => ({
...view,
filterList: [
{
type: 'filter',
left: {
type: 'ref',
name: 'Created',
},
funcName: 'Create',
args: [],
},
],
filterList: [createDefaultFilter(vars[0])],
}));
settingHook.rerender();
const nextView = settingHook.result.current.currentView;
expect(nextView).not.toBe(prevView);
expect(nextView.filterList).toEqual([
{
type: 'filter',
left: {
type: 'ref',
name: 'Created',
},
funcName: 'Create',
args: [],
},
]);
expect(nextView.filterList).toEqual([createDefaultFilter(vars[0])]);
settingHook.result.current.setCurrentView(RESET);
await settingHook.result.current.createView({
...settingHook.result.current.currentView,

View File

@@ -5,7 +5,7 @@ import { Menu, MenuItem } from '../../../ui/menu';
import { literalMatcher } from './literal-matcher';
import type { TFunction, TType } from './logical/typesystem';
import type { Filter, Literal } from './vars';
import { ChangeFilterMenu, filterMatcher, vars } from './vars';
import { filterMatcher, VariableSelect, vars } from './vars';
export const Condition = ({
value,
@@ -31,15 +31,20 @@ export const Condition = ({
>
<Menu
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
trigger="click"
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>
{args}
</div>

View File

@@ -37,7 +37,7 @@ literalMatcher.register(tDate.create(), {
onChange={e => {
onChange({
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[] {
const match = this._match ?? typesystem.isSubtype.bind(typesystem);
const result: Data[] = [];
for (const t of this.list) {
if (match(t.type, type)) {
result.push(t.data);
}
}
return result;
return this.allMatched(type).map(v => v.data);
}
findData(f: (data: Data) => boolean): Data | undefined {

View File

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

View File

@@ -18,10 +18,19 @@ export type Filter = {
funcName: string;
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 = {
type: 'literal';
value: unknown;
value: LiteralValue;
};
export type FilterVariable = {
@@ -45,7 +54,9 @@ export const variableDefineMap = {
// type: tBoolean.create(),
// },
} 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(
([key, value]) => ({
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);
if (!data) {
throw new Error('No matching function found');
@@ -74,7 +85,7 @@ export const CreateFilterMenu = ({
onChange: (value: Filter[]) => void;
}) => {
return (
<ChangeFilterMenu
<VariableSelect
selected={value}
onSelect={filter => {
onChange([...value, filter]);
@@ -83,7 +94,7 @@ export const CreateFilterMenu = ({
);
};
export const ChangeFilterMenu = ({
export const VariableSelect = ({
onSelect,
}: {
selected: Filter[];
@@ -107,22 +118,22 @@ export const ChangeFilterMenu = ({
);
};
export const filterMatcher = new Matcher<
{
name: string;
defaultArgs: () => unknown[];
render?: (props: { ast: Filter }) => ReactNode;
impl: (...args: unknown[]) => boolean;
},
TFunction
>((type, target) => {
const staticType = typesystem.subst(
Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []),
type
);
const firstArg = staticType.args[0];
return firstArg && typesystem.isSubtype(firstArg, target);
});
export type FilterMatcherDataType = {
name: string;
defaultArgs: () => LiteralValue[];
render?: (props: { ast: Filter }) => ReactNode;
impl: (...args: LiteralValue[]) => boolean;
};
export const filterMatcher = new Matcher<FilterMatcherDataType, TFunction>(
(type, target) => {
const staticType = typesystem.subst(
Object.fromEntries(type.typeVars?.map(v => [v.name, v.bound]) ?? []),
type
);
const firstArg = staticType.args[0];
return firstArg && typesystem.isSubtype(firstArg, target);
}
);
filterMatcher.register(
tFunction({
@@ -143,13 +154,13 @@ filterMatcher.register(
{
name: 'after',
defaultArgs: () => {
return [dayjs().subtract(1, 'day').endOf('day')];
return [dayjs().subtract(1, 'day').endOf('day').millisecond()];
},
impl: (date, target) => {
if (typeof date !== 'number' || !dayjs.isDayjs(target)) {
if (typeof date !== 'number' || typeof target !== 'number') {
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() }),
{
name: 'before',
defaultArgs: () => [dayjs().endOf('day')],
defaultArgs: () => [dayjs().endOf('day').millisecond()],
impl: (date, target) => {
if (typeof date !== 'number' || !dayjs.isDayjs(target)) {
if (typeof date !== 'number' || typeof target !== 'number') {
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 { openDB } from 'idb';
import type { IDBPDatabase } from 'idb/build/entry';
@@ -10,7 +9,6 @@ import { NIL } from 'uuid';
import { evalFilterList } from './filter';
import type { Filter, VariableMap } from './filter/vars';
import type { Literal } from './filter/vars';
export type View = {
id: string;
@@ -18,13 +16,7 @@ export type View = {
filterList: Filter[];
};
type PersistenceView = Omit<View, 'filterList'> & {
filterList: (Omit<Filter, 'args'> & {
args: (Omit<Literal, 'value'> & {
value: string;
})[];
})[];
};
type PersistenceView = View;
export interface PageViewDBV1 extends DBSchema {
view: {
@@ -58,17 +50,7 @@ export const useAllPageSetting = () => {
fetcher: async () => {
const db = await pageViewDBPromise;
const t = db.transaction('view').objectStore('view');
const all = await t.getAll();
return all.map(view => ({
...view,
filterList: view.filterList.map(filter => ({
...filter,
args: filter.args.map(arg => ({
...arg,
value: dayjs(arg.value),
})),
})),
}));
return await t.getAll();
},
suspense: true,
fallbackData: [],
@@ -85,22 +67,7 @@ export const useAllPageSetting = () => {
}
const db = await pageViewDBPromise;
const t = db.transaction('view', 'readwrite').objectStore('view');
const persistenceView: PersistenceView = {
...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 t.put(view);
await mutate();
},
[mutate]
@@ -115,5 +82,5 @@ export const useAllPageSetting = () => {
setCurrentView,
};
};
export const filterByView = (view: View, varMap: VariableMap) =>
evalFilterList(view.filterList, varMap);
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
evalFilterList(filterList, varMap);