diff --git a/packages/router/__tests__/data-context.test.ts b/packages/router/__tests__/data-context.test.ts new file mode 100644 index 000000000..0c63ce585 --- /dev/null +++ b/packages/router/__tests__/data-context.test.ts @@ -0,0 +1,118 @@ +/** + * @openelement/router/data-context — render-scoped loader/action data stack. + * + * These are pure stack operations with no DOM dependency. We verify the + * push/pop/peek contract, action-data association to the top frame, nested + * render scoping, and the depth-warning guard. + */ + +import { assert, assertEquals } from 'jsr:@std/assert@^1.0.0'; +import { + __internal_popData, + __internal_pushActionData, + __internal_pushLoaderData, + useActionData, + useLoaderData, +} from '../src/data-context.ts'; + +// Reset the module-scoped stack between tests so ordering does not leak. +function drainStack(): void { + // Pop until empty — guarded by try since we cannot read length directly. + for (let i = 0; i < 16; i++) { + try { + __internal_popData(); + } catch { + break; + } + } +} + +Deno.test('data-context: useLoaderData reads the top frame loader data', () => { + drainStack(); + __internal_pushLoaderData({ message: 'hello' }); + try { + assertEquals(useLoaderData<{ message: string }>(), { message: 'hello' }); + } finally { + __internal_popData(); + } +}); + +Deno.test('data-context: useActionData is undefined before any action push', () => { + drainStack(); + __internal_pushLoaderData({ id: 1 }); + try { + assertEquals(useActionData(), undefined); + } finally { + __internal_popData(); + } +}); + +Deno.test('data-context: pushActionData associates with the top frame', () => { + drainStack(); + __internal_pushLoaderData({ id: 1 }); + __internal_pushActionData({ ok: true }); + try { + assertEquals(useLoaderData(), { id: 1 }); + assertEquals(useActionData(), { ok: true }); + } finally { + __internal_popData(); + } +}); + +Deno.test('data-context: pop restores the previous frame', () => { + drainStack(); + __internal_pushLoaderData('outer'); + __internal_pushLoaderData('inner'); + try { + assertEquals(useLoaderData(), 'inner'); + } finally { + __internal_popData(); + } + assertEquals(useLoaderData(), 'outer'); + __internal_popData(); +}); + +Deno.test('data-context: nested renders scope data independently', () => { + drainStack(); + __internal_pushLoaderData('a'); + __internal_pushLoaderData('b'); + __internal_pushLoaderData('c'); + try { + assertEquals(useLoaderData(), 'c'); + } finally { + __internal_popData(); + } + try { + assertEquals(useLoaderData(), 'b'); + } finally { + __internal_popData(); + } + assertEquals(useLoaderData(), 'a'); + __internal_popData(); +}); + +Deno.test('data-context: warns when stack depth exceeds 10', () => { + drainStack(); + let warnCount = 0; + const originalWarn = console.warn; + console.warn = () => { + warnCount++; + }; + try { + for (let i = 0; i < 12; i++) { + __internal_pushLoaderData(i); + } + // Depth reaches 11 and 12, each triggering a warning. + assert(warnCount >= 1, `expected a depth warning, got ${warnCount}`); + assertEquals(warnCount, 2); + } finally { + console.warn = originalWarn; + drainStack(); + } +}); + +Deno.test('data-context: empty stack returns undefined for both hooks', () => { + drainStack(); + assertEquals(useLoaderData(), undefined); + assertEquals(useActionData(), undefined); +}); diff --git a/packages/router/__tests__/i18n.test.ts b/packages/router/__tests__/i18n.test.ts new file mode 100644 index 000000000..4a3ab6b35 --- /dev/null +++ b/packages/router/__tests__/i18n.test.ts @@ -0,0 +1,83 @@ +/** + * @openelement/router/i18n — locale-aware path normalization. + * + * Pure function, no DOM dependency. Verifies prefix detection, default-locale + * fallback, localized-path construction, and default-locale identity. + */ + +import { assertEquals } from 'jsr:@std/assert@^1.0.0'; +import { normalizeLocalePath } from '../src/i18n.ts'; + +Deno.test('i18n: strips a recognized locale prefix', () => { + const result = normalizeLocalePath('/en/about', { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + }); + assertEquals(result, { + locale: 'en', + path: '/about', + localizedPath: '/about', + isDefaultLocalePath: true, + }); +}); + +Deno.test('i18n: prefixes a non-default locale path', () => { + const result = normalizeLocalePath('/about', { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + }); + assertEquals(result, { + locale: 'en', + path: '/about', + localizedPath: '/about', + isDefaultLocalePath: true, + }); + + const fr = normalizeLocalePath('/fr/about', { + locales: ['en', 'fr', 'de'], + defaultLocale: 'en', + }); + assertEquals(fr.locale, 'fr'); + assertEquals(fr.path, '/about'); + assertEquals(fr.localizedPath, '/fr/about'); + assertEquals(fr.isDefaultLocalePath, false); +}); + +Deno.test('i18n: falls back to default locale when no prefix', () => { + const result = normalizeLocalePath('products/widget', { + locales: ['en', 'ja'], + defaultLocale: 'ja', + }); + assertEquals(result.locale, 'ja'); + assertEquals(result.path, '/products/widget'); + assertEquals(result.localizedPath, '/products/widget'); + assertEquals(result.isDefaultLocalePath, true); +}); + +Deno.test('i18n: root path maps to locale + "/"', () => { + const result = normalizeLocalePath('/', { + locales: ['en', 'fr'], + defaultLocale: 'en', + }); + assertEquals(result.locale, 'en'); + assertEquals(result.path, '/'); + assertEquals(result.localizedPath, '/'); +}); + +Deno.test('i18n: uses first locale when defaultLocale not in list', () => { + const result = normalizeLocalePath('/', { + locales: ['fr', 'de'], + defaultLocale: 'en', + }); + assertEquals(result.locale, 'fr'); + assertEquals(result.isDefaultLocalePath, true); +}); + +Deno.test('i18n: empty locales list falls back to defaultLocale', () => { + const result = normalizeLocalePath('/de/foo', { + locales: [], + defaultLocale: 'en', + }); + assertEquals(result.locale, 'en'); + assertEquals(result.path, '/de/foo'); +});