Skip to content

Commit 75e7021

Browse files
chaxusclaude
andcommitted
test: pin what the i18n split and the guard numbers were relying on
Two invariants this repository states and nothing was checking. Every shell language says everything. The compiler enforces that for en and zh-CN only -- the other five are Partial<I18nMessages>, so a missing key is silent and t() falls back to English, which reads like a translation somebody chose rather than one nobody wrote. All seven are complete today (99 strings each); this is what keeps them that way, and it is the check a new UI string has to pass. Reverse-validated: dropping one key from ja fails with its name. And every runtime guard has a unique number. The number is the guard's name in prose -- CLAUDE.md, the exploration records and a good many commit messages say "guard 8" and expect that to identify one file. Two pairs had drifted into sharing one (comment-selection/font-loading on 8, hint-fallback/unload-prompt on 11), quietly making four of those references ambiguous, and chrome and shared-worker had no number at all. Renumbered so 1..16 is unique and complete, without moving any number that existing records already point at. Found by reading today's work over rather than by anything failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MKNHFQ1kek1cHeqhbH37Pk
1 parent 727bb70 commit 75e7021

7 files changed

Lines changed: 137 additions & 6 deletions

File tree

‎CLAUDE.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,9 @@ lib/ # 应用层(纯 TypeScript,只在本站点用)
6565
editor-theme.ts # 站点主题 → 编辑器 uiTheme 的跟随
6666
onlyoffice-editor.ts # 编辑器生命周期门面:挂载/重建/loadEditorApi,并对外统一导出下面这些模块
6767
onlyoffice/ # 编辑器周边(2026-08-19 从 1975 行的单文件拆出,公开导出面不变)
68-
iframe-guards.ts # 14 条运行时守卫的编排;每条守卫一个文件在 guards/
68+
iframe-guards.ts # 16 条运行时守卫的编排;每条守卫一个文件在 guards/,
69+
# 文件头注释开头的编号就是它的身份(文档和提交信息里说
70+
# "守卫 8" 指的就是那个号),guards-numbering.test.ts 钉住不重号
6971
guards/ # chrome / shared-worker / fetch-fonts / image-pipeline /
7072
# serverless-save / long-action / series-settings /
7173
# font-loading / comment-selection / canvas-loss /

‎lib/onlyoffice/guards/chrome.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { COMPACT_VIEWPORT_MAX_WIDTH } from '../viewport';
22

