/** * Locale contract for the client half. The apes:* tabs are bilingual * (zh default, en fallback) — the same shape tutti-os ships. This spec pins * the i18n surface against the REAL locale module API: * - t() resolves the active locale, falling back en → raw key * - registerLocales() syncs activeLocale from a mock ApesLocaleService and * keeps it in sync as the runtime emits new snapshots * - subscribeLocale() re-render hook + clean detach * The React hook (useApesLocale) needs a renderer; it is a thin * useSyncExternalStore wrapper over the same getSnapshot/subscribe pair and * is exercised indirectly here. */ import { describe, expect, it } from 'vitest' import { t, getActiveLocale, subscribeLocale, registerLocales, } from '../src/client/locales.ts' import type { ApesLocaleService } from '../src/context-types.ts' /** Build a mock ApesLocaleService whose active locale we can flip. */ function makeLocaleService(initialActive = 'zh'): { service: ApesLocaleService setActive: (loc: string) => void registered: Array<{ ns: string; locale: string }> } { let active = initialActive const subs = new Set<() => void>() const registered: Array<{ ns: string; locale: string }> = [] const service: ApesLocaleService = { getSnapshot: () => ({ active }), subscribe: (fn) => { subs.add(fn) return () => subs.delete(fn) }, register: (ns, locale) => { registered.push({ ns, locale }) return () => {} }, } return { service, setActive: (loc) => { active = loc for (const fn of [...subs]) fn() }, registered, } } describe('locales module (real API)', () => { it('resolves keys in the default zh locale', () => { // Before any registerLocales call, activeLocale is the module default. const active = getActiveLocale() expect(['zh', 'en']).toContain(active) // Every registered key resolves to a non-empty string. expect(t('tab.tasks.title').length).toBeGreaterThan(0) expect(t('tab.agents.title').length).toBeGreaterThan(0) // A key in neither locale falls back to the raw key (flag the gap). expect(t('does.not.exist')).toBe('does.not.exist') }) it('registerLocales registers zh+en under the apesSidebar namespace', () => { const { service, registered } = makeLocaleService('zh') const dispose = registerLocales(service) expect(registered.map((r) => r.locale).sort()).toEqual(['en', 'zh']) expect(registered.every((r) => r.ns === 'apesSidebar')).toBe(true) dispose() // clean disposer, no throw on double-run expect(() => dispose()).not.toThrow() }) it('registerLocales seeds activeLocale from the runtime snapshot', () => { const { service } = makeLocaleService('en') const dispose = registerLocales(service) expect(getActiveLocale()).toBe('en') expect(t('tab.tasks.title')).toBe('Tasks') dispose() }) it('keeps activeLocale in sync as the runtime emits a new active locale', () => { const { service, setActive } = makeLocaleService('zh') const dispose = registerLocales(service) expect(getActiveLocale()).toBe('zh') const before = t('tab.tasks.title') setActive('en') expect(getActiveLocale()).toBe('en') expect(t('tab.tasks.title')).toBe('Tasks') // The value actually changed. expect(before).not.toBe(t('tab.tasks.title')) dispose() }) it('subscribeLocale notifies on locale change and detaches cleanly', () => { const { service, setActive } = makeLocaleService('zh') const dispose = registerLocales(service) const seen: string[] = [] const unsub = subscribeLocale(() => seen.push(getActiveLocale())) setActive('en') expect(seen).toEqual(['en']) unsub() setActive('zh') // After detach, no further observation. expect(seen).toEqual(['en']) dispose() }) it('is a no-op (clean disposer) when the locale runtime is absent', () => { const dispose = registerLocales(undefined) expect(typeof dispose).toBe('function') expect(() => dispose()).not.toThrow() }) it('tolerates a runtime whose subscribe throws (early boot guard)', () => { const service: ApesLocaleService = { getSnapshot: () => ({ active: 'en' }), subscribe: () => { throw new Error('runtime not ready') }, register: () => () => {}, } // The try/catch in registerLocales swallows the subscribe failure and // still returns a working disposer. const dispose = registerLocales(service) expect(typeof dispose).toBe('function') expect(() => dispose()).not.toThrow() }) })