From ef63cb3189a559552902e70e8ca92f1e1e562d79 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 01:47:48 +0000 Subject: [PATCH 01/13] Initial plan From 5cb638f3f6f4b03217cede87bc7bc4be2048f799 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:10:19 +0000 Subject: [PATCH 02/13] feat: integrate i18n into @object-ui/react and add sideEffects declarations for tree-shaking Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/core/package.json | 1 + packages/i18n/package.json | 1 + packages/layout/package.json | 1 + packages/react/package.json | 1 + packages/react/src/index.ts | 21 +++++++++++++++++++++ packages/types/package.json | 1 + pnpm-lock.yaml | 7 +++++-- 7 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 17f4e7039..57b844601 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,6 +2,7 @@ "name": "@object-ui/core", "version": "0.5.0", "type": "module", + "sideEffects": false, "license": "MIT", "description": "Core logic, types, and validation for Object UI. Zero React dependencies.", "homepage": "https://www.objectui.org", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 5eb8fbf96..66f92466f 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -2,6 +2,7 @@ "name": "@object-ui/i18n", "version": "0.5.0", "type": "module", + "sideEffects": false, "license": "MIT", "description": "Internationalization (i18n) support for Object UI with 10+ language packs, RTL layout, and date/currency formatting.", "homepage": "https://www.objectui.org", diff --git a/packages/layout/package.json b/packages/layout/package.json index d23de0827..9b36d4321 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -2,6 +2,7 @@ "name": "@object-ui/layout", "version": "0.5.0", "type": "module", + "sideEffects": false, "main": "dist/index.umd.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/react/package.json b/packages/react/package.json index dc3cdb717..0ad7fd497 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@object-ui/core": "workspace:*", + "@object-ui/i18n": "workspace:*", "@object-ui/types": "workspace:*", "@objectstack/spec": "^2.0.4", "react-hook-form": "^7.71.1" diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c47e8f221..ba5e36f4a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -12,3 +12,24 @@ export * from './context'; // will be empty for now export * from './components/form'; export * from './LazyPluginLoader'; +// Built-in i18n support +export { + I18nProvider, + useObjectTranslation, + useI18nContext, + createI18n, + getDirection, + getAvailableLanguages, + formatDate, + formatDateTime, + formatRelativeTime, + formatCurrency, + formatNumber, + type I18nConfig, + type I18nProviderProps, + type TranslationKeys, + type DateFormatOptions, + type CurrencyFormatOptions, + type NumberFormatOptions, +} from '@object-ui/i18n'; + diff --git a/packages/types/package.json b/packages/types/package.json index a5fb643a1..933b919dc 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -3,6 +3,7 @@ "version": "0.5.0", "description": "Pure TypeScript type definitions for Object UI - The Protocol Layer", "type": "module", + "sideEffects": false, "main": "./dist/index.js", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 561eefa65..77cfc895e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1938,7 +1938,7 @@ importers: version: 3.9.1(@types/node@25.2.2)(rollup@4.57.1)(typescript@5.9.3)(vite@5.4.21(@types/node@25.2.2)(lightningcss@1.30.2)) vitest: specifier: ^1.3.1 - version: 1.6.1(@types/node@25.2.2)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2) + version: 1.6.1(@types/node@25.2.2)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2) packages/plugin-timeline: dependencies: @@ -2092,6 +2092,9 @@ importers: '@object-ui/core': specifier: workspace:* version: link:../core + '@object-ui/i18n': + specifier: workspace:* + version: link:../i18n '@object-ui/types': specifier: workspace:* version: link:../types @@ -21628,7 +21631,7 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 - vitest@1.6.1(@types/node@25.2.2)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2): + vitest@1.6.1(@types/node@25.2.2)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2): dependencies: '@vitest/expect': 1.6.1 '@vitest/runner': 1.6.1 From 6f89ff103ed9660ed924eeef9906c79faadc0b8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:13:05 +0000 Subject: [PATCH 03/13] Add tests for useExpression and useCondition hooks Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/hooks/__tests__/useExpression.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/react/src/hooks/__tests__/useExpression.test.ts diff --git a/packages/react/src/hooks/__tests__/useExpression.test.ts b/packages/react/src/hooks/__tests__/useExpression.test.ts new file mode 100644 index 000000000..f4cf548b2 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useExpression.test.ts @@ -0,0 +1,86 @@ +/** + * Tests for useExpression and useCondition hooks + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useExpression, useCondition } from '../useExpression'; + +describe('useExpression', () => { + it('returns string value directly for non-expression strings', () => { + const { result } = renderHook(() => useExpression('hello')); + + expect(result.current).toBe('hello'); + }); + + it('returns number value directly', () => { + const { result } = renderHook(() => useExpression(42)); + + expect(result.current).toBe(42); + }); + + it('returns boolean value directly', () => { + const { result } = renderHook(() => useExpression(true)); + + expect(result.current).toBe(true); + }); + + it('returns null for null expressions', () => { + const { result } = renderHook(() => useExpression(null)); + + expect(result.current).toBeNull(); + }); + + it('returns undefined for undefined expressions', () => { + const { result } = renderHook(() => useExpression(undefined)); + + expect(result.current).toBeUndefined(); + }); + + it('evaluates expressions with ${...} syntax', () => { + const context = { data: { name: 'John' } }; + const { result } = renderHook(() => + useExpression('${data.name}', context), + ); + + expect(result.current).toBe('John'); + }); + + it('evaluates expressions with context data', () => { + const context = { data: { age: 25 } }; + const { result } = renderHook(() => + useExpression('${data.age > 18}', context), + ); + + expect(result.current).toBe(true); + }); +}); + +describe('useCondition', () => { + it('returns true for boolean true', () => { + const { result } = renderHook(() => useCondition(true)); + + expect(result.current).toBe(true); + }); + + it('returns false for boolean false', () => { + const { result } = renderHook(() => useCondition(false)); + + expect(result.current).toBe(false); + }); + + it('returns true for undefined', () => { + const { result } = renderHook(() => useCondition(undefined)); + + expect(result.current).toBe(true); + }); + + it('evaluates string conditions with context', () => { + const context = { data: { status: 'active' } }; + const { result } = renderHook(() => + useCondition('${data.status === "active"}', context), + ); + + expect(result.current).toBe(true); + }); +}); From b6d0c826ac421ac6357c6c7f795403059b2cc7f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:15:34 +0000 Subject: [PATCH 04/13] Add tests for useDiscovery hook Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/hooks/__tests__/useDiscovery.test.tsx | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 packages/react/src/hooks/__tests__/useDiscovery.test.tsx diff --git a/packages/react/src/hooks/__tests__/useDiscovery.test.tsx b/packages/react/src/hooks/__tests__/useDiscovery.test.tsx new file mode 100644 index 000000000..1c85ab364 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useDiscovery.test.tsx @@ -0,0 +1,149 @@ +/** + * Tests for useDiscovery hook + */ + +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useDiscovery } from '../useDiscovery'; +import { SchemaRendererContext } from '../../context/SchemaRendererContext'; + +function createWrapper(dataSource: any) { + return ({ children }: { children: React.ReactNode }) => + React.createElement( + SchemaRendererContext.Provider, + { value: { dataSource } }, + children, + ); +} + +describe('useDiscovery', () => { + it('returns isLoading false and null discovery when no context is provided', async () => { + const { result } = renderHook(() => useDiscovery()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.discovery).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('returns isLoading false and null discovery when dataSource has no getDiscovery method', async () => { + const dataSource = { someOtherMethod: vi.fn() }; + + const { result } = renderHook(() => useDiscovery(), { + wrapper: createWrapper(dataSource), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.discovery).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('successfully fetches discovery data from dataSource.getDiscovery()', async () => { + const discoveryData = { + name: 'test-server', + version: '1.0.0', + services: { + auth: { enabled: true, status: 'available' as const }, + data: { enabled: true, status: 'available' as const }, + }, + }; + + const dataSource = { + getDiscovery: vi.fn().mockResolvedValue(discoveryData), + }; + + const { result } = renderHook(() => useDiscovery(), { + wrapper: createWrapper(dataSource), + }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.discovery).toEqual(discoveryData); + expect(result.current.error).toBeNull(); + expect(dataSource.getDiscovery).toHaveBeenCalled(); + }); + + it('handles errors from getDiscovery gracefully', async () => { + const dataSource = { + getDiscovery: vi.fn().mockRejectedValue(new Error('Network error')), + }; + + const { result } = renderHook(() => useDiscovery(), { + wrapper: createWrapper(dataSource), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.discovery).toBeNull(); + expect(result.current.error?.message).toBe('Network error'); + }); + + it('isAuthEnabled defaults to true when no discovery', async () => { + const { result } = renderHook(() => useDiscovery()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isAuthEnabled).toBe(true); + }); + + it('isAuthEnabled reflects the auth service enabled state', async () => { + const discoveryData = { + services: { + auth: { enabled: false }, + }, + }; + + const dataSource = { + getDiscovery: vi.fn().mockResolvedValue(discoveryData), + }; + + const { result } = renderHook(() => useDiscovery(), { + wrapper: createWrapper(dataSource), + }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isAuthEnabled).toBe(false); + }); + + it('cleans up on unmount (cancelled flag)', async () => { + let resolveDiscovery: (value: any) => void; + const discoveryPromise = new Promise((resolve) => { + resolveDiscovery = resolve; + }); + + const dataSource = { + getDiscovery: vi.fn().mockReturnValue(discoveryPromise), + }; + + const { result, unmount } = renderHook(() => useDiscovery(), { + wrapper: createWrapper(dataSource), + }); + + expect(result.current.isLoading).toBe(true); + + unmount(); + + // Resolve after unmount — state should not update + resolveDiscovery!({ name: 'late-response' }); + + // Discovery should remain null since the component was unmounted + expect(result.current.discovery).toBeNull(); + }); +}); From 1a636c170508a5b1de6b44447cf631649d1fffcd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:17:51 +0000 Subject: [PATCH 05/13] Add tests for useActionRunner hook Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../hooks/__tests__/useActionRunner.test.ts | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 packages/react/src/hooks/__tests__/useActionRunner.test.ts diff --git a/packages/react/src/hooks/__tests__/useActionRunner.test.ts b/packages/react/src/hooks/__tests__/useActionRunner.test.ts new file mode 100644 index 000000000..96154ccb5 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useActionRunner.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for useActionRunner hook + */ + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useActionRunner } from '../useActionRunner'; + +describe('useActionRunner', () => { + it('returns initial state with loading=false, error=null, result=null', () => { + const { result } = renderHook(() => useActionRunner()); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.result).toBeNull(); + }); + + it('exposes runner, execute, and updateContext', () => { + const { result } = renderHook(() => useActionRunner()); + + expect(result.current.runner).toBeDefined(); + expect(typeof result.current.execute).toBe('function'); + expect(typeof result.current.updateContext).toBe('function'); + }); + + it('handles options with context key format', () => { + const { result } = renderHook(() => + useActionRunner({ context: { data: { name: 'test' } } }), + ); + + expect(result.current.runner).toBeDefined(); + expect(result.current.loading).toBe(false); + }); + + it('handles plain context object format (backwards compat)', () => { + const { result } = renderHook(() => + useActionRunner({ data: { name: 'test' } }), + ); + + expect(result.current.runner).toBeDefined(); + expect(result.current.loading).toBe(false); + }); + + it('sets loading=true during execution and loading=false after', async () => { + let resolveAction: () => void; + const pendingAction = new Promise((resolve) => { + resolveAction = resolve; + }); + + const { result } = renderHook(() => useActionRunner()); + + let executePromise: Promise; + act(() => { + executePromise = result.current.execute({ + onClick: () => pendingAction, + }); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(true); + }); + + await act(async () => { + resolveAction!(); + await executePromise; + }); + + expect(result.current.loading).toBe(false); + }); + + it('sets result on successful action execution', async () => { + const onNavigate = vi.fn(); + const { result } = renderHook(() => + useActionRunner({ context: {}, onNavigate }), + ); + + await act(async () => { + await result.current.execute({ + type: 'navigation', + navigate: { to: '/dashboard' }, + }); + }); + + expect(result.current.result).toEqual({ success: true }); + expect(result.current.error).toBeNull(); + expect(result.current.loading).toBe(false); + expect(onNavigate).toHaveBeenCalledWith( + '/dashboard', + expect.objectContaining({ external: false }), + ); + }); + + it('sets error on failed action execution (catch path)', async () => { + const { result } = renderHook(() => useActionRunner()); + + await act(async () => { + await result.current.execute({ + onClick: () => { + throw new Error('Something went wrong'); + }, + }); + }); + + expect(result.current.error).toBe('Something went wrong'); + expect(result.current.result).toEqual({ + success: false, + error: 'Something went wrong', + }); + expect(result.current.loading).toBe(false); + }); + + it('updateContext calls runner.updateContext', () => { + const { result } = renderHook(() => useActionRunner()); + + const spy = vi.spyOn(result.current.runner, 'updateContext'); + + act(() => { + result.current.updateContext({ data: { id: 42 } }); + }); + + expect(spy).toHaveBeenCalledWith({ data: { id: 42 } }); + }); + + it('accepts handler options (onToast, onNavigate, etc.)', async () => { + const onToast = vi.fn(); + const onNavigate = vi.fn(); + + const { result } = renderHook(() => + useActionRunner({ + context: {}, + onToast, + onNavigate, + }), + ); + + await act(async () => { + await result.current.execute({ + type: 'navigation', + navigate: { to: '/settings' }, + successMessage: 'Navigated!', + }); + }); + + expect(onNavigate).toHaveBeenCalledWith( + '/settings', + expect.objectContaining({ external: false }), + ); + expect(onToast).toHaveBeenCalledWith( + 'Navigated!', + expect.objectContaining({ type: 'success' }), + ); + }); +}); From dc56bd97f92644418f01948c731085ae46aad32b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:20:46 +0000 Subject: [PATCH 06/13] Add ActionContext tests for ActionProvider, useAction, and useHasActionProvider Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../context/__tests__/ActionContext.test.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 packages/react/src/context/__tests__/ActionContext.test.tsx diff --git a/packages/react/src/context/__tests__/ActionContext.test.tsx b/packages/react/src/context/__tests__/ActionContext.test.tsx new file mode 100644 index 000000000..aa05db73b --- /dev/null +++ b/packages/react/src/context/__tests__/ActionContext.test.tsx @@ -0,0 +1,174 @@ +/** + * Tests for ActionContext — ActionProvider, useAction, useHasActionProvider + */ + +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import { ActionProvider, useAction, useHasActionProvider } from '../ActionContext'; + +describe('ActionContext', () => { + describe('useAction without ActionProvider', () => { + it('falls back to local ActionRunner with default state', () => { + const { result } = renderHook(() => useAction()); + + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.result).toBeNull(); + expect(typeof result.current.execute).toBe('function'); + expect(typeof result.current.executeChain).toBe('function'); + expect(typeof result.current.updateContext).toBe('function'); + expect(result.current.runner).toBeDefined(); + }); + }); + + describe('useHasActionProvider', () => { + it('returns false without provider', () => { + const { result } = renderHook(() => useHasActionProvider()); + + expect(result.current).toBe(false); + }); + + it('returns true with provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(ActionProvider, { context: {} }, children); + + const { result } = renderHook(() => useHasActionProvider(), { wrapper }); + + expect(result.current).toBe(true); + }); + }); + + describe('ActionProvider basic', () => { + it('children can access execute function', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(ActionProvider, { context: {} }, children); + + const { result } = renderHook(() => useAction(), { wrapper }); + + expect(typeof result.current.execute).toBe('function'); + expect(typeof result.current.executeChain).toBe('function'); + expect(typeof result.current.updateContext).toBe('function'); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.result).toBeNull(); + }); + }); + + describe('ActionProvider execute', () => { + it('executes a toast action and returns result', async () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(ActionProvider, { context: {} }, children); + + const { result } = renderHook(() => useAction(), { wrapper }); + + let actionResult: any; + await act(async () => { + actionResult = await result.current.execute({ + type: 'script', + execute: 'true', + toast: { showOnSuccess: true }, + successMessage: 'Done!', + }); + }); + + expect(actionResult.success).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.result).toEqual(actionResult); + expect(result.current.error).toBeNull(); + }); + }); + + describe('ActionProvider execute error', () => { + it('executes an action that fails and sets error state', async () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(ActionProvider, { context: {} }, children); + + const { result } = renderHook(() => useAction(), { wrapper }); + + let actionResult: any; + await act(async () => { + actionResult = await result.current.execute({ + onClick: vi.fn().mockRejectedValue(new Error('boom')), + }); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(actionResult.success).toBe(false); + expect(result.current.error).toBeTruthy(); + expect(result.current.result?.success).toBe(false); + }); + }); + + describe('ActionProvider executeChain', () => { + it('executes a chain of actions', async () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(ActionProvider, { context: {} }, children); + + const { result } = renderHook(() => useAction(), { wrapper }); + + let chainResult: any; + await act(async () => { + chainResult = await result.current.executeChain([ + { type: 'script', execute: 'true' }, + { type: 'script', execute: 'true' }, + ]); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(chainResult.success).toBe(true); + expect(result.current.result).toEqual(chainResult); + expect(result.current.error).toBeNull(); + }); + }); + + describe('ActionProvider updateContext', () => { + it('updateContext is callable', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(ActionProvider, { context: {} }, children); + + const { result } = renderHook(() => useAction(), { wrapper }); + + expect(() => { + result.current.updateContext({ record: { id: 1 } }); + }).not.toThrow(); + }); + }); + + describe('ActionProvider with handlers', () => { + it('invokes onToast handler when a toast action is executed', async () => { + const onToast = vi.fn(); + const onNavigate = vi.fn(); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement( + ActionProvider, + { context: {}, onToast, onNavigate }, + children, + ); + + const { result } = renderHook(() => useAction(), { wrapper }); + + await act(async () => { + await result.current.execute({ + type: 'script', + execute: 'true', + toast: { showOnSuccess: true }, + successMessage: 'Saved!', + }); + }); + + expect(onToast).toHaveBeenCalledWith('Saved!', expect.objectContaining({ type: 'success' })); + }); + }); +}); From 7454ba77cd9f02774076868ceca93936a7817964 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:22:37 +0000 Subject: [PATCH 07/13] Add provider.test.tsx for @object-ui/i18n React integration Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/i18n/src/__tests__/provider.test.tsx | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/i18n/src/__tests__/provider.test.tsx diff --git a/packages/i18n/src/__tests__/provider.test.tsx b/packages/i18n/src/__tests__/provider.test.tsx new file mode 100644 index 000000000..62007f164 --- /dev/null +++ b/packages/i18n/src/__tests__/provider.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider, useObjectTranslation, useI18nContext } from '../provider'; +import { createI18n } from '../i18n'; + +describe('I18nProvider', () => { + it('creates i18n instance from config', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children); + + const { result } = renderHook(() => useObjectTranslation(), { wrapper }); + + expect(result.current.i18n).toBeDefined(); + expect(result.current.language).toBe('en'); + }); + + it('accepts pre-created instance', () => { + const instance = createI18n({ defaultLanguage: 'fr', detectBrowserLanguage: false }); + + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(I18nProvider, { instance }, children); + + const { result } = renderHook(() => useObjectTranslation(), { wrapper }); + + expect(result.current.i18n).toBeDefined(); + expect(result.current.language).toBe('fr'); + }); +}); + +describe('useObjectTranslation', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children); + + it('returns t, language, changeLanguage, direction, i18n', () => { + const { result } = renderHook(() => useObjectTranslation(), { wrapper }); + + expect(result.current.t).toBeTypeOf('function'); + expect(result.current.language).toBeTypeOf('string'); + expect(result.current.changeLanguage).toBeTypeOf('function'); + expect(result.current.direction).toBeTypeOf('string'); + expect(result.current.i18n).toBeDefined(); + }); + + it('translates keys correctly', () => { + const { result } = renderHook(() => useObjectTranslation(), { wrapper }); + + expect(result.current.t('common.save')).toBe('Save'); + expect(result.current.t('common.cancel')).toBe('Cancel'); + expect(result.current.t('common.delete')).toBe('Delete'); + }); + + it('returns en as default language', () => { + const { result } = renderHook(() => useObjectTranslation(), { wrapper }); + + expect(result.current.language).toBe('en'); + }); + + it('works with Chinese language', () => { + const zhWrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(I18nProvider, { config: { defaultLanguage: 'zh', detectBrowserLanguage: false } }, children); + + const { result } = renderHook(() => useObjectTranslation(), { wrapper: zhWrapper }); + + expect(result.current.language).toBe('zh'); + expect(result.current.t('common.save')).toBe('保存'); + expect(result.current.t('common.cancel')).toBe('取消'); + }); + + it('changeLanguage updates language', async () => { + const { result } = renderHook(() => useObjectTranslation(), { wrapper }); + + expect(result.current.language).toBe('en'); + + await act(async () => { + await result.current.changeLanguage('zh'); + }); + + await waitFor(() => { + expect(result.current.language).toBe('zh'); + }); + }); + + it('returns RTL direction for Arabic', () => { + const arWrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(I18nProvider, { config: { defaultLanguage: 'ar', detectBrowserLanguage: false } }, children); + + const { result } = renderHook(() => useObjectTranslation(), { wrapper: arWrapper }); + + expect(result.current.direction).toBe('rtl'); + }); +}); + +describe('useI18nContext', () => { + it('throws when used outside provider', () => { + // Suppress console.error from React for the expected error + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useI18nContext()); + }).toThrow('useI18nContext must be used within an I18nProvider'); + + spy.mockRestore(); + }); + + it('returns context inside provider', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(I18nProvider, { config: { defaultLanguage: 'en', detectBrowserLanguage: false } }, children); + + const { result } = renderHook(() => useI18nContext(), { wrapper }); + + expect(result.current.language).toBe('en'); + expect(result.current.changeLanguage).toBeTypeOf('function'); + expect(result.current.direction).toBe('ltr'); + expect(result.current.i18n).toBeDefined(); + }); +}); From 9e964d14788a52893711a996cefd48737d37bf9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:24:47 +0000 Subject: [PATCH 08/13] Add test coverage for LazyPluginLoader: preloadPlugin, errorFallback, retries=0, retry-then-succeed Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/LazyPluginLoader.test.tsx | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/react/src/__tests__/LazyPluginLoader.test.tsx b/packages/react/src/__tests__/LazyPluginLoader.test.tsx index 7b1c1ab32..9c677a2e6 100644 --- a/packages/react/src/__tests__/LazyPluginLoader.test.tsx +++ b/packages/react/src/__tests__/LazyPluginLoader.test.tsx @@ -9,7 +9,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { createLazyPlugin } from '../LazyPluginLoader'; +import { createLazyPlugin, preloadPlugin } from '../LazyPluginLoader'; describe('createLazyPlugin', () => { it('should create a lazy-loaded component', async () => { @@ -77,4 +77,100 @@ describe('createLazyPlugin', () => { // Restore console.error console.error = originalError; }); + + it('should preload a plugin without rendering', async () => { + const TestComponent = () =>
Preloaded
; + const importFn = vi.fn(() => Promise.resolve({ default: TestComponent })); + + const result = await preloadPlugin(importFn); + + expect(importFn).toHaveBeenCalledTimes(1); + expect(result).toEqual({ default: TestComponent }); + }); + + it('should render errorFallback when component fails to load', async () => { + const originalError = console.error; + console.error = vi.fn(); + + const ErrorFallback = ({ error, retry }: { error: Error; retry: () => void }) => ( +
+ Error: {error.message} + +
+ ); + + const LazyComponent = createLazyPlugin( + () => Promise.reject(new Error('chunk failed')), + { retries: 0, errorFallback: ErrorFallback } + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Error: chunk failed')).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + + console.error = originalError; + }); + + it('should not retry when retries=0', async () => { + const originalError = console.error; + console.error = vi.fn(); + + const importFn = vi.fn(() => Promise.reject(new Error('fail'))); + + const ErrorFallback = ({ error }: { error: Error; retry: () => void }) => ( +
Error: {error.message}
+ ); + + const LazyComponent = createLazyPlugin(importFn, { + retries: 0, + errorFallback: ErrorFallback, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Error: fail')).toBeInTheDocument(); + }); + + // Called only once — no retries + expect(importFn).toHaveBeenCalledTimes(1); + + console.error = originalError; + }); + + it('should retry on failure then succeed', async () => { + const originalError = console.error; + console.error = vi.fn(); + + const TestComponent = () =>
Success after retry
; + + let callCount = 0; + const importFn = vi.fn(() => { + callCount++; + if (callCount <= 2) { + return Promise.reject(new Error('transient error')); + } + return Promise.resolve({ default: TestComponent as React.ComponentType }); + }); + + const LazyComponent = createLazyPlugin(importFn, { + retries: 3, + retryDelay: 10, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Success after retry')).toBeInTheDocument(); + }, { timeout: 5000 }); + + // First call + 2 retries (fails) + 1 success = called 3 times + expect(importFn).toHaveBeenCalledTimes(3); + + console.error = originalError; + }); }); From eb91925cbc6c41913e40b23109bc3c398fe718a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:29:00 +0000 Subject: [PATCH 09/13] Add test file for timeline renderer component (packages/plugin-timeline/src/__tests__/renderer.test.tsx) Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/renderer.test.tsx | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 packages/plugin-timeline/src/__tests__/renderer.test.tsx diff --git a/packages/plugin-timeline/src/__tests__/renderer.test.tsx b/packages/plugin-timeline/src/__tests__/renderer.test.tsx new file mode 100644 index 000000000..34152b8a5 --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/renderer.test.tsx @@ -0,0 +1,295 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; + +// Import renderer to trigger registration +import '../renderer'; + +// Mock renderChildren used by the renderer, preserving cn and other exports +vi.mock('@object-ui/components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderChildren: vi.fn(() => null), + }; +}); + +describe('Timeline Renderer (ComponentRegistry)', () => { + let TimelineComponent: any; + + beforeEach(() => { + TimelineComponent = ComponentRegistry.get('timeline'); + }); + + it('is registered in ComponentRegistry as "timeline"', () => { + expect(TimelineComponent).toBeDefined(); + }); + + // --- Vertical variant --- + + it('renders in vertical variant by default', () => { + const schema: any = { + type: 'timeline', + items: [ + { time: '2024-01-15', title: 'First Event', description: 'Description one' }, + ], + }; + + const { container } = render(); + // Vertical uses an
    element + expect(container.querySelector('ol')).toBeInTheDocument(); + expect(screen.getByText('First Event')).toBeInTheDocument(); + expect(screen.getByText('Description one')).toBeInTheDocument(); + }); + + it('renders items with titles and descriptions in vertical variant', () => { + const schema: any = { + type: 'timeline', + variant: 'vertical', + items: [ + { time: '2024-01-15', title: 'Alpha Release', description: 'Initial alpha' }, + { time: '2024-02-01', title: 'Beta Release', description: 'Public beta' }, + { time: '2024-03-10', title: 'GA Launch', description: 'General availability' }, + ], + }; + + render(); + expect(screen.getByText('Alpha Release')).toBeInTheDocument(); + expect(screen.getByText('Initial alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta Release')).toBeInTheDocument(); + expect(screen.getByText('Public beta')).toBeInTheDocument(); + expect(screen.getByText('GA Launch')).toBeInTheDocument(); + expect(screen.getByText('General availability')).toBeInTheDocument(); + }); + + it('handles empty items array in vertical variant', () => { + const schema: any = { + type: 'timeline', + variant: 'vertical', + items: [], + }; + + const { container } = render(); + // Should render the
      wrapper but no
    1. items + expect(container.querySelector('ol')).toBeInTheDocument(); + expect(container.querySelectorAll('li')).toHaveLength(0); + }); + + it('formats dates correctly with short format', () => { + const schema: any = { + type: 'timeline', + variant: 'vertical', + dateFormat: 'short', + items: [ + { time: '2024-06-15', title: 'Event' }, + ], + }; + + render(); + // short format uses toLocaleDateString() + const expected = new Date('2024-06-15').toLocaleDateString(); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + it('formats dates correctly with long format', () => { + const schema: any = { + type: 'timeline', + variant: 'vertical', + dateFormat: 'long', + items: [ + { time: '2024-06-15', title: 'Event' }, + ], + }; + + render(); + const expected = new Date('2024-06-15').toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + it('formats dates correctly with iso (default) format', () => { + const schema: any = { + type: 'timeline', + variant: 'vertical', + dateFormat: 'iso', + items: [ + { time: '2024-06-15', title: 'Event' }, + ], + }; + + render(); + expect(screen.getByText('2024-06-15')).toBeInTheDocument(); + }); + + // --- Horizontal variant --- + + it('renders in horizontal variant', () => { + const schema: any = { + type: 'timeline', + variant: 'horizontal', + items: [ + { time: '2024-01-01', title: 'Q1', description: 'First quarter' }, + { time: '2024-04-01', title: 'Q2', description: 'Second quarter' }, + ], + }; + + render(); + expect(screen.getByText('Q1')).toBeInTheDocument(); + expect(screen.getByText('First quarter')).toBeInTheDocument(); + expect(screen.getByText('Q2')).toBeInTheDocument(); + expect(screen.getByText('Second quarter')).toBeInTheDocument(); + }); + + it('handles empty items array in horizontal variant', () => { + const schema: any = { + type: 'timeline', + variant: 'horizontal', + items: [], + }; + + const { container } = render(); + // Horizontal wraps in a div, should have no child items + expect(container.firstChild).toBeInTheDocument(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); + + // --- Gantt variant --- + + it('renders in gantt variant', () => { + const schema: any = { + type: 'timeline', + variant: 'gantt', + timeScale: 'month', + items: [ + { + label: 'Backend', + items: [ + { title: 'API Design', startDate: '2024-01-01', endDate: '2024-02-01' }, + ], + }, + { + label: 'Frontend', + items: [ + { title: 'UI Build', startDate: '2024-02-01', endDate: '2024-03-01' }, + ], + }, + ], + }; + + render(); + expect(screen.getByText('Backend')).toBeInTheDocument(); + expect(screen.getByText('Frontend')).toBeInTheDocument(); + expect(screen.getByText('API Design')).toBeInTheDocument(); + expect(screen.getByText('UI Build')).toBeInTheDocument(); + }); + + it('handles rows with no sub-items in gantt variant', () => { + const schema: any = { + type: 'timeline', + variant: 'gantt', + minDate: '2024-01-01', + maxDate: '2024-03-01', + items: [ + { label: 'Empty Row', items: [] }, + { + label: 'Has Items', + items: [ + { title: 'Task', startDate: '2024-01-15', endDate: '2024-02-15' }, + ], + }, + ], + }; + + render(); + expect(screen.getByText('Empty Row')).toBeInTheDocument(); + expect(screen.getByText('Has Items')).toBeInTheDocument(); + }); + + it('gantt variant calculates date ranges from items', () => { + const schema: any = { + type: 'timeline', + variant: 'gantt', + timeScale: 'month', + items: [ + { + label: 'Project A', + items: [ + { title: 'Task 1', startDate: '2024-01-01', endDate: '2024-01-31' }, + { title: 'Task 2', startDate: '2024-02-01', endDate: '2024-03-31' }, + ], + }, + ], + }; + + render(); + // Gantt header should contain month labels spanning the range + expect(screen.getByText('Project A')).toBeInTheDocument(); + expect(screen.getByText('Task 1')).toBeInTheDocument(); + expect(screen.getByText('Task 2')).toBeInTheDocument(); + }); + + it('gantt variant uses custom rowLabel', () => { + const schema: any = { + type: 'timeline', + variant: 'gantt', + rowLabel: 'Projects', + minDate: '2024-01-01', + maxDate: '2024-03-01', + items: [ + { + label: 'My Project', + items: [ + { title: 'Work', startDate: '2024-01-15', endDate: '2024-02-15' }, + ], + }, + ], + }; + + render(); + expect(screen.getByText('Projects')).toBeInTheDocument(); + }); + + it('gantt variant defaults rowLabel to "Items"', () => { + const schema: any = { + type: 'timeline', + variant: 'gantt', + minDate: '2024-01-01', + maxDate: '2024-03-01', + items: [ + { + label: 'Row', + items: [ + { title: 'Bar', startDate: '2024-01-10', endDate: '2024-02-10' }, + ], + }, + ], + }; + + render(); + expect(screen.getByText('Items')).toBeInTheDocument(); + }); + + it('returns null for unknown variant', () => { + const schema: any = { + type: 'timeline', + variant: 'unknown', + items: [], + }; + + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); +}); From a4ae6dcc536ad166ae608697d29625b2b7cb11c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:31:40 +0000 Subject: [PATCH 10/13] Add SortUI component test suite Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugin-view/src/__tests__/SortUI.test.tsx | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 packages/plugin-view/src/__tests__/SortUI.test.tsx diff --git a/packages/plugin-view/src/__tests__/SortUI.test.tsx b/packages/plugin-view/src/__tests__/SortUI.test.tsx new file mode 100644 index 000000000..6ff1a998a --- /dev/null +++ b/packages/plugin-view/src/__tests__/SortUI.test.tsx @@ -0,0 +1,380 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SortUI } from '../SortUI'; +import type { SortUISchema } from '@object-ui/types'; + +// --------------------------------------------------------------------------- +// Mock @object-ui/components – provide lightweight stand-ins for Shadcn +// primitives so tests render without a full component tree. +// --------------------------------------------------------------------------- +vi.mock('@object-ui/components', () => { + const cn = (...args: any[]) => args.filter(Boolean).join(' '); + + const Button = ({ children, onClick, variant, ...rest }: any) => ( + + ); + + const Select = ({ children, value, onValueChange }: any) => ( +
      + {typeof children === 'function' + ? children({ value, onValueChange }) + : children} +
      + ); + + const SelectTrigger = ({ children, className }: any) => ( + + ); + + const SelectValue = ({ placeholder }: any) => ( + {placeholder} + ); + + const SelectContent = ({ children }: any) => ( +
      {children}
      + ); + + const SelectItem = ({ children, value }: any) => ( +
      + {children} +
      + ); + + const SortBuilder = ({ fields, value, onChange }: any) => ( +
      + +
      + ); + + return { cn, Button, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SortBuilder }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const baseFields: SortUISchema['fields'] = [ + { field: 'name', label: 'Name' }, + { field: 'date', label: 'Date' }, +]; + +const makeSchema = (overrides: Partial = {}): SortUISchema => ({ + type: 'sort-ui', + fields: baseFields, + ...overrides, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('SortUI', () => { + // ------------------------------------------------------------------------- + // 1. Renders with default (buttons) variant + // ------------------------------------------------------------------------- + describe('buttons variant', () => { + it('renders sort buttons for each field', () => { + render(); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Date')).toBeInTheDocument(); + }); + + it('renders all fields as outline buttons when no sort is active', () => { + render(); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + buttons.forEach(btn => { + expect(btn).toHaveAttribute('data-variant', 'outline'); + }); + }); + + it('highlights active sort field with secondary variant', () => { + render( + , + ); + + const nameBtn = screen.getByText('Name').closest('button')!; + expect(nameBtn).toHaveAttribute('data-variant', 'secondary'); + + const dateBtn = screen.getByText('Date').closest('button')!; + expect(dateBtn).toHaveAttribute('data-variant', 'outline'); + }); + + it('cycles through asc → desc → removed on repeated clicks', () => { + const onChange = vi.fn(); + render( + , + ); + + const nameBtn = screen.getByText('Name').closest('button')!; + + // First click: activate asc + fireEvent.click(nameBtn); + expect(onChange).toHaveBeenCalledWith([{ field: 'name', direction: 'asc' }]); + }); + }); + + // ------------------------------------------------------------------------- + // 2. Renders with dropdown variant + // ------------------------------------------------------------------------- + describe('dropdown variant', () => { + it('renders select elements for field and direction', () => { + render(); + + const selectRoots = screen.getAllByTestId('select-root'); + expect(selectRoots.length).toBe(2); + }); + + it('renders field options inside select', () => { + render(); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Date')).toBeInTheDocument(); + }); + + it('renders direction options (Ascending / Descending)', () => { + render(); + + expect(screen.getByText('Ascending')).toBeInTheDocument(); + expect(screen.getByText('Descending')).toBeInTheDocument(); + }); + + it('defaults to dropdown when variant is omitted', () => { + render(); + + // dropdown renders select-root elements, not buttons + const selectRoots = screen.getAllByTestId('select-root'); + expect(selectRoots.length).toBe(2); + }); + }); + + // ------------------------------------------------------------------------- + // 3. Renders with builder variant (multiple = true) + // ------------------------------------------------------------------------- + describe('builder variant (multiple)', () => { + it('renders SortBuilder when multiple is true', () => { + render( + , + ); + + expect(screen.getByTestId('sort-builder')).toBeInTheDocument(); + }); + + it('passes fields and value to SortBuilder', () => { + render( + , + ); + + const builder = screen.getByTestId('sort-builder'); + const fields = JSON.parse(builder.getAttribute('data-fields')!); + expect(fields).toEqual([ + { value: 'name', label: 'Name' }, + { value: 'date', label: 'Date' }, + ]); + + const value = JSON.parse(builder.getAttribute('data-value')!); + expect(value).toEqual([ + { id: 'name-asc', field: 'name', order: 'asc' }, + ]); + }); + + it('calls onChange when SortBuilder triggers a change', () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByTestId('sort-builder-change')); + expect(onChange).toHaveBeenCalledWith([{ field: 'date', direction: 'desc' }]); + }); + }); + + // ------------------------------------------------------------------------- + // 4. Initial sort configuration from schema + // ------------------------------------------------------------------------- + describe('initial sort from schema', () => { + it('initialises state from schema.sort in buttons variant', () => { + render( + , + ); + + const dateBtn = screen.getByText('Date').closest('button')!; + expect(dateBtn).toHaveAttribute('data-variant', 'secondary'); + }); + + it('renders without error when schema.sort is undefined', () => { + render(); + + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); + }); + + it('renders without error when schema.sort is empty', () => { + render( + , + ); + + const buttons = screen.getAllByRole('button'); + buttons.forEach(btn => { + expect(btn).toHaveAttribute('data-variant', 'outline'); + }); + }); + }); + + // ------------------------------------------------------------------------- + // 5. onChange callback + // ------------------------------------------------------------------------- + describe('onChange callback', () => { + it('fires onChange when a button sort is toggled', () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText('Name').closest('button')!); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith([{ field: 'name', direction: 'asc' }]); + }); + + it('dispatches custom window event when schema.onChange is set', () => { + const spy = vi.fn(); + window.addEventListener('sort:changed', spy); + + render( + , + ); + + fireEvent.click(screen.getByText('Name').closest('button')!); + expect(spy).toHaveBeenCalledTimes(1); + + const detail = (spy.mock.calls[0][0] as CustomEvent).detail; + expect(detail).toEqual({ sort: [{ field: 'name', direction: 'asc' }] }); + + window.removeEventListener('sort:changed', spy); + }); + + it('replaces active sort when multiple is false (buttons)', () => { + const onChange = vi.fn(); + render( + , + ); + + // Click a different field — should replace, not append + fireEvent.click(screen.getByText('Date').closest('button')!); + expect(onChange).toHaveBeenCalledWith([{ field: 'date', direction: 'asc' }]); + }); + }); + + // ------------------------------------------------------------------------- + // 6. Helper functions (toSortEntries / toSortItems) – tested indirectly + // ------------------------------------------------------------------------- + describe('helper functions (toSortEntries / toSortItems)', () => { + it('toSortEntries: maps schema.sort to internal state shown via button variant', () => { + render( + , + ); + + // Both fields should be highlighted since both are in the sort config + const nameBtn = screen.getByText('Name').closest('button')!; + const dateBtn = screen.getByText('Date').closest('button')!; + expect(nameBtn).toHaveAttribute('data-variant', 'secondary'); + expect(dateBtn).toHaveAttribute('data-variant', 'secondary'); + }); + + it('toSortItems: maps sort entries to SortBuilder items', () => { + render( + , + ); + + const builder = screen.getByTestId('sort-builder'); + const value = JSON.parse(builder.getAttribute('data-value')!); + expect(value).toEqual([ + { id: 'name-asc', field: 'name', order: 'asc' }, + { id: 'date-desc', field: 'date', order: 'desc' }, + ]); + }); + + it('toSortEntries: returns empty array when sort is undefined', () => { + render( + , + ); + + // No button should have secondary variant + const buttons = screen.getAllByRole('button'); + buttons.forEach(btn => { + expect(btn).toHaveAttribute('data-variant', 'outline'); + }); + }); + }); +}); From 5ad981ceb9449cdeb1afc3b7a4f78a347a201a32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:35:08 +0000 Subject: [PATCH 11/13] Add FilterUI component tests covering rendering, values, layout, and callbacks Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/FilterUI.test.tsx | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 packages/plugin-view/src/__tests__/FilterUI.test.tsx diff --git a/packages/plugin-view/src/__tests__/FilterUI.test.tsx b/packages/plugin-view/src/__tests__/FilterUI.test.tsx new file mode 100644 index 000000000..73297ebad --- /dev/null +++ b/packages/plugin-view/src/__tests__/FilterUI.test.tsx @@ -0,0 +1,539 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { FilterUI } from '../FilterUI'; +import type { FilterUISchema } from '@object-ui/types'; + +// --------------------------------------------------------------------------- +// Mock @object-ui/components – provide lightweight stand-ins for Shadcn +// primitives so tests render without a full component tree. +// --------------------------------------------------------------------------- +vi.mock('@object-ui/components', () => { + const cn = (...args: any[]) => args.filter(Boolean).join(' '); + + const Button = ({ children, onClick, variant, size, type, ...rest }: any) => ( + + ); + + const Input = ({ value, onChange, placeholder, type, ...rest }: any) => ( + + ); + + const Label = ({ children, className }: any) => ( + + ); + + const Checkbox = ({ checked, onCheckedChange }: any) => ( + onCheckedChange?.(e.target.checked)} + /> + ); + + // Store onValueChange callbacks by a global map keyed on a unique id + let selectCallback: ((v: string) => void) | undefined; + + const Select = ({ children, value, onValueChange }: any) => { + selectCallback = onValueChange; + return ( +
      + {children} +
      + ); + }; + + const SelectTrigger = ({ children }: any) => ( + + ); + + const SelectValue = ({ placeholder }: any) => ( + {placeholder} + ); + + const SelectContent = ({ children }: any) => ( +
      {children}
      + ); + + const SelectItem = ({ children, value }: any) => ( +
      selectCallback?.(String(value))} + > + {children} +
      + ); + + const Popover = ({ children, open }: any) => ( +
      + {children} +
      + ); + + const PopoverTrigger = ({ children }: any) => ( +
      {children}
      + ); + + const PopoverContent = ({ children }: any) => ( +
      {children}
      + ); + + const Drawer = ({ children, open }: any) => ( +
      + {children} +
      + ); + + const DrawerContent = ({ children }: any) => ( +
      {children}
      + ); + + const DrawerHeader = ({ children }: any) => ( +
      {children}
      + ); + + const DrawerTitle = ({ children }: any) => ( +

      {children}

      + ); + + const DrawerDescription = ({ children }: any) => ( +

      {children}

      + ); + + return { + cn, + Button, + Input, + Label, + Checkbox, + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, + Popover, + PopoverTrigger, + PopoverContent, + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerDescription, + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const baseFilters: FilterUISchema['filters'] = [ + { + field: 'status', + label: 'Status', + type: 'select', + options: [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + ], + }, + { field: 'name', label: 'Name', type: 'text' }, +]; + +const makeSchema = (overrides: Partial = {}): FilterUISchema => ({ + type: 'filter-ui', + filters: baseFilters, + ...overrides, +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('FilterUI', () => { + // ------------------------------------------------------------------------- + // 1. Renders with inline layout + // ------------------------------------------------------------------------- + describe('inline layout', () => { + it('renders the filter form inline by default', () => { + const { container } = render(); + + // Labels should be visible + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + + // No popover or drawer elements + expect(screen.queryByTestId('popover')).not.toBeInTheDocument(); + expect(screen.queryByTestId('drawer')).not.toBeInTheDocument(); + }); + + it('renders with explicit inline layout', () => { + render(); + + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // 2. Renders filter fields + // ------------------------------------------------------------------------- + describe('filter fields', () => { + it('renders a label for each filter', () => { + render(); + + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + it('falls back to field name when label is omitted', () => { + render( + , + ); + + expect(screen.getByText('email')).toBeInTheDocument(); + }); + + it('renders select options for select-type filters', () => { + render(); + + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); + }); + + it('renders a text input for text-type filters', () => { + render( + , + ); + + const input = screen.getByPlaceholderText('Filter by Search'); + expect(input).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // 3. Handles text filter values + // ------------------------------------------------------------------------- + describe('text filter values', () => { + it('calls onChange when a text input is changed', () => { + const onChange = vi.fn(); + render( + , + ); + + const input = screen.getByPlaceholderText('Filter by Name'); + fireEvent.change(input, { target: { value: 'Alice' } }); + + expect(onChange).toHaveBeenCalledWith({ name: 'Alice' }); + }); + + it('renders with pre-set values from schema', () => { + render( + , + ); + + const input = screen.getByPlaceholderText('Filter by Name') as HTMLInputElement; + expect(input.value).toBe('Bob'); + }); + }); + + // ------------------------------------------------------------------------- + // 4. Handles select filter values + // ------------------------------------------------------------------------- + describe('select filter values', () => { + it('calls onChange when a select value is changed', () => { + const onChange = vi.fn(); + render( + , + ); + + const activeOption = screen.getByText('Active'); + fireEvent.click(activeOption); + + expect(onChange).toHaveBeenCalledWith({ status: 'active' }); + }); + + it('renders with pre-set select value from schema', () => { + render( + , + ); + + const selectRoot = screen.getAllByTestId('select-root')[0]; + expect(selectRoot).toHaveAttribute('data-value', 'inactive'); + }); + }); + + // ------------------------------------------------------------------------- + // 5. Renders with popover layout + // ------------------------------------------------------------------------- + describe('popover layout', () => { + it('renders a Filters button with popover wrapper', () => { + render(); + + expect(screen.getByTestId('popover')).toBeInTheDocument(); + expect(screen.getByText('Filters')).toBeInTheDocument(); + }); + + it('renders the filter form inside popover content', () => { + render(); + + const popoverContent = screen.getByTestId('popover-content'); + expect(popoverContent).toBeInTheDocument(); + + // Filter labels should still be rendered within popover + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + it('shows active filter count badge when filters have values', () => { + render( + , + ); + + // The active count should be rendered + expect(screen.getByText('1')).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // 6. Renders with empty / no filters + // ------------------------------------------------------------------------- + describe('empty / no filters', () => { + it('renders without error when filters array is empty', () => { + render(); + + // Should not throw; no labels rendered + expect(screen.queryByText('Status')).not.toBeInTheDocument(); + expect(screen.queryByText('Name')).not.toBeInTheDocument(); + }); + + it('renders without error when values are empty', () => { + render(); + + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + it('renders without error when values are undefined', () => { + render(); + + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // 7. isEmptyValue edge cases (tested indirectly via active count badge) + // ------------------------------------------------------------------------- + describe('isEmptyValue edge cases', () => { + it('treats null as empty — no active count badge', () => { + render( + , + ); + + // No badge with count should appear + expect(screen.queryByText('1')).not.toBeInTheDocument(); + }); + + it('treats undefined as empty — no active count badge', () => { + render( + , + ); + + expect(screen.queryByText('1')).not.toBeInTheDocument(); + }); + + it('treats empty string as empty — no active count badge', () => { + render( + , + ); + + expect(screen.queryByText('1')).not.toBeInTheDocument(); + }); + + it('treats empty array as empty — no active count badge', () => { + render( + , + ); + + expect(screen.queryByText('1')).not.toBeInTheDocument(); + }); + + it('counts non-empty values for the active count badge', () => { + render( + , + ); + + expect(screen.getByText('2')).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // 8. onChange callback + // ------------------------------------------------------------------------- + describe('onChange callback', () => { + it('fires onChange immediately when showApply is not set', () => { + const onChange = vi.fn(); + render( + , + ); + + const input = screen.getByPlaceholderText('Filter by Name'); + fireEvent.change(input, { target: { value: 'Test' } }); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ name: 'Test' }); + }); + + it('defers onChange until Apply is clicked when showApply is true', () => { + const onChange = vi.fn(); + render( + , + ); + + const input = screen.getByPlaceholderText('Filter by Name'); + fireEvent.change(input, { target: { value: 'Deferred' } }); + + // onChange should NOT have been called yet + expect(onChange).not.toHaveBeenCalled(); + + // Click Apply + fireEvent.click(screen.getByText('Apply')); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ name: 'Deferred' }); + }); + + it('clears all values when Clear is clicked', () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText('Clear')); + expect(onChange).toHaveBeenCalledWith({}); + }); + + it('dispatches custom window event when schema.onChange is set', () => { + const spy = vi.fn(); + window.addEventListener('filter:changed', spy); + + render( + , + ); + + const input = screen.getByPlaceholderText('Filter by Name'); + fireEvent.change(input, { target: { value: 'Event' } }); + + expect(spy).toHaveBeenCalledTimes(1); + const detail = (spy.mock.calls[0][0] as CustomEvent).detail; + expect(detail).toEqual({ values: { name: 'Event' } }); + + window.removeEventListener('filter:changed', spy); + }); + }); +}); From 508bcc2cad02865960608dba091a4f3fd2fc9d31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:41:29 +0000 Subject: [PATCH 12/13] Add comprehensive VirtualGrid test suite in __tests__/VirtualGrid.test.tsx Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/__tests__/VirtualGrid.test.tsx | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 packages/plugin-grid/src/__tests__/VirtualGrid.test.tsx diff --git a/packages/plugin-grid/src/__tests__/VirtualGrid.test.tsx b/packages/plugin-grid/src/__tests__/VirtualGrid.test.tsx new file mode 100644 index 000000000..00aab3ff3 --- /dev/null +++ b/packages/plugin-grid/src/__tests__/VirtualGrid.test.tsx @@ -0,0 +1,438 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { VirtualGridColumn, VirtualGridProps } from '../VirtualGrid'; + +// --- Mock @tanstack/react-virtual --- +// The vitest setup file pre-loads @object-ui/plugin-grid which caches the +// real virtualizer. We call vi.resetModules() in beforeEach so that the +// dynamic import of VirtualGrid picks up our mock instead. +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (opts: any) => { + const count: number = opts.count; + const size: number = opts.estimateSize(); + const items = []; + for (let i = 0; i < count; i++) { + items.push({ index: i, key: String(i), start: i * size, size }); + } + return { + getVirtualItems: () => items, + getTotalSize: () => count * size, + }; + }, +})); + +// --- Test helpers --- +const sampleColumns: VirtualGridColumn[] = [ + { header: 'Name', accessorKey: 'name' }, + { header: 'Email', accessorKey: 'email' }, + { header: 'Age', accessorKey: 'age' }, +]; + +const sampleData = [ + { name: 'Alice', email: 'alice@test.com', age: 30 }, + { name: 'Bob', email: 'bob@test.com', age: 25 }, + { name: 'Charlie', email: 'charlie@test.com', age: 40 }, +]; + +type VirtualGridComponent = React.FC; + +let VirtualGrid: VirtualGridComponent; + +beforeEach(async () => { + cleanup(); + vi.resetModules(); + const mod = await import('../VirtualGrid'); + VirtualGrid = mod.VirtualGrid; +}); + +function renderGrid(overrides: Partial = {}) { + const props: VirtualGridProps = { + data: sampleData, + columns: sampleColumns, + ...overrides, + }; + return render(); +} + +// ========================================================================= +// 1. Basic rendering +// ========================================================================= +describe('VirtualGrid: basic rendering', () => { + it('renders column headers', () => { + renderGrid(); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText('Age')).toBeInTheDocument(); + }); + + it('renders row cell values', () => { + renderGrid(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('bob@test.com')).toBeInTheDocument(); + expect(screen.getByText('40')).toBeInTheDocument(); + }); + + it('renders footer with row count', () => { + renderGrid(); + + expect( + screen.getByText(/Showing 3 of 3 rows/), + ).toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 2. Empty data +// ========================================================================= +describe('VirtualGrid: empty data', () => { + it('renders headers with no rows when data is empty', () => { + renderGrid({ data: [] }); + + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + expect(screen.getByText(/Showing 0 of 0 rows/)).toBeInTheDocument(); + }); + + it('does not render any data cells when data is empty', () => { + renderGrid({ data: [] }); + + expect(screen.queryByText('Alice')).not.toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 3. Custom className / headerClassName +// ========================================================================= +describe('VirtualGrid: className support', () => { + it('applies custom className to the root element', () => { + const { container } = renderGrid({ className: 'my-custom-grid' }); + const root = container.firstElementChild as HTMLElement; + expect(root).toHaveClass('my-custom-grid'); + }); + + it('applies headerClassName to the header row', () => { + const { container } = renderGrid({ headerClassName: 'header-custom' }); + const headerRow = container.querySelector('.header-custom'); + expect(headerRow).toBeInTheDocument(); + }); + + it('uses empty className by default', () => { + const { container } = renderGrid(); + const root = container.firstElementChild as HTMLElement; + expect(root.className).toBe(''); + }); +}); + +// ========================================================================= +// 4. Column alignment +// ========================================================================= +describe('VirtualGrid: column alignment', () => { + it('defaults to left alignment', () => { + renderGrid({ + columns: [{ header: 'Name', accessorKey: 'name' }], + data: [{ name: 'Alice' }], + }); + + expect(screen.getByText('Name')).toHaveClass('text-left'); + }); + + it('applies center alignment to header and cells', () => { + renderGrid({ + columns: [{ header: 'Count', accessorKey: 'count', align: 'center' }], + data: [{ count: 42 }], + }); + + expect(screen.getByText('Count')).toHaveClass('text-center'); + expect(screen.getByText('42')).toHaveClass('text-center'); + expect(screen.getByText('42')).toHaveClass('justify-center'); + }); + + it('applies right alignment to header and cells', () => { + renderGrid({ + columns: [{ header: 'Price', accessorKey: 'price', align: 'right' }], + data: [{ price: 99 }], + }); + + expect(screen.getByText('Price')).toHaveClass('text-right'); + expect(screen.getByText('99')).toHaveClass('text-right'); + expect(screen.getByText('99')).toHaveClass('justify-end'); + }); +}); + +// ========================================================================= +// 5. Custom cell renderer +// ========================================================================= +describe('VirtualGrid: custom cell renderer', () => { + it('uses custom cell function when provided', () => { + renderGrid({ + columns: [ + { + header: 'Name', + accessorKey: 'name', + cell: (value: string) => {value.toUpperCase()}, + }, + ], + data: [{ name: 'Alice' }], + }); + + const cell = screen.getByTestId('bold-name'); + expect(cell).toBeInTheDocument(); + expect(cell.tagName).toBe('STRONG'); + expect(cell).toHaveTextContent('ALICE'); + }); + + it('passes both value and row to custom cell function', () => { + const cellFn = vi.fn((_value, row) => ( + {row.name} ({row.age}) + )); + + renderGrid({ + columns: [{ header: 'Info', accessorKey: 'name', cell: cellFn }], + data: [{ name: 'Alice', age: 30 }], + }); + + expect(cellFn).toHaveBeenCalledWith('Alice', { name: 'Alice', age: 30 }); + expect(screen.getByTestId('composite')).toHaveTextContent('Alice (30)'); + }); + + it('renders raw value when no cell function is provided', () => { + renderGrid({ + columns: [{ header: 'Name', accessorKey: 'name' }], + data: [{ name: 'Bob' }], + }); + + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); +}); + +// ========================================================================= +// 6. Column widths +// ========================================================================= +describe('VirtualGrid: column widths', () => { + it('uses 1fr default when no width specified', () => { + const { container } = renderGrid({ + columns: [ + { header: 'A', accessorKey: 'a' }, + { header: 'B', accessorKey: 'b' }, + ], + data: [{ a: '1', b: '2' }], + }); + + const headerRow = container.querySelector('.grid.border-b.sticky') as HTMLElement; + expect(headerRow.style.gridTemplateColumns).toBe('1fr 1fr'); + }); + + it('applies custom column widths', () => { + const { container } = renderGrid({ + columns: [ + { header: 'A', accessorKey: 'a', width: 200 }, + { header: 'B', accessorKey: 'b', width: '2fr' }, + ], + data: [{ a: '1', b: '2' }], + }); + + const headerRow = container.querySelector('.grid.border-b.sticky') as HTMLElement; + expect(headerRow.style.gridTemplateColumns).toBe('200 2fr'); + }); +}); + +// ========================================================================= +// 7. Row click handler +// ========================================================================= +describe('VirtualGrid: onRowClick', () => { + it('calls onRowClick with row data and index when row is clicked', () => { + const onRowClick = vi.fn(); + renderGrid({ onRowClick }); + + const aliceCell = screen.getByText('Alice'); + const row = aliceCell.closest('[style*="position: absolute"]') as HTMLElement; + fireEvent.click(row); + + expect(onRowClick).toHaveBeenCalledTimes(1); + expect(onRowClick).toHaveBeenCalledWith( + { name: 'Alice', email: 'alice@test.com', age: 30 }, + 0, + ); + }); + + it('passes correct index for different rows', () => { + const onRowClick = vi.fn(); + renderGrid({ onRowClick }); + + const charlieCell = screen.getByText('Charlie'); + const row = charlieCell.closest('[style*="position: absolute"]') as HTMLElement; + fireEvent.click(row); + + expect(onRowClick).toHaveBeenCalledWith( + { name: 'Charlie', email: 'charlie@test.com', age: 40 }, + 2, + ); + }); + + it('does not error when onRowClick is not provided', () => { + renderGrid(); + const aliceCell = screen.getByText('Alice'); + const row = aliceCell.closest('[style*="position: absolute"]') as HTMLElement; + + expect(() => fireEvent.click(row)).not.toThrow(); + }); +}); + +// ========================================================================= +// 8. Row className (static and dynamic) +// ========================================================================= +describe('VirtualGrid: rowClassName', () => { + it('applies static rowClassName to all rows', () => { + renderGrid({ rowClassName: 'row-highlight' }); + + const aliceCell = screen.getByText('Alice'); + const row = aliceCell.closest('[style*="position: absolute"]') as HTMLElement; + expect(row).toHaveClass('row-highlight'); + }); + + it('applies dynamic rowClassName function', () => { + renderGrid({ + rowClassName: (_row, index) => (index % 2 === 0 ? 'even-row' : 'odd-row'), + }); + + const aliceRow = screen.getByText('Alice').closest('[style*="position: absolute"]') as HTMLElement; + expect(aliceRow).toHaveClass('even-row'); + + const bobRow = screen.getByText('Bob').closest('[style*="position: absolute"]') as HTMLElement; + expect(bobRow).toHaveClass('odd-row'); + }); + + it('defaults to empty string when rowClassName is not provided', () => { + renderGrid(); + const row = screen.getByText('Alice').closest('[style*="position: absolute"]') as HTMLElement; + expect(row.className).toContain('grid'); + expect(row.className).toContain('border-b'); + }); +}); + +// ========================================================================= +// 9. Virtual scrolling props +// ========================================================================= +describe('VirtualGrid: virtual scrolling configuration', () => { + it('uses default height of 600px', () => { + const { container } = renderGrid(); + const scrollContainer = container.querySelector('.overflow-auto') as HTMLElement; + expect(scrollContainer.style.height).toBe('600px'); + }); + + it('accepts numeric height', () => { + const { container } = renderGrid({ height: 400 }); + const scrollContainer = container.querySelector('.overflow-auto') as HTMLElement; + expect(scrollContainer.style.height).toBe('400px'); + }); + + it('accepts string height', () => { + const { container } = renderGrid({ height: '80vh' }); + const scrollContainer = container.querySelector('.overflow-auto') as HTMLElement; + expect(scrollContainer.style.height).toBe('80vh'); + }); + + it('renders a relative-positioned inner container for virtual positioning', () => { + const { container } = renderGrid(); + const innerContainer = container.querySelector( + '.overflow-auto > div', + ) as HTMLElement; + expect(innerContainer.style.position).toBe('relative'); + expect(innerContainer.style.width).toBe('100%'); + }); + + it('positions rows absolutely with translateY', () => { + renderGrid({ rowHeight: 50 }); + + const aliceRow = screen.getByText('Alice').closest( + '[style*="position: absolute"]', + ) as HTMLElement; + expect(aliceRow.style.position).toBe('absolute'); + expect(aliceRow.style.transform).toBe('translateY(0px)'); + + const bobRow = screen.getByText('Bob').closest( + '[style*="position: absolute"]', + ) as HTMLElement; + expect(bobRow.style.transform).toBe('translateY(50px)'); + }); + + it('sets total height on inner container based on data length and row height', () => { + const { container } = renderGrid({ rowHeight: 50 }); + const innerContainer = container.querySelector( + '.overflow-auto > div', + ) as HTMLElement; + // 3 rows × 50px = 150px + expect(innerContainer.style.height).toBe('150px'); + }); +}); + +// ========================================================================= +// 10. Different data types and edge cases +// ========================================================================= +describe('VirtualGrid: different column types', () => { + it('renders numeric values correctly', () => { + renderGrid({ + columns: [{ header: 'Count', accessorKey: 'count' }], + data: [{ count: 0 }, { count: 100 }, { count: -5 }], + }); + + expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('-5')).toBeInTheDocument(); + }); + + it('handles null / undefined values gracefully', () => { + renderGrid({ + columns: [ + { header: 'Name', accessorKey: 'name' }, + { header: 'Email', accessorKey: 'email' }, + ], + data: [ + { name: 'Alice', email: null }, + { name: undefined, email: 'bob@test.com' }, + ], + }); + + expect(screen.getByText('bob@test.com')).toBeInTheDocument(); + expect(screen.getByText(/Showing 2 of 2 rows/)).toBeInTheDocument(); + }); + + it('renders many columns without error', () => { + const cols: VirtualGridColumn[] = Array.from({ length: 10 }, (_, i) => ({ + header: `Col ${i}`, + accessorKey: `field${i}`, + })); + const data = [Object.fromEntries(cols.map((c) => [c.accessorKey, `val-${c.accessorKey}`]))]; + + renderGrid({ columns: cols, data }); + + expect(screen.getByText('Col 0')).toBeInTheDocument(); + expect(screen.getByText('Col 9')).toBeInTheDocument(); + expect(screen.getByText('val-field5')).toBeInTheDocument(); + }); + + it('applies gridTemplateColumns to each data row matching header', () => { + const { container } = renderGrid({ + columns: [ + { header: 'A', accessorKey: 'a', width: '100px' }, + { header: 'B', accessorKey: 'b', width: '200px' }, + ], + data: [{ a: '1', b: '2' }], + }); + + const dataRow = container.querySelector('[style*="position: absolute"]') as HTMLElement; + expect(dataRow.style.gridTemplateColumns).toBe('100px 200px'); + }); +}); From 3ffdb40d898d762f4ec371b541a87d38895de043 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 02:46:14 +0000 Subject: [PATCH 13/13] chore: raise coverage thresholds after adding comprehensive tests Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- vitest.config.mts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/vitest.config.mts b/vitest.config.mts index 587cfdc28..23ff444cc 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -25,15 +25,15 @@ export default defineConfig({ 'examples/', ], // Section 3.6: Testing coverage thresholds - // Adjusted to reflect current coverage levels and prevent CI failures - // Target: Gradually increase these as test coverage improves - // Last adjusted: 2026-02-03 - Reduced after @objectstack 0.9.1 upgrade - // to allow PR merge while maintaining coverage enforcement + // Target: 80%+ lines and functions + // Last adjusted: 2026-02-11 - Increased after adding comprehensive tests + // for hooks, contexts, plugins (useExpression, useDiscovery, useActionRunner, + // ActionContext, i18n provider, LazyPluginLoader, timeline, SortUI, FilterUI, VirtualGrid) thresholds: { - lines: 61, // Actual: 61.67% (was 63%) - functions: 43, - branches: 40, - statements: 60, // Actual: 60.46% (was 62%) + lines: 66, + functions: 55, + branches: 50, + statements: 65, }, }, },