diff --git a/docs/I18N.md b/docs/I18N.md index 1045d862..7e5bdf95 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -46,8 +46,9 @@ html`${tParts('第 {r} 回合 · 最终攻势', { r: html`$ ### Translating Edit `public/i18n/en.json` (`"msgid": "English"`, two-space JSON; `_about` is metadata). Keep every `{placeholder}`; -`test/i18n.test.js` checks that, that no Chinese is left in a value, and that every msgid of the converted screens is -present. Use the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts): +`test/i18n.test.js` checks that, that no Chinese is left in a value, and that every msgid the code uses is present. A +translation may use more params than its msgid (`'{n} {count|unit|units}'` with `count` passed beside a vnode `n`). Use +the official EN terms of the game (the EN client's own wording, e.g. its tips and mode texts): | 中文 | English | 中文 | English | |---|---|---|---| @@ -80,7 +81,15 @@ present. Use the official EN terms of the game (the EN client's own wording, e.g code uses (`--all`: also the literals not wrapped yet, under the msgid the codemod will give them). Server code is scanned too: the first argument of `msg()` and a Chinese literal handed to `m.toast` / `ctx.toast` / -`tickerText` are msgids (`server/sim` only for those — its own `t()` helpers are not translation calls). +`tickerText` are msgids (`server/sim` only for those — its own `t()` helpers are not translation calls). Not player text, +so neither wrapped nor counted: `console.*` / logger calls (`log.warn(…)`, `this.m.log?.info?.(…)`), `Error` messages, +the `detail` of an error result (`{ error: 'BAD_TARGET', detail: '…' }`, `ev.detail = '…'` — players see `ERR_TEXT`), a +line marked `// i18n-ignore` and a file marked `i18n-ignore-file` in a comment (the developer invariant reports +`server/match/audit.js` / `invariants.js`, the bilingual HTTP error pages `server/http/*`). An aliased import +(`import { t as tr }`, where `t` is a local variable) is read as the function it names. + +`test/i18n.test.js` scans what `extract` / `check` scan (public/js, shared, server) and fails on a Chinese literal left +unwrapped or a msgid missing from `public/i18n/en.json` — `extract` 0 and `check --strict` are part of the tests. ## 2. Game texts: `data/i18n/en.json` @@ -138,21 +147,38 @@ protocol is backward compatible both ways (new fields are optional). this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) })); ``` -## 4. Phase 2 (after the 0.2.0 feature screens land) +Game texts the server copies into `m.public` / `m.private` in Chinese — a 机变 card's name and description, the draft's +name, an effect entry of the effects column, a reward offer's label, a result title — are shown from the localized data +on the client: names through `tName()` (every record name is in the overlay's `names`), texts through +`ui/lang.js sentText(sent, raw, local)`, which takes the localized record's text when the sent text is that record's own +Chinese text (`data.lookupRaw` / `data.getRaw`), else the sent text (a line the server reworded — the two-battle text of a +multi-round 悬赏, `server/match/choices.js bountyText` — stays Chinese). In Chinese both return what the server sent. + +## 4. Coverage of the UI strings (phase 2, 0.2.0) Phase 1 converted the title screen, the lobby, the room, the settings panel (with the language switch), the app shell -(main.js: toasts, close reasons, the crash screen), `ui/toasts.js`, `DifficultyTag` and the server messages. Phase 2: +(main.js: toasts, close reasons, the crash screen), `ui/toasts.js`, `DifficultyTag` and the server messages. Phase 2 +(2026-10-06) converted the rest: of the 1 128 Chinese literals `extract` listed, 1 056 go through `t()` / `tParts()` / +`N_()` — the briefing, the band draft, the game screen and its overlays, 干员调配 / 干员持有, the result (275), the HUD, +shop bar, detail card, bond strip, 机变 overlay, enemy drawer, team panel, guide, emotes, components and the client +models (579), the phase banners, refusal reasons and formats of `ui/gameLogic` (106), the canvas / fallback-field / facing +labels (16) and the msgid tables of `shared/constants.js` (80) — and 72 are marked as no player text (above). Now +`node tools/i18n.mjs extract` reports 0, `check` 1 002 msgids, all in `public/i18n/en.json` (1 015 entries). The Chinese +output did not change: a render of every converted file in Chinese (each `t()` call put back as the string it renders, +html`` parsed with htm, text runs compared) equals the code before the sweep; the few rewrites it cannot follow (a +variable folded into whole sentences, a list param in place of `.join('、')`, `fmtNum`'s English branch) were checked by +hand. Numbers: `fmtNum` writes 万 / 亿 in Chinese and K / M / B in English. -1. `node tools/i18n.mjs extract` lists the rest (2026-10-05: 1 033 literals in 56 files — briefing, band draft, game - screen and its HUD / panels / overlays, loadout, result, guide, connection banner, components …; 964 of them already - have a translation seeded from PR #70). -2. Per file (one stream per file group to avoid conflicts): `node tools/i18n.mjs codemod --write`, review the - diff (module-level tables: `t()` at the use site; join split sentences; `tc()` where one Chinese text needs two - English ones), then `node tools/i18n.mjs check` and translate what is missing, in the official terms above. -3. Texts the server puts in `m.public` / `m.private` (机变 card names and descriptions, effect names in views) should be - looked up by id on the client (`getEffect` / `data.lookup`) instead of shown as sent. -4. `public/js/ui/guide.js` chapter / page titles, the `GuideButton` / `LoadoutButton` default labels and tooltips. -5. When the sweep is complete: `node tools/i18n.mjs check --strict` in CI, and `extract` reporting 0. +What stays Chinese in English, and why: + +- player names, including the AI seats' (`AI·华法琳` …, `server/lobby.js BOT_NAMES`) — a name is never translated; +- a server text the client cannot match to a data record (the reworded multi-round 悬赏 text above); +- game texts the official EN client does not have yet (§2: some 自选 operators, newer modules); +- the 玩法说明 pages, which are the official Chinese pictures (their titles are translated). + +Not player text, never translated: developer details and logs (`detail`, `log.*`, the invariant reports), the data keys +(`shared/highGround.js` reads the trait text 「可以放置于远程位」), the development STUB match, and the HTTP error pages, +which are bilingual on purpose. ## Credits diff --git a/public/i18n/en.json b/public/i18n/en.json index a3308fab..325e6795 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -183,10 +183,7 @@ "战场": "Battlefield", "本局禁用": "Disabled this match", "{skip};本局不限时": "{skip}; no time limit this match", - "每位博士有 {0} 秒": "each Doctor has {0} s", "每位博士限时决策": "each Doctor has a time limit", - "当前选中的": "the currently highlighted ", - "已选择「{0}」{1}": "Selected \"{0}\"{1}", ",等待其他博士": ", waiting for other Doctors", "轮到你决策": "Your turn to decide", "{turnName} 决策中": "{turnName} is deciding", @@ -211,8 +208,6 @@ "战场随机(共{poolN}张)": "Random Battlefield ({poolN} total)", "确认本局信息": "Confirm Match Info", "ENEMY LEADER // 敌方领袖": "ENEMY LEADER // Enemy Leader", - "第": "Round", - "回合 · 最终攻势": "· Final Assault", "特训敌人": "Tactical Training Enemies", "未知领袖": "Unknown Leader", "本局没有特训敌人": "No Tactical Training Enemies this match", @@ -228,11 +223,9 @@ "当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "3D / WebGL rendering is unavailable on this device; switched to a simplified view (all features still work)", "已配发的装备无法销毁": "Equipped Equipment cannot be destroyed", "销毁道具": "Destroy Item", - "道具无法出售。确定要销毁「{0}」吗?": "Items cannot be sold. Destroy \"{0}\"?", "道具": "Item", "销毁": "Destroy", "正在查看": "Viewing", - "的阵地(只读)": "'s Field (read-only)", "返回自己": "Back to Mine", "你已被淘汰 · 可继续观战队友": "You have been eliminated · You can keep spectating your Teammates", "已调整": "Customized", @@ -274,7 +267,6 @@ "按盟约筛选": "Filter by Alliance", "全部盟约": "All Alliances", "仅看已调整": "Customized Only", - "{t}阶": "Tier {t}", "保存中…": "Saving…", "同步中…": "Syncing…", "已同步": "Synced", @@ -308,7 +300,6 @@ "把导出的内容粘贴到下方,或点「选择文件」。": "Paste exported data below, or tap [Choose File]. ", "覆盖": "overwrite", "在此粘贴导出的调配内容…": "Paste the exported loadout here…", - "调整干员携带的技能与模组": "Change the Skills and Modules your Operators carry", "造成伤害": "Damage Dealt", "击倒敌人": "Enemies Defeated", "领袖伤害": "Leader Damage", @@ -335,8 +326,6 @@ "防线已被突破": "The defense line has been breached", "敌方领袖": "Enemy Leader", "隐秘核心": "Hidden Core", - "本局耗时": "Match duration:", - "分钟": "min", "返回大厅": "Back to Lobby", "暂无结算数据": "No results data", "服务器会话已重置,上一局模拟已结束": "Server session was reset. The previous simulation has ended.", @@ -349,8 +338,6 @@ "(含调和 +{harmony})": " (incl. Harmony +{harmony})", "层数叠加已禁用": "Stack accumulation disabled", "调和": "Harmony", - "{0} 在场": "{0} on field", - "{harmonyName}已激活": "{harmonyName} active", "层数": "Stacks", "盟约效果": "Alliance Effect", "成员": "Members", @@ -358,12 +345,9 @@ "盟约:{name}": "Alliance: {name}", "CORE BOND // 核心盟约": "CORE BOND // Core Alliance", "ADD-ON BOND // 附加盟约": "ADD-ON BOND // Add-on Alliance", - "的盟约": "'s Alliance", "在场": "On field", "(含整备区)": "(incl. Bench)", "(含{harmonyName} +{harmony})": "(incl. {harmonyName} +{harmony})", - "已激活{0}": "Active{0}", - " · {tier} 阶": " · Tier {tier}", "未激活": "Inactive", "本模式下该盟约不会激活(其干员仍可能因所属的其他盟约出现)": "This Alliance never activates in this mode (its Operators may still appear through their other Alliances)", "名及以下": " or fewer", @@ -440,7 +424,6 @@ "无": "None", "攻击范围": "Attack Range", "全场": "Whole field", - "基础 {0}": "Base {0}", "当前作战中的实时数值(绿色为增益,红色为减益)": "Live values in the current battle (green: buffed, red: debuffed)", "下一场作战开始时的数值:已计入装备、盟约层数、特质、策略与机变效果(不含技能与作战中的临时效果)": "Values at the start of the next battle: Equipment, Alliance stacks, Attributes, Strategy and Improv effects included (skills and temporary in-battle effects excluded)", "实时": "Live", @@ -496,7 +479,6 @@ "作战开始时在摆放的位置部署一次,之后所属干员每次发动技能时再次出现(未摆放则不会出现)": "Deploys once on its placed tile when the battle starts, then reappears each time its Operator activates a skill (does not appear if not placed)", "所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)": "Appears on its placed tile only when its Operator activates a skill (does not appear if not placed)", "出售精锐干员": "Sell Elite Operator", - "确定要出售精锐干员「{0}」吗?出售后获得 {1} 资金。": "Sell the Elite Operator \"{0}\"? You will get {1} Funds.", "详情": "Details", "退出全屏": "Exit Fullscreen", "全屏": "Fullscreen", @@ -509,8 +491,6 @@ "表情主题": "Emote themes", "悬赏": "Bounty", "护卫": "Escort", - "回合 · 即将迎击": "·", - "名敌人": "incoming enemies", "暂无敌方情报": "No enemy intel yet", "本局禁用干员情况": "Banned Operators this match", "我的策略": "My Strategy", @@ -522,22 +502,16 @@ "该干员": "This Operator", "将被销毁": "Will be destroyed", "替换装备": "Replace Equipment", - "」的装备栏已满(2/2)。选择一件装备进行替换,": "]'s Equipment slots are full (2/2). Choose a piece of Equipment to replace.", "被替换的装备将被销毁": "The replaced Equipment will be destroyed", "新装备": "New Equipment", "选择被替换的装备": "Choose Equipment to replace", - "将销毁「": "Destroy [", - "」并配发「": "] and equip [", "点击选择要替换的装备": "Tap to choose the Equipment to replace", "上": "Up", "右": "Right", "下": "Down", "左": "Left", - "选择{0}朝向": "Choose facing{0}", - "「{name}」的": " for [{name}]", "点击取消": "Tap to cancel", "拖回中心区域取消": "Drag back to center to cancel", - "朝向:{0}": "Facing: {0}", "干员": "Operator", "整备区": "Bench", "召唤": "Summon", @@ -546,7 +520,6 @@ "已淘汰": "Eliminated", "已离开": "Left", "{name}:本局禁用(该盟约不会激活)": "{name}: Disabled this match (this Alliance will not activate)", - "本局禁用{0}盟约,此策略效果可能无法发挥": "{0} Alliance disabled this match; this Strategy's effect may not work", "行动中": "Acting", "作战中": "In Combat", "联防中": "Uniting", @@ -561,7 +534,6 @@ "作战开始": "Battle Start", "各自行动阶段": "Individual action phase", "联防阶段": "Unite Phase", - "联防:{0}": "Unite: {0}", "完美作战的博士迎战突破防线的敌人": "Doctors with a flawless battle take on the enemies that broke through", "最终攻势": "Final Assault", "击败敌方领袖": "Defeat the Enemy Leader", @@ -626,28 +598,16 @@ "下一页": "Next page", "页码": "Pages", "联防": "Unite", - "{name} 漏过的敌人": "Enemies that got past {name}", - "你漏过的敌人": "Enemies that got past you", - "队友正在迎战": "teammates are engaging", - "{who}已全部被击倒": "{who}: all defeated", "剩余敌人 {v}": "Enemies remaining: {v}", "剩余不足 {cap} 个后,队友每击倒一个少扣 1 点": "once fewer than {cap} remain, each one your teammates defeat costs 1 less", "队友每击倒一个就少扣 1 点": "each one your teammates defeat costs 1 less", "目标生命值 {lp}:联防中,你漏过的敌人还剩 {left} 个,{each};按现在结算扣除 {pending} 点(每回合至多 {cap} 点)": "LP {lp}: Unite in progress, {left} enemies that got past you remain; {each}. Settling now would cost {pending} LP (max {cap} per round)", "目标生命值 {lp}:联防中,队友正在迎战你漏过的敌人,结算时按联防后剩余的敌人扣除(至多 {pending} 点)": "LP {lp}: Unite in progress, teammates are engaging the enemies that got past you; the loss at settlement depends on the enemies left after Unite (max {pending} LP)", - "{cap} 个以上": "{cap}+", - "{pending} 个": "{pending}", "放入整备区或战场、配发或使用后才能准备就绪;休整期结束时仍留在临时整备区的单位将被销毁": "Move them to the Bench or Battlefield, equip or use them before you can Ready; units still on the Temporary Bench when the Rest Phase ends will be destroyed", - "临时整备区还有 {count} 个单位:{TEMP_RULE}": "{count} unit(s) still on the Temporary Bench: {TEMP_RULE}", "取消准备": "Cancel Ready", - "临时整备区": "Temporary Bench:", - "个单位待处理": "unit(s) pending", "本局信息(策略 / 禁用盟约 / 干员)": "Match Info (Strategies / Banned Alliances / Operators)", "查看即将迎击的敌方单位": "View the enemies you are about to face", "休整期可以查看即将迎击的敌方单位": "Upcoming enemies can be viewed during the Rest Phase", - "秒后全队生命值开始流失": "s", - "超时 · 生命值": "Overtime · LP", - "/秒": "/s", "已流失": "Lost", "暂停": "Pause", "继续作战(Space)": "Resume Battle (Space)", @@ -667,15 +627,12 @@ "这不是干员调配的数据": "This is not Operator Loadout data", "里面没有有效的调配条目": "It contains no valid loadout entries", "弹药": "Ammo", - "{0}秒": "{0}s", "本局的干员调配已锁定,修改将在下一局生效": "Operator loadout is locked for this match; changes will apply next match", "独立模拟的休整期没有时间限制,你可以随时继续。": "The Rest Phase in Solo Simulation has no time limit. You can continue at any time.", "放弃模拟将立即结束本局并返回大厅,本局进度不会保留,也不会进行结算。": "Abandoning the simulation ends this match immediately and returns you to the lobby. Progress will not be kept and no results will be tallied.", "离开模拟": "Leave Simulation", "暂离(AI 托管)": "Step Away (AI Autopilot)", "暂离": "Step Away", - ":由 AI 托管你的席位(自动部署、准备与选择),随时可以返回。": ": AI takes over your seat (auto deploy, ready-up and choices). You can return at any time.", - ":离开后无法返回本局,你的干员将回到共享卡池。": ": You cannot return to this match after leaving, and your Operators go back to the shared pool.", "AI 托管中": "AI Autopilot active", "AUTOPILOT // AI 托管": "AUTOPILOT // AI AUTOPILOT", "AI 正在代为操作你的席位": "AI is playing your seat for you", @@ -697,7 +654,6 @@ ",再次点击确认": ", tap again to confirm", "已拥有 {copies}/{need}": "Owned {copies}/{need}", "可晋升": "Promotable", - "技能{0}:{1}{2}{3}": "Skill{0}: {1}{2}{3}", "(已调配)": " (Assigned)", " · 模组:{name}": " · Module: {name}", " · 未装备模组": " · No Module equipped", @@ -719,23 +675,17 @@ "解冻": "Unfreeze", "刷新商店 · R": "Refresh Shop · R", "免费 ×{free}": "Free ×{free}", - "联防中,": "In Unite; ", - "至多": "up to ", "查看 {name} 的战场": "View {name}'s Battlefield", "查看自己的阵地": "View your Field", "查看 {name} 的阵地": "View {name}'s Field", "连接已断开": "Disconnected", "前往查看": "Go View", - "{0} 操作": "{0} actions", "单位": "Unit", "撤退": "Retreat", "撤退至整备区": "Retreat to Bench", "出售,获得 {sell} 资金": "Sell for {sell} Funds", "已准备就绪后进入的单位保留到下个休整期,届时仍在此处的将被销毁(取消准备则在本休整期结束时销毁)": "Units that arrived after Ready are kept until the next Rest Phase; anything still here then will be destroyed (cancel Ready and they are destroyed at the end of this Rest Phase)", "放入整备区或战场、配发或使用后才能准备;休整期结束时仍在此处的将被销毁": "Move to the Bench or Battlefield, equip or use them before you can Ready; anything still here at the end of the Rest Phase will be destroyed", - "件道具": "item(s)", - "个单位": "unit(s)", - "{what}待处理": "{what} pending", "结算": "Settlement", "模拟结束": "Simulation Over", "表情套组:卫戍协议": "Emote Set: Stronghold Protocol", @@ -778,20 +728,6 @@ "维维美 4": "Wiwime 4", "维维美 5": "Wiwime 5", "维维美 6": "Wiwime 6", - "\n{status} · 卫戍协议:盟约

{status}

{2}

\n{3}

返回首页 · Back to home

": "\n{status} · Stronghold Protocol: Alliance

{status}

{2}

\n{3}

Back to home

", - "页面不存在 · Not found": "Not found", - "禁止访问 · Forbidden": "Forbidden", - "请求地址过长 · URI too long": "URI too long", - "请求地址无效 · Bad request": "Bad request", - "不支持的请求方法 · Method not allowed": "Method not allowed", - "服务器内部错误 · Internal error": "Internal error", - "AI·华法琳": "AI·Warfarin", - "AI·阿米娅": "AI·Amiya", - "AI·惊蛰": "AI·Leizi", - "AI·杜宾": "AI·Dobermann", - "AI·凯尔希": "AI·Kal'tsit", - "AI·可露希尔": "AI·Closure", - "对局核心尚未实现(平台占位 STUB):全员确认本局信息或倒计时结束后将直接结算。": "Match core not implemented (STUB): the match settles once everyone confirms or the countdown ends.", "精打细算玩偶": "Budget-Savvy Doll", "悬赏决策": "Bounty Decision", "道具补给": "Item Supply", diff --git a/test/i18n.test.js b/test/i18n.test.js index e250c22d..44968000 100644 --- a/test/i18n.test.js +++ b/test/i18n.test.js @@ -4,7 +4,7 @@ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { @@ -116,7 +116,7 @@ test('server wire format: text keeps the Chinese rendering for older clients; pa test('public/i18n/en.json: valid, English values, placeholders kept, the pilot screens and server messages translated', () => { const entries = Object.entries(EN).filter(([k]) => !k.startsWith('_')); - assert.ok(entries.length > 800, `seeded from PR #70: ${entries.length}`); + assert.ok(entries.length >= 1000, `PR #70's seed + phase 2: ${entries.length}`); for (const [k, v] of entries) { assert.equal(typeof v, 'string', k); assert.ok(v.trim(), `empty translation for ${k}`); @@ -126,25 +126,40 @@ test('public/i18n/en.json: valid, English values, placeholders kept, the pilot s for (const p of want) assert.ok(have.has(p), `${k}: {${p}} missing in "${v}"`); } for (const k of ['开始', '博士代号', '设置', '语言', '同盟模拟', '创建同盟', '加入同盟', '开始模拟', 'toggle::关闭', '整备区已满', + '第 {r} 回合 · 最终攻势', '本局禁用{names}盟约,此策略效果可能无法发挥', 'bond-threshold::名', 'list::、', '干员持有', '先锋', '开心', '{names}只能部署在召唤者攻击范围内,已退回整备区', '{name}博士的目标生命值已耗尽', '联防阶段:{names} 迎战突破防线的敌人', '【维多利亚】获得{n}件维式重锤']) { assert.ok(EN[k], `en.json lacks ${k}`); } }); -test('every msgid of the pilot screens, the shell and the server messages is in public/i18n/en.json', async () => { - const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js', - 'public/js/screens/diy.js', - 'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js', - 'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js', - 'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js', - 'server/sim/content/bonds/addon/meta.js', 'server/sim/content/bonds/core.js']; +test('the whole client, shared and the server messages: no Chinese literal left unwrapped, every msgid in public/i18n/en.json (extract 0, check --strict)', async () => { + // what tools/i18n.mjs scans: public/js (not dev / vendor / assets / fonts), shared, server — server/sim only for the + // texts it sends (msg() / ctx.toast), its own t() / N_() are other helpers + const SKIP = new Set(['node_modules', 'vendor', 'assets', 'fonts', 'dev']); + const files = []; + const visit = (rel) => { + for (const name of readdirSync(path.join(ROOT, rel)).sort()) { + if (SKIP.has(name) || name.startsWith('.')) continue; + const r = `${rel}/${name}`; + if (statSync(path.join(ROOT, r)).isDirectory()) visit(r); + else if (/\.m?js$/.test(name)) files.push(r); + } + }; + for (const root of ['public/js', 'shared', 'server']) visit(root); + assert.ok(files.length > 300, `${files.length} files`); const missing = []; + let used = 0; for (const f of files) { const { msgids, literals } = await scanSource(readFileSync(path.join(ROOT, f), 'utf8'), f); const server = f.startsWith('server/'); - for (const m of msgids) if ((!server || m.via === 'msg' || m.via === 'server') && !EN[m.msgid]) missing.push(`${f}:${m.line} ${m.msgid}`); - if (!server) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`); + for (const m of msgids) { + if (server && m.via !== 'msg' && m.via !== 'server') continue; + used++; + if (!EN[m.msgid]) missing.push(`${f}:${m.line} ${m.msgid}`); + } + if (!f.startsWith('server/sim/')) for (const l of literals) if (!l.reason) missing.push(`${f}:${l.line} not wrapped: ${l.msgid}`); } + assert.ok(used >= 1350, `msgid uses: ${used} (1 404 at phase 2)`); assert.deepEqual(missing, []); });