Skip to content

Commit bee9836

Browse files
fix(service-settings)!: Setup's default language offers and accepts the languages the deployment supports, not four hard-coded ones (#30446)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg21NveYnJ8STuX2gJsTgYaA)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF ## 维护者速读 - **改了什么:** Setup → 本地化 →「默认语言」不再写死四种语言,改成部署实际支持的语言:和用户右上角语言菜单读同一个来源(i18n 服务的 `getLocales()`,即 `GET /api/v1/i18n/locales`),选项每次打开页面、每次保存时现读,所以后加的翻译项(比如越南语)马上就能选;每个选项用该语言自己的名字显示(简体中文、日本語、Tiếng Việt)。 - **为什么改:** 法国客户有完整法语包、用户菜单能选法语,但组织默认语言只能在英/中/日/西四种里选,存 `fr-FR`、`vi-VN` 一律被拒,每个新用户都从公司没选的语言起步。 - **风险与回滚:** 这是一次收窄(`Clause-②: yes (narrowing)`):部署不支持的语言以后存不进去,例如应用只声明 `en`、`zh-CN` 时,以前能存的 `ja-JP`、`es-ES` 现在被拒。已经存着的旧值照常生效、照常可读,页面上显示为「(已不再提供)」,保存别的字段不受影响;只有再次写入它才会被拒。`en-US` 这类老写法在部署提供 `en` 时照常接受。没有 i18n 服务的组合仍用原来四种。回滚即撤本 PR。 - **先看:** `packages/services/service-settings/src/localization-locale-options.ts`(来源、命名、接受规则、"已不再提供");`settings-service.ts` 里新的 `registerOptionSource` / `optionTableOf` / `servedManifest` 三段,以及保存和环境变量两处改成问 `optionTableOf` 的两行;`settings-service-plugin.ts` 在注册清单之前注册这个来源的那一段。 - **两分钟验证:** `pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2 src/localization-locale-options.test.ts`,预期 14 条全绿,其中"后加的翻译项不重启即可保存"和"已不再提供的旧值照常可读并标注"两条。 - **需要你做的:** 这是 BREAKING 收窄,合入需要你本人同意(合约审查由席位安排)。 Fixes #30286 Clause-②: yes (narrowing) — `SettingsService` gains `registerOptionSource(namespace, key, source)` and the package exports the `SettingsOptionSource`, `SettingsOptionSourceAnswer` and `SettingsServedOption` types; `getNamespace` takes an optional third `display` argument. The save of `localization.locale` (`PUT /api/settings/localization`, `SettingsService.setMany`) and an `OS_LOCALIZATION_LOCALE` override now accept the i18n service's locale list instead of the declared `en-US`, `zh-CN`, `ja-JP`, `es-ES`: a language the deployment supports is accepted where it was refused (`fr-FR`, `vi-VN`), and one of the four the deployment does not support is refused where it was accepted (`ja-JP` on an app that declares `i18n.supportedLocales: ['en', 'zh-CN']`, as `examples/app-showcase` and `examples/app-crm` do). Metadata-protocol: none Skills-docs: none — `git grep -n "localization.locale" skills content/docs` answers one line, `content/docs/permissions/authentication.mdx:474` (SMS bodies fall back to the setting), which lists no values and stays true; `skills/**` names the setting nowhere. ## What changes **A settings key can serve its options from the running system (`SettingsService`, `settings-service.ts`, `settings-service.types.ts`).** Measured first: a settings option table was recorded once, from the manifest, at `registerManifest`; the `valueDomain` vocabulary is closed (three members) and `bcp47_locale` is refused by name in `packages/spec/src/system/settings-manifest.test.ts`, because this key's options were meant to be the supported set, not a standard. So no existing mechanism and no spec change: a service-level registration, `registerOptionSource(namespace, key, source)`. The source answers `{ options, admits(value) }`, or `undefined` to leave the declared table standing. - **Save** (`validatePatch`) and **`OS_*` override** (`effectiveEnvOverride`) both ask one helper, `optionTableOf`, for the values offered and the judgment of a written value: the source's answer when it has one, else the declared table through `firstRejectedOption`, unchanged. Each site changes by two lines; the refusal is the existing `invalid_option` with `constraint.allowed` listing the offered values. The TOUCH gate is unchanged, so a stored value is judged only by a save that writes it. - **Registration-time env audit** (`auditEnvOverrides`) skips the options family of a sourced key: the source reads a service that registers later in the boot, so a verdict there would come from the table it replaces. The override is judged when read, by the same helper (`AGENTS.md`, Startup registry reads). - **Page read** (`getNamespace`, optional `display.locale`; the `GET /api/settings/:namespace` route passes the request's language, one line) serves a copy of the manifest whose sourced specifiers carry the source's options for the value in effect. The registered manifest is never changed. `GET /api/settings` (the manifest list) still serves the declared table. - A source that throws is answered as absent (declared table) and logged once per cause at `warn`. **`localization.locale`'s source (`localization-locale-options.ts`, new).** Offered codes = the i18n service's `getLocales()`, de-duplicated, in its order: the app's declared `i18n.supportedLocales`, else every loaded bundle and authored translation item, the list `GET /api/v1/i18n/locales` serves to the language menu. - **Names** in their own language: the platform locale table's name (`PLATFORM_LOCALES`, #30001) for the language the code means through `platformLocaleTagOf`, else `Intl.DisplayNames([code])` with `fallback: 'none'`, capitalized, else the code. The console's language menu names locales the same way (`ui/packages/app-shell/src/layout/LocaleSwitcher.tsx`, `localeLabel`). - **Accepted:** each offered code, and a stored spelling of an offered table language (`en-US` where `en` is offered, `zh-CN` where `zh-Hans` is), through the table's read aliases. Without this the manifest default `en-US` would not be a member on any deployment whose list says `en`, which is all three examples. A code the table does not alias is accepted only as spelled (`fr-FR` is not `fr`). - **The value in effect:** a stored second spelling is served under its own spelling so the select shows it chosen; a stored value no longer offered is served as an extra option named `NAME (no longer offered)` in the reader's language (en, zh-CN, ja-JP, es-ES), so the page shows it rather than a blank select. - No i18n service, or one reporting nothing ⇒ the declared four stand. **Wiring (`settings-service-plugin.ts`).** `init` registers the source before the manifests, with `ctx.getService('i18n')` asked at each use. **Translations (`translations/zh-CN.ts`).** The bundle translated the four option names (英语(美国), 日语, …). The console's option-label lookup prefers the bundle over the served name, so those entries would have named each language in the reader's language; they are removed. The other three bundles carried none. The coverage guard already excludes option labels for this reason (`settings-translation-coverage.test.ts` header). **Manifest (`localization.manifest.ts`).** The declared four stay as the no-i18n fallback; a comment says what a running deployment serves instead. ### Readers of `localization.locale` (the card's question) None breaks on a locale outside the four; each was read on `origin/main` fe835b4, not exercised end to end: | Reader | Where | With `vi-VN` | |:--|:--|:--| | Execution context locale (number/date formatting, `resolveLocalizationContext`) | `packages/core/src/security/assemble-execution-context.ts`, `resolve-authz-context.ts` | `Intl` formats any BCP 47 tag. | | Auth email locale | `plugin-auth/src/auth-plugin.ts` (`applyLocalizationLocale`), `auth-manager.ts` (`normalizeAuthEmailLocale`) | Passed through unchanged; `EmailService.sendTemplate`'s ladder finds no `vi-VN` row and falls to `en-US` (`plugin-email/src/email-service.ts`). `en` still maps to `en-US`. | | Auth SMS bodies | `plugin-auth/src/phone-sms-texts.ts` | `phoneSmsLocaleChain` ends at `en`; never renders nothing. | | Seed dataset locale | `packages/runtime/src/seed-locale.ts` → `metadata-protocol/src/seed-loader.ts` (`filterByLocale`) | Datasets scoped to other locales are skipped and named in one log line; unscoped ones load, as for any unmatched locale. | | Activity summaries, @mention titles | `plugin-audit/src/audit-plugin.ts`, `audit-writers.ts` | i18n `t()` falls back to the default locale. | | Messaging template locale | `service-messaging/src/messaging-service-plugin.ts` | Names the setting in a comment only; reads `i18n.getDefaultLocale()` at delivery, unset lands the `en-US` default. Unaffected. | | `GET /auth/me/localization` | `plugin-hono-server/src/current-user-endpoints.ts` | Echoes the value; the console shows English for a language with no console pack, as its language menu already does. | ## Tests New `src/localization-locale-options.test.ts` (14 tests), on a real `LiteKernel` boot of `SettingsServicePlugin` with a stand-in i18n service whose list the cases change, through the real settings routes (`GET` / `PUT /api/settings/:namespace`): - serves the list, named in each language, with `en-US` in effect shown as the `en` option; the registered manifest unchanged; - accepts `fr-FR` when offered; follows the list at runtime (`vi-VN` refused, then accepted after the list grows, no reboot); - refuses an unoffered `ja-JP`: 400 `SETTINGS_VALIDATION`, `invalid_option` at `locale`, `constraint.allowed: 'en, zh-CN'`, nothing written; - accepts `zh-CN` / `en-US` where `zh-Hans` / `en` are offered, refuses `fr-FR` where `fr` is; - a stored `ja-JP` the list drops: still the value, served as `日本語 (已不再提供)` for a zh-CN reader, a save of `fiscal_year_start` passes, a re-write of `ja-JP` is refused; - empty list ⇒ the declared four; - `OS_LOCALIZATION_LOCALE=vi-VN` in force while offered, not in force once the list drops it; not reported by the registration-time audit while a declared-table key (`fiscal_year_start`) still is; - no source ⇒ declared table; a throwing source ⇒ declared table, one `warn`; - `localeSelfName`: table names, the code for an unnamed tag, a malformed tag and a runtime without `Intl.DisplayNames`, and the runtime's ICU name for `vi-VN`. Ablation through `scripts/ablation-replace.mjs`: `optionTableOf` made to ignore the source ⇒ 6 of 14 red; restored, blob equal to `HEAD`. Package: `pnpm --filter @objectstack/service-settings test` 69 files, 1170 passed; `typecheck` exit 0. ## Acceptance notes - Setup's renderer needs no change: `ui/apps/console/src/pages/settings/SettingsField.tsx` draws a `select` from the served `spec.options` and names each through `labels.optionLabel`, which falls back to the served label when the bundle has no entry. Read, not changed (`ui/**` is out of scope). - #30206 (first-run checklist language row) should read the same list: `GET /api/settings/localization`'s served `locale` options, or the i18n service's `getLocales()` server-side. No change here. - The new component's pins are in its own test file (`localization-locale-options.test.ts`); the settings routes' shared harness (`settings-routes.test.ts`) is not extended. - `GET /api/settings` (the manifest list the Setup hub reads for its navigation) still carries the declared four; only the namespace read serves the live list. ## Verification All at head `e1c377ef` (the branch with `origin/main` merged), after `pnpm install --frozen-lockfile` (exit 0) and a full build (`turbo run build --filter='!@objectstack/docs'`, 119/119, under the verify lock): - `pnpm --filter @objectstack/service-settings test`: 69 files, 1170 passed. `typecheck`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 63 families from the change set; each run with its exit recorded before any pipe: 63 of 63 exit 0 (`check-plugin-teardown-shape --self-test` first answered exit 3, PREREQUISITE NOT MET, on the shallow clone; it exits 0 after fetching its pinned fixture commit). `--ran`: 63 derived, 63 run, 0 NOT-MEASURED, exit 0. - `pnpm lint`: exit 0. - Full dogfood (BREAKING, narrowing arm), `pnpm --filter @objectstack/dogfood test` under the lock: exit 0, 419 files passed and 3 skipped, 3492 tests passed and 36 skipped. - CI check-runs: not yet reported at opening. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZEhtHfQH5TLEVe3TF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8cc709d commit bee9836

10 files changed

Lines changed: 650 additions & 14 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
fix(service-settings)!: Setup's default language (`localization.locale`) offers and accepts the languages the deployment supports, not four hard-coded ones (#30286)
6+
7+
Clause-②: yes (narrowing) — `SettingsService` gains `registerOptionSource(namespace, key, source)` and the package exports the `SettingsOptionSource`, `SettingsOptionSourceAnswer` and `SettingsServedOption` types; `getNamespace` takes an optional third `display` argument. The save of `localization.locale` (`PUT /api/settings/localization`, `SettingsService.setMany`) and an `OS_LOCALIZATION_LOCALE` override now accept the i18n service's locale list instead of the declared `en-US`, `zh-CN`, `ja-JP`, `es-ES`: a language the deployment supports is accepted where it was refused (`fr-FR`, `vi-VN`), and one of the four the deployment does not support is refused where it was accepted (`ja-JP` on an app that declares `i18n.supportedLocales: ['en', 'zh-CN']`).
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, and no stored row is read, rewritten, converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime accept set on one settings key, `localization.locale`, judged against the deployment's i18n locale list at save time; a stored value outside it is kept, read and applied as before, and the remedy for a refused write is a deployment change (ship the language, or declare it in `i18n.supportedLocales`), which no ledger entry can derive. The other categories are closed on facts: the bumped package publishes (not unpublished); no ADR-0087 id covers this key and this diff adds none (not registered, not already-registered); the exports this diff adds are additions and no published export is removed or retyped, so no interface or type narrows (not runtime-export-retired, runtime-interface-only or type-surface-only). -->
11+
12+
**BREAKING** accept-set narrowing on the `localization.locale` settings save (Setup → Localization → Default language, `PUT /api/settings/localization`, `SettingsService.setMany`) and on an `OS_LOCALIZATION_LOCALE` override, on a deployment whose language list leaves out one of `en-US`, `zh-CN`, `ja-JP`, `es-ES`.
13+
14+
- **Before.** Default language offered English (US), 简体中文, 日本語 and Español (España), and the save accepted exactly those four, whatever the deployment shipped. A French pack, or a Vietnamese translation item the user's language menu already offered, could not be chosen as the organization's default.
15+
- **Now.** The options and the accepted values are the i18n service's locale list, the one `GET /api/v1/i18n/locales` and the user's language menu read (the app's declared `i18n.supportedLocales`, else every locale a bundle or translation item has loaded), asked at each save and page read, so a translation item added at runtime is offered at once. Each option is named in its own language (the platform locale table's name, else `Intl.DisplayNames` in that language, else the code). A stored spelling of an offered language is still accepted and shown chosen (`en-US` where `en` is offered, `zh-CN` where `zh-Hans` is). A value outside the list is refused `400 SETTINGS_VALIDATION` with `invalid_option` at `locale`, listing the offered values. With no i18n service in the composition the declared four stand.
16+
- **Unchanged.** A value stored before this release that the deployment no longer offers stays in effect and readable: the page offers it, named as no longer offered in the reader's language, and a save of other keys passes it by; only a save that writes it again is judged. Every reader of the setting (formatting, auth email and SMS, seeds, activity titles) reads it as before.
17+
18+
**The remedy.** To keep offering a language the deployment refuses, ship it: add the language to the app's `i18n.supportedLocales` (and its translations), then pick it in Setup.

‎packages/services/service-settings/src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ export {
5454
type SettingsActionHandler,
5555
type SettingsLiveStatus,
5656
type SettingsLiveStatusProvider,
57+
// The options a select key serves from the running system, not its manifest.
58+
type SettingsOptionSource,
59+
type SettingsOptionSourceAnswer,
60+
type SettingsServedOption,
5761
// ADR-0144 D4 — the write-side rule a package registers on one key.
5862
type SettingsWritePredicate,
5963
type SettingsWritePredicateInput,
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Setup's 默认语言 (`localization.locale`) offers and accepts the languages
5+
* the deployment supports: the i18n service's locale list, the one the user's
6+
* language menu reads (`GET /api/v1/i18n/locales`), not four codes written
7+
* into the manifest.
8+
*
9+
* Pinned on a real `LiteKernel` boot of `SettingsServicePlugin`, through the
10+
* settings routes the Localization page reads and saves through, so the
11+
* wiring, the served manifest and the save's acceptance are one path. The
12+
* i18n service is a stand-in whose locale list the cases change at runtime,
13+
* the way an added translation item grows it.
14+
*/
15+
16+
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
17+
import { LiteKernel } from '@objectstack/core';
18+
import type { Plugin, PluginContext } from '@objectstack/core';
19+
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
20+
import { SettingsServicePlugin } from './settings-service-plugin.js';
21+
import { SettingsService } from './settings-service.js';
22+
import { registerSettingsRoutes } from './settings-routes.js';
23+
import { localizationSettingsManifest } from './manifests/localization.manifest.js';
24+
import { localeSelfName, localizationLocaleOptionSource, notOfferedCopy } from './localization-locale-options.js';
25+
26+
/** An i18n service whose reported locale list each case sets. */
27+
class LocaleListI18nPlugin implements Plugin {
28+
name = 'test.locale-list-i18n';
29+
version = '0.0.0';
30+
locales: string[] = [];
31+
async init(ctx: PluginContext): Promise<void> {
32+
ctx.registerService('i18n', {
33+
loadTranslations: () => {},
34+
getLocales: () => [...this.locales],
35+
});
36+
}
37+
}
38+
39+
class MockHttp implements IHttpServer {
40+
routes = new Map<string, RouteHandler>();
41+
get(path: string, h: RouteHandler) { this.routes.set(`GET ${path}`, h); return this as any; }
42+
post(path: string, h: RouteHandler) { this.routes.set(`POST ${path}`, h); return this as any; }
43+
put(path: string, h: RouteHandler) { this.routes.set(`PUT ${path}`, h); return this as any; }
44+
delete(path: string, h: RouteHandler) { this.routes.set(`DELETE ${path}`, h); return this as any; }
45+
patch(path: string, h: RouteHandler) { this.routes.set(`PATCH ${path}`, h); return this as any; }
46+
use() { return this as any; }
47+
listen() { return Promise.resolve(); }
48+
close() { return Promise.resolve(); }
49+
getInstance() { return null; }
50+
}
51+
52+
async function call(
53+
http: MockHttp,
54+
route: string,
55+
opts: { body?: unknown; headers?: Record<string, string> } = {},
56+
): Promise<{ status: number; body: any }> {
57+
const state: { status: number; body?: any } = { status: 200 };
58+
const res = {
59+
json: vi.fn((data: unknown) => { state.body = data; }),
60+
send: vi.fn(),
61+
status: vi.fn((code: number) => { state.status = code; return res; }),
62+
header: vi.fn(() => res),
63+
} as unknown as IHttpResponse;
64+
const req = {
65+
params: { namespace: 'localization' },
66+
query: {},
67+
body: opts.body,
68+
headers: opts.headers ?? {},
69+
method: route.split(' ')[0],
70+
path: '/api/settings/localization',
71+
} as IHttpRequest;
72+
await http.routes.get(route)!(req, res);
73+
return { status: state.status, body: state.body };
74+
}
75+
76+
const admin = () => ({ enforced: true, permissions: ['setup.access', 'setup.write'], userId: 'usr_admin' });
77+
78+
async function boot(env: Record<string, string> = {}): Promise<{
79+
kernel: LiteKernel;
80+
i18n: LocaleListI18nPlugin;
81+
service: SettingsService;
82+
http: MockHttp;
83+
}> {
84+
const i18n = new LocaleListI18nPlugin();
85+
const kernel = new LiteKernel({ logger: { level: 'error' } as never });
86+
kernel.use(i18n);
87+
kernel.use(new SettingsServicePlugin({ registerRoutes: false, env }));
88+
await kernel.bootstrap();
89+
const service = kernel.getService<SettingsService>('settings');
90+
const http = new MockHttp();
91+
registerSettingsRoutes(http, service, { contextFromRequest: admin });
92+
return { kernel, i18n, service, http };
93+
}
94+
95+
const servedLocaleOptions = (body: any): Array<{ value: string; label: string }> =>
96+
body.data.manifest.specifiers.find((s: { key?: string }) => s.key === 'locale').options;
97+
98+
const getPage = (http: MockHttp, headers?: Record<string, string>) =>
99+
call(http, 'GET /api/settings/:namespace', { headers });
100+
const save = (http: MockHttp, body: unknown) => call(http, 'PUT /api/settings/:namespace', { body });
101+
102+
describe('localization.locale serves the deployment\'s locale list', () => {
103+
let booted: Awaited<ReturnType<typeof boot>>;
104+
beforeAll(async () => {
105+
booted = await boot();
106+
});
107+
afterAll(async () => {
108+
await booted.kernel.shutdown();
109+
});
110+
afterEach(async () => {
111+
await booted.service.setMany('localization', { locale: null });
112+
});
113+
114+
it('offers what the i18n service reports, each named in its own language', async () => {
115+
booted.i18n.locales = ['en', 'zh-CN', 'fr', 'vi-VN'];
116+
const page = await getPage(booted.http);
117+
expect(page.status).toBe(200);
118+
const options = servedLocaleOptions(page.body);
119+
// The manifest default `en-US` is in effect, a second spelling of `en`:
120+
// that option is offered under the stored spelling, so it shows chosen.
121+
expect(options.map((o) => o.value)).toEqual(['en-US', 'zh-CN', 'fr', 'vi-VN']);
122+
expect(options.slice(0, 3).map((o) => o.label)).toEqual(['English', '简体中文', 'Français']);
123+
expect(options[3].label).toBe(localeSelfName('vi-VN'));
124+
// The registered manifest is left as declared.
125+
const declared = (booted.service.getManifest('localization').specifiers as any[]).find((s) => s.key === 'locale');
126+
expect(declared.options.map((o: { value: string }) => o.value)).toEqual(['en-US', 'zh-CN', 'ja-JP', 'es-ES']);
127+
});
128+
129+
it('accepts a language the deployment supports that the declared four never named', async () => {
130+
booted.i18n.locales = ['en', 'zh-CN', 'fr-FR'];
131+
const saved = await save(booted.http, { locale: 'fr-FR' });
132+
expect(saved.status).toBe(200);
133+
expect(saved.body.data.values.locale.value).toBe('fr-FR');
134+
expect((await booted.service.get('localization', 'locale')).value).toBe('fr-FR');
135+
});
136+
137+
it('follows the list at runtime: a translation item added later is accepted without a reboot', async () => {
138+
booted.i18n.locales = ['en', 'zh-CN'];
139+
const before = await save(booted.http, { locale: 'vi-VN' });
140+
expect(before.status).toBe(400);
141+
booted.i18n.locales = ['en', 'zh-CN', 'vi-VN'];
142+
const after = await save(booted.http, { locale: 'vi-VN' });
143+
expect(after.status).toBe(200);
144+
expect((await booted.service.get('localization', 'locale')).value).toBe('vi-VN');
145+
});
146+
147+
it('refuses a language the deployment does not support, as any value outside a table is refused', async () => {
148+
booted.i18n.locales = ['en', 'zh-CN'];
149+
const refused = await save(booted.http, { locale: 'ja-JP' });
150+
expect(refused.status).toBe(400);
151+
expect(refused.body.error.code).toBe('SETTINGS_VALIDATION');
152+
expect(refused.body.error.details.fields).toEqual([
153+
expect.objectContaining({ field: 'locale', code: 'invalid_option', value: 'ja-JP', constraint: { allowed: 'en, zh-CN' } }),
154+
]);
155+
// Nothing was written.
156+
expect((await booted.service.get('localization', 'locale')).source).toBe('default');
157+
});
158+
159+
it('accepts the spelling stored values carry for an offered language (`en-US` where `en` is offered)', async () => {
160+
booted.i18n.locales = ['en', 'zh-Hans'];
161+
expect((await save(booted.http, { locale: 'zh-CN' })).status).toBe(200);
162+
expect((await save(booted.http, { locale: 'en-US' })).status).toBe(200);
163+
// A code the platform locale table does not alias is accepted only as spelled.
164+
booted.i18n.locales = ['en', 'fr'];
165+
expect((await save(booted.http, { locale: 'fr-FR' })).status).toBe(400);
166+
});
167+
168+
it('keeps a stored value the deployment no longer offers readable, and serves it named as such', async () => {
169+
booted.i18n.locales = ['en', 'zh-CN', 'ja-JP'];
170+
expect((await save(booted.http, { locale: 'ja-JP' })).status).toBe(200);
171+
booted.i18n.locales = ['en', 'zh-CN'];
172+
173+
const page = await getPage(booted.http, { 'accept-language': 'zh-CN' });
174+
expect(page.body.data.values.locale.value).toBe('ja-JP');
175+
expect(servedLocaleOptions(page.body)).toEqual([
176+
{ value: 'en', label: 'English' },
177+
{ value: 'zh-CN', label: '简体中文' },
178+
{ value: 'ja-JP', label: `日本語 (${notOfferedCopy('zh-CN')})` },
179+
]);
180+
// It keeps being the value in effect, and a save of another key passes it by.
181+
expect((await save(booted.http, { fiscal_year_start: 'april' })).status).toBe(200);
182+
expect((await booted.service.get('localization', 'locale')).value).toBe('ja-JP');
183+
// Written again, it is judged like any other value.
184+
expect((await save(booted.http, { locale: 'ja-JP' })).status).toBe(400);
185+
});
186+
187+
it('stands on the declared four when the i18n service reports no locale', async () => {
188+
booted.i18n.locales = [];
189+
const page = await getPage(booted.http);
190+
expect(servedLocaleOptions(page.body).map((o) => o.value)).toEqual(['en-US', 'zh-CN', 'ja-JP', 'es-ES']);
191+
expect((await save(booted.http, { locale: 'fr-FR' })).status).toBe(400);
192+
expect((await save(booted.http, { locale: 'es-ES' })).status).toBe(200);
193+
});
194+
});
195+
196+
describe('an OS_LOCALIZATION_LOCALE override is judged against the same list, when it is read', () => {
197+
it('is in force for a language the deployment supports, and not reported at boot', async () => {
198+
const booted = await boot({ OS_LOCALIZATION_LOCALE: 'vi-VN' });
199+
try {
200+
booted.i18n.locales = ['en', 'vi-VN'];
201+
const resolved = await booted.service.get('localization', 'locale');
202+
expect(resolved.value).toBe('vi-VN');
203+
expect(resolved.source).toBe('env');
204+
booted.i18n.locales = ['en'];
205+
// No longer offered: the override is not in force, as any rejected one.
206+
expect((await booted.service.get('localization', 'locale')).source).not.toBe('env');
207+
} finally {
208+
await booted.kernel.shutdown();
209+
}
210+
});
211+
});
212+
213+
describe('the registration-time audit of OS_* overrides', () => {
214+
it('leaves a sourced key to be judged when read, and still judges a key with only a declared table', () => {
215+
const error = vi.fn();
216+
const svc = new SettingsService({
217+
env: { OS_LOCALIZATION_LOCALE: 'vi-VN', OS_LOCALIZATION_FISCAL_YEAR_START: 'smarch' },
218+
logger: { warn: vi.fn(), error } as never,
219+
});
220+
// The i18n service is not up yet at this point of a boot.
221+
svc.registerOptionSource('localization', 'locale', localizationLocaleOptionSource(() => undefined));
222+
svc.registerManifest(localizationSettingsManifest);
223+
const reported = error.mock.calls.map((c) => String(c[0]));
224+
expect(reported.some((line) => line.includes('OS_LOCALIZATION_LOCALE'))).toBe(false);
225+
expect(reported.some((line) => line.includes('OS_LOCALIZATION_FISCAL_YEAR_START'))).toBe(true);
226+
});
227+
});
228+
229+
describe('a service with no option source, or a failing one', () => {
230+
it('judges the declared table', async () => {
231+
const svc = new SettingsService({ env: {} });
232+
svc.registerManifest(localizationSettingsManifest);
233+
await expect(svc.setMany('localization', { locale: 'fr-FR' })).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION' });
234+
await svc.setMany('localization', { locale: 'ja-JP' });
235+
expect((await svc.get('localization', 'locale')).value).toBe('ja-JP');
236+
});
237+
238+
it('falls back to the declared table when the source throws, and says so once', async () => {
239+
const warn = vi.fn();
240+
const svc = new SettingsService({ env: {}, logger: { warn, error: vi.fn() } as never });
241+
svc.registerOptionSource('localization', 'locale', localizationLocaleOptionSource(() => {
242+
throw new Error('i18n exploded');
243+
}));
244+
svc.registerManifest(localizationSettingsManifest);
245+
await svc.setMany('localization', { locale: 'ja-JP' });
246+
await expect(svc.setMany('localization', { locale: 'fr-FR' })).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION' });
247+
expect(warn).toHaveBeenCalledTimes(1);
248+
expect(String(warn.mock.calls[0][0])).toContain("'localization.locale'");
249+
});
250+
});
251+
252+
describe('localeSelfName: a locale named in its own language, else its code', () => {
253+
it('takes the platform locale table\'s name for the language a code means', () => {
254+
expect(localeSelfName('en')).toBe('English');
255+
expect(localeSelfName('en-US')).toBe('English');
256+
expect(localeSelfName('zh-TW')).toBe('繁體中文');
257+
expect(localeSelfName('ja-JP')).toBe('日本語');
258+
});
259+
260+
it('answers the code for a tag nothing names, a malformed tag, and a runtime without Intl.DisplayNames', () => {
261+
expect(localeSelfName('xx-YY')).toBe('xx-YY');
262+
expect(localeSelfName('not a tag')).toBe('not a tag');
263+
const original = Intl.DisplayNames;
264+
try {
265+
(Intl as { DisplayNames?: unknown }).DisplayNames = undefined;
266+
expect(localeSelfName('vi-VN')).toBe('vi-VN');
267+
} finally {
268+
(Intl as { DisplayNames?: unknown }).DisplayNames = original;
269+
}
270+
});
271+
272+
it('names a language off the table through Intl.DisplayNames where the runtime\'s ICU data has it', () => {
273+
// Read from the runtime rather than written down: ICU builds differ, and
274+
// the pin is that the name comes from it, capitalized, or is the code.
275+
let expected = 'vi-VN';
276+
try {
277+
const name = new Intl.DisplayNames(['vi-VN'], { type: 'language', fallback: 'none' }).of('vi-VN');
278+
if (name && name !== 'vi-VN') expected = name.charAt(0).toLocaleUpperCase('vi-VN') + name.slice(1);
279+
} catch {
280+
// keep the code
281+
}
282+
expect(localeSelfName('vi-VN')).toBe(expected);
283+
});
284+
});

0 commit comments

Comments
 (0)