From 3177a704fff12cc8c2a721ca3b6530d21890287d Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 27 Apr 2026 23:24:25 +0800 Subject: [PATCH] =?UTF-8?q?test(web):=20exprEvaluator=20+=20useDebouncedVa?= =?UTF-8?q?lue=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=20=E2=80=94=2024=20?= =?UTF-8?q?=E4=B8=AA=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit exprEvaluator(19): 等值/不等/AND/OR/NOT/括号/短路运算/ missing field/type coercion/visibleWhen 便捷函数。 useDebouncedValue(5): 初始值/防抖/快速更新重置/自定义延迟/数值类型。 --- apps/web/src/hooks/useDebouncedValue.test.ts | 78 +++++++++++ apps/web/src/utils/exprEvaluator.test.ts | 138 +++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 apps/web/src/hooks/useDebouncedValue.test.ts create mode 100644 apps/web/src/utils/exprEvaluator.test.ts diff --git a/apps/web/src/hooks/useDebouncedValue.test.ts b/apps/web/src/hooks/useDebouncedValue.test.ts new file mode 100644 index 0000000..713e9dc --- /dev/null +++ b/apps/web/src/hooks/useDebouncedValue.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useDebouncedValue } from './useDebouncedValue' + +describe('useDebouncedValue', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns initial value immediately', () => { + const { result } = renderHook(() => useDebouncedValue('hello')) + expect(result.current).toBe('hello') + }) + + it('debounces value updates', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 300), + { initialProps: { value: 'a' } }, + ) + + expect(result.current).toBe('a') + + rerender({ value: 'b' }) + expect(result.current).toBe('a') + + act(() => { vi.advanceTimersByTime(299) }) + expect(result.current).toBe('a') + + act(() => { vi.advanceTimersByTime(1) }) + expect(result.current).toBe('b') + }) + + it('resets timer on rapid updates', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 200), + { initialProps: { value: 'a' } }, + ) + + rerender({ value: 'b' }) + act(() => { vi.advanceTimersByTime(100) }) + + rerender({ value: 'c' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(result.current).toBe('a') + + act(() => { vi.advanceTimersByTime(100) }) + expect(result.current).toBe('c') + }) + + it('uses custom delay', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: 'x' } }, + ) + + rerender({ value: 'y' }) + act(() => { vi.advanceTimersByTime(499) }) + expect(result.current).toBe('x') + + act(() => { vi.advanceTimersByTime(1) }) + expect(result.current).toBe('y') + }) + + it('works with numeric values', () => { + const { result, rerender } = renderHook( + ({ value }) => useDebouncedValue(value, 100), + { initialProps: { value: 0 } }, + ) + + rerender({ value: 42 }) + act(() => { vi.advanceTimersByTime(100) }) + expect(result.current).toBe(42) + }) +}) diff --git a/apps/web/src/utils/exprEvaluator.test.ts b/apps/web/src/utils/exprEvaluator.test.ts new file mode 100644 index 0000000..9a52a5d --- /dev/null +++ b/apps/web/src/utils/exprEvaluator.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest' +import { parseExpr, evaluateExpr, evaluateVisibleWhen } from './exprEvaluator' + +describe('parseExpr', () => { + it('parses equality expression', () => { + const ast = parseExpr("status == 'active'") + expect(ast).toEqual({ + type: 'eq', + field: 'status', + value: 'active', + }) + }) + + it('parses inequality expression', () => { + const ast = parseExpr("type != 'internal'") + expect(ast).toEqual({ + type: 'neq', + field: 'type', + value: 'internal', + }) + }) + + it('parses AND expression', () => { + const ast = parseExpr("status == 'active' AND role == 'admin'") + expect(ast).toEqual({ + type: 'and', + left: { type: 'eq', field: 'status', value: 'active' }, + right: { type: 'eq', field: 'role', value: 'admin' }, + }) + }) + + it('parses OR expression', () => { + const ast = parseExpr("status == 'active' OR status == 'pending'") + expect(ast).toEqual({ + type: 'or', + left: { type: 'eq', field: 'status', value: 'active' }, + right: { type: 'eq', field: 'status', value: 'pending' }, + }) + }) + + it('parses NOT expression', () => { + const ast = parseExpr("NOT status == 'deleted'") + expect(ast).toEqual({ + type: 'not', + operand: { type: 'eq', field: 'status', value: 'deleted' }, + }) + }) + + it('parses parenthesized expression', () => { + const ast = parseExpr("(status == 'a' OR status == 'b') AND role == 'admin'") + expect(ast?.type).toBe('and') + expect(ast?.left?.type).toBe('or') + }) + + it('returns null for empty input', () => { + expect(parseExpr('')).toBeNull() + }) + + it('parses && and || operators', () => { + const ast = parseExpr("a == '1' && b == '2' || c == '3'") + expect(ast).toBeDefined() + expect(ast?.type).toBe('or') + }) +}) + +describe('evaluateExpr', () => { + it('evaluates equality true', () => { + const ast = parseExpr("status == 'active'")! + expect(evaluateExpr(ast, { status: 'active' })).toBe(true) + }) + + it('evaluates equality false', () => { + const ast = parseExpr("status == 'active'")! + expect(evaluateExpr(ast, { status: 'inactive' })).toBe(false) + }) + + it('evaluates inequality', () => { + const ast = parseExpr("status != 'deleted'")! + expect(evaluateExpr(ast, { status: 'active' })).toBe(true) + expect(evaluateExpr(ast, { status: 'deleted' })).toBe(false) + }) + + it('evaluates AND', () => { + const ast = parseExpr("a == '1' AND b == '2'")! + expect(evaluateExpr(ast, { a: '1', b: '2' })).toBe(true) + expect(evaluateExpr(ast, { a: '1', b: '3' })).toBe(false) + expect(evaluateExpr(ast, { a: '0', b: '2' })).toBe(false) + }) + + it('evaluates OR', () => { + const ast = parseExpr("a == '1' OR b == '2'")! + expect(evaluateExpr(ast, { a: '1', b: 'x' })).toBe(true) + expect(evaluateExpr(ast, { a: 'x', b: '2' })).toBe(true) + expect(evaluateExpr(ast, { a: 'x', b: 'x' })).toBe(false) + }) + + it('evaluates NOT', () => { + const ast = parseExpr("NOT a == '1'")! + expect(evaluateExpr(ast, { a: '1' })).toBe(false) + expect(evaluateExpr(ast, { a: '2' })).toBe(true) + }) + + it('handles missing field as empty string', () => { + const ast = parseExpr("status == ''")! + expect(evaluateExpr(ast, {})).toBe(true) + }) + + it('converts non-string values to string', () => { + const ast = parseExpr("count == '5'")! + expect(evaluateExpr(ast, { count: 5 })).toBe(true) + }) +}) + +describe('evaluateVisibleWhen', () => { + it('returns true for undefined expression', () => { + expect(evaluateVisibleWhen(undefined, {})).toBe(true) + }) + + it('returns true for empty string expression', () => { + expect(evaluateVisibleWhen('', {})).toBe(true) + }) + + it('evaluates complex expression', () => { + expect( + evaluateVisibleWhen("type == 'doctor' AND status == 'active'", { + type: 'doctor', + status: 'active', + }), + ).toBe(true) + + expect( + evaluateVisibleWhen("type == 'doctor' AND status == 'active'", { + type: 'doctor', + status: 'inactive', + }), + ).toBe(false) + }) +})