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) + }) +})