document-language.client.spec.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  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. ok: true as const,
  35. value: { writable: true, hasDocument: true, namespaces: [namespace()] },
  36. }))
  37. const mutate = vi.fn(async (_ns: string, ops: { value: string }[]) => {
  38. stored = ops[0]!.value
  39. revision += 1
  40. return { ok: true as const, value: namespace() }
  41. })
  42. // The settings transport and the forwarded-event port the plugin injects.
  43. new TestRemote(ctx, { settings: { describe: describeRpc, mutate } })
  44. await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
  45. await ctx.plugin({ inject: [...inject], apply }).await()
  46. return { ctx, locale: ctx.get('locale') as LocaleRuntime }
  47. }
  48. const langOf = (): string => document.documentElement.lang
  49. describe('document language', () => {
  50. beforeEach(() => {
  51. // The served markup declares the product default; the plugin must not
  52. // depend on that value already being correct.
  53. document.documentElement.lang = 'en'
  54. Object.defineProperty(navigator, 'languages', { value: ['zh-CN'], configurable: true })
  55. Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true })
  56. })
  57. afterEach(() => {
  58. // navigator properties are installed with defineProperty above, so they
  59. // are removed the same way; nothing here goes through vi.stubGlobal.
  60. const own = navigator as unknown as Record<string, unknown>
  61. delete own.languages
  62. delete own.language
  63. })
  64. it('states the resolved locale at activation, not the value the markup shipped', async () => {
  65. // A Chinese browser resolves zh even though the markup said en.
  66. const { locale } = await bench()
  67. expect(locale.getLocale().active).toBe('zh')
  68. expect(langOf()).toBe('zh-CN')
  69. })
  70. it('follows a locale switch in both directions with BCP 47 tags', async () => {
  71. const { locale } = await bench()
  72. expect(langOf()).toBe('zh-CN')
  73. locale.setLocale('en')
  74. // `en` needs no region; `zh` names its script variant, which bare `zh`
  75. // leaves ambiguous for pronunciation and font selection.
  76. expect(langOf()).toBe('en')
  77. locale.setLocale('zh')
  78. expect(langOf()).toBe('zh-CN')
  79. })
  80. it('follows an explicit Host preference that overrides browser detection', async () => {
  81. // Stored preference wins over the zh browser pinned above.
  82. const { locale } = await bench('en')
  83. await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') })
  84. await vi.waitFor(() => { expect(langOf()).toBe('en') })
  85. })
  86. it('uses an external locale definition for the document language', async () => {
  87. const { locale } = await bench()
  88. locale.addLanguage({ id: 'pt-BR', label: 'Português', fallback: 'en' })
  89. locale.setLocale('pt-BR')
  90. expect(langOf()).toBe('pt-BR')
  91. })
  92. })