33
/**
4-
* Strip the OnlyOffice chrome that has no place in a single-user local editor
4+
* 1. Strip the OnlyOffice chrome that has no place in a single-user local editor
55
* -- the current-user / co-users widgets, which describe a collaboration
66
* session this build cannot have -- and hide the right panel on phone-sized
77
* viewports. There is no DocEditor config switch for either in this build.

‎lib/onlyoffice/guards/font-loading.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/**
2-
* 8. Font-load acceleration. The SDK's CFontLoader works through fonts_loading
2+
* 15. Font-load acceleration. The SDK's CFontLoader works through fonts_loading
33
* strictly one family at a time (LoadFontAsync for the faces of
44
* fonts_loading[0], poll every 50ms until they land, shift, repeat), so a real
55
* CJK deck's 30 families cost 30 serial round trips of multi-MB downloads --
66
* minutes on a cold CDN path. Two coordinated patches, both
77
* semantics-preserving:
8-
* 8a. IsNeedDefaultFonts -> false: the Word/Slide editors preload
8+
* 15a. IsNeedDefaultFonts -> false: the Word/Slide editors preload
99
* Arial/Symbol/Wingdings/Courier/Times (12 files, 3.2 MB) on every open
1010
* "just in case"; the runtime path (LoadDocumentFonts2) already fetches any
1111
* of them on first actual use.

‎lib/onlyoffice/guards/shared-worker.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Shadow SharedWorker inside the editor iframe. The SDK's local spellchecker
2+
* 2. Shadow SharedWorker inside the editor iframe. The SDK's local spellchecker
33
* prefers `new SharedWorker(spell.js, ...)`, and loading that script on a cold
44
* profile of a service-worker-controlled origin hangs forever in Chromium (the
55
* request never settles; warm profiles are immune only because the previous

‎lib/onlyoffice/guards/unload-prompt.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* 11. One unload prompt, and the right one.
2+
* 16. One unload prompt, and the right one.
33
*
44
* The vendor sets `window.onbeforeunload` inside the editor frame whenever the
55
* document is editable, so a same-origin frame plus the app's own guard means

‎test/unit/guards-numbering.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { readFileSync, readdirSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
5+
/**
6+
* Every runtime guard has a number, and no two share one.
7+
*
8+
* The number is the guard's name in prose: CLAUDE.md, the exploration records
9+
* and a good many commit messages say "guard 8" or "守卫 10" and expect that to
10+
* identify one file. Two of them had drifted into sharing a number by
11+
* 2026-09-12 -- comment-selection and font-loading both called themselves 8,
12+
* hint-fallback and unload-prompt both 11 -- which quietly made four of those
13+
* references ambiguous, and two guards had no number at all.
14+
*
15+
* Numbers are also the one thing here that must never be recycled: "guard 5"
16+
* has to keep meaning what the record that mentions it meant. So this checks
17+
* uniqueness and coverage, not that the set is tidy.
18+
*/
19+
const GUARDS = resolve(__dirname, '../../lib/onlyoffice/guards');
20+
21+
/** The leading `N.` or `Guard N` of a file's own doc comment. */
22+
function numberOf(source: string): number | null {
23+
const match = /^ \* (?:Guard )?(\d+)[.:]?\s/m.exec(source.slice(0, 400));
24+
return match ? Number(match[1]) : null;
25+
}
26+
27+
describe('runtime guard numbering', () => {
28+
const files = readdirSync(GUARDS).filter((name) => name.endsWith('.ts'));
29+
30+
it('finds the guards (a bad path would make the checks below vacuous)', () => {
31+
expect(files.length).toBeGreaterThanOrEqual(16);
32+
});
33+
34+
it('gives every guard a number', () => {
35+
const unnumbered = files.filter((name) => numberOf(readFileSync(resolve(GUARDS, name), 'utf8')) === null);
36+
expect(unnumbered, 'guards without a number in their doc comment').toEqual([]);
37+
});
38+
39+
it('gives no two guards the same number', () => {
40+
const byNumber = new Map<number, string[]>();
41+
for (const name of files) {
42+
const n = numberOf(readFileSync(resolve(GUARDS, name), 'utf8'))!;
43+
byNumber.set(n, [...(byNumber.get(n) ?? []), name]);
44+
}
45+
const shared = [...byNumber.entries()]
46+
.filter(([, names]) => names.length > 1)
47+
.map(([n, names]) => `${n}: ${names.join(', ')}`);
48+
expect(shared, 'guards sharing a number').toEqual([]);
49+
});
50+
51+
it('numbers them 1..N with no gaps', () => {
52+
const numbers = files.map((name) => numberOf(readFileSync(resolve(GUARDS, name), 'utf8'))!).sort((a, b) => a - b);
53+
expect(numbers).toEqual(Array.from({ length: files.length }, (_, i) => i + 1));
54+
});
55+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { de } from '../../packages/shared/src/i18n/messages/de';
3+
import { en } from '../../packages/shared/src/i18n/messages/en';
4+
import { es } from '../../packages/shared/src/i18n/messages/es';
5+
import { ja } from '../../packages/shared/src/i18n/messages/ja';
6+
import { ko } from '../../packages/shared/src/i18n/messages/ko';
7+
import { pt } from '../../packages/shared/src/i18n/messages/pt';
8+
import { zhCN } from '../../packages/shared/src/i18n/messages/zh-CN';
9+
import { LanguageCode, SHELL_LOCALES } from '@ranuts/shared/i18n';
10+
11+
/**
12+
* Every language this site claims to ship says everything.
13+
*
14+
* The compiler only enforces that for en and zh-CN: they are typed
15+
* `I18nMessages`, the other five are `Partial<I18nMessages>` so that staying
16+
* complete is a choice rather than something a half-finished translation
17+
* cannot compile against. The cost of that choice is that a missing key is
18+
* silent -- `t()` falls back to English, which reads like a translation
19+
* somebody chose rather than one nobody wrote.
20+
*
21+
* CLAUDE.md and the README both say all seven tables are complete. This is
22+
* what makes that true rather than aspirational, and it is the check a new UI
23+
* string has to pass: add it to en, and this fails until the other six have it.
24+
*
25+
* Imported from source rather than from the built package: the tables are
26+
* internal, and the point is to compare them against each other.
27+
*/
28+
// Keyed by the internal language code, which is what SHELL_LOCALES holds --
29+
// `zh` there, `zh-CN` as a file name and as the tag the vendor wants.
30+
const asTable = (table: object): Record<string, unknown> => table as Record<string, unknown>;
31+
32+
const TABLES: Record<string, Record<string, unknown>> = {
33+
[LanguageCode.EN]: asTable(en),
34+
[LanguageCode.ZH]: asTable(zhCN),
35+
[LanguageCode.JA]: asTable(ja),
36+
[LanguageCode.KO]: asTable(ko),
37+
[LanguageCode.DE]: asTable(de),
38+
[LanguageCode.ES]: asTable(es),
39+
[LanguageCode.PT]: asTable(pt),
40+
};
41+
42+
describe('message tables', () => {
43+
it('covers every language the shell offers (and nothing it does not)', () => {
44+
expect(Object.keys(TABLES).sort()).toEqual([...SHELL_LOCALES].sort());
45+
});
46+
47+
it('gives every language every string en has', () => {
48+
const reference = Object.keys(en).sort();
49+
expect(reference.length).toBeGreaterThan(50);
50+
for (const [locale, table] of Object.entries(TABLES)) {
51+
const missing = reference.filter((key) => !(key in table));
52+
expect(missing, `${locale} is missing ${missing.length} string(s)`).toEqual([]);
53+
}
54+
});
55+
56+
it('has no string a language invented on its own', () => {
57+
// An extra key is a typo that will never be read -- `t()` looks up by the
58+
// name en uses.
59+
const reference = new Set(Object.keys(en));
60+
for (const [locale, table] of Object.entries(TABLES)) {
61+
const extra = Object.keys(table).filter((key) => !reference.has(key));
62+
expect(extra, `${locale} has ${extra.length} string(s) en does not`).toEqual([]);
63+
}
64+
});
65+
66+
it('leaves no string empty', () => {
67+
for (const [locale, table] of Object.entries(TABLES)) {
68+
const blank = Object.entries(table)
69+
.filter(([, value]) => typeof value === 'string' && value.trim() === '')
70+
.map(([key]) => key);
71+
expect(blank, `${locale} has blank string(s)`).toEqual([]);
72+
}
73+
});
74+
});

0 commit comments

Comments
 (0)