document-language.client.spec.ts 4.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. // @vitest-environment jsdom
  2. /**
  3. * `<html lang>` tracks the active locale.
  4. *
  5. * The served markup declares one language, but the resolved locale may differ
  6. * (browser detection, or a stored Host preference adopted after activation),
  7. * and it changes again whenever the user switches. Assistive technology and
  8. * browser features read this attribute, so a stale value misreports the
  9. * document language rather than merely looking untidy.
  10. */
  11. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  12. import { Context } from '@deepseek-ai/cordis'
  13. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  14. import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
  15. import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
  16. import { apply, inject } from '@deepseek-ai/dsh-client-locale/client'
  17. import type { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  18. import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from '../src/locale-settings.ts'
  19. /** Boot the plugin over a stub Host settings document. */
  20. async function bench(preference?: string) {
  21. const ctx = new Context()
  22. await ctx.plugin(SlotRegistry).await()
  23. let stored = preference
  24. let revision = 0
  25. const namespace = () => ({
  26. ns: LOCALE_SETTINGS_NAMESPACE,
  27. schema: LocaleSettingsSchema.toJSON(),
  28. value: stored === undefined ? {} : { preference: stored },
  29. applies: 'live' as const,
  30. secrets: [],
  31. revision,
  32. })
  33. const describeRpc = vi.fn(async () => ({
  34. rpcId: 'locale-describe' as never,
  35. result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [namespace()] } },
  36. }))
  37. const mutate = vi.fn(async (request: { ops: { value: string }[] }) => {
  38. stored = request.ops[0]!.value
  39. revision += 1
  40. return { rpcId: 'locale-mutate' as never, result: { ok: true as const, value: namespace() } }
  41. })
  42. ctx.provide('connection', { api: { settings: { describe: describeRpc, mutate } }, isLoopback: true } as never)
  43. // The settings transport and the forwarded-event port the plugin injects.
  44. new TestRemote(ctx)
  45. await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
  46. await ctx.plugin({ inject: [...inject], apply }).await()
  47. return { ctx, locale: ctx.get('locale') as LocaleRuntime }
  48. }
  49. const langOf = (): string => document.documentElement.lang
  50. describe('document language', () => {
  51. beforeEach(() => {
  52. // The served markup declares the product default; the plugin must not
  53. // depend on that value already being correct.
  54. document.documentElement.lang = 'en'
  55. Object.defineProperty(navigator, 'languages', { value: ['zh-CN'], configurable: true })
  56. Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true })
  57. })
  58. afterEach(() => {
  59. // navigator properties are installed with defineProperty above, so they
  60. // are removed the same way; nothing here goes through vi.stubGlobal.
  61. const own = navigator as unknown as Record<string, unknown>
  62. delete own.languages
  63. delete own.language
  64. })
  65. it('states the resolved locale at activation, not the value the markup shipped', async () => {
  66. // A Chinese browser resolves zh even though the markup said en.
  67. const { locale } = await bench()
  68. expect(locale.getLocale().active).toBe('zh')
  69. expect(langOf()).toBe('zh-CN')
  70. })
  71. it('follows a locale switch in both directions with BCP 47 tags', async () => {
  72. const { locale } = await bench()
  73. expect(langOf()).toBe('zh-CN')
  74. locale.setLocale('en')
  75. // `en` needs no region; `zh` names its script variant, which bare `zh`
  76. // leaves ambiguous for pronunciation and font selection.
  77. expect(langOf()).toBe('en')
  78. locale.setLocale('zh')
  79. expect(langOf()).toBe('zh-CN')
  80. })
  81. it('follows an explicit Host preference that overrides browser detection', async () => {
  82. // Stored preference wins over the zh browser pinned above.
  83. const { locale } = await bench('en')
  84. await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
  85. await vi.waitFor(() => { expect(langOf()).toBe('en') })
  86. })
  87. })