Files
houseplan-card/test/i18n.test.mjs
Claude bf6602bbd8 feat(device): «Устройство на плане» по референсу — серия 4 из четырёх (#600)
§7 SPEC.md референса. Пять карточек #598 остались, менялось содержимое; все
ключи _markerDialog и все записи в них прежние (§7.2):

- Basics — баннеры привязки стали callout'ами; Name с подсказкой «Shown on
  the plan»; привязка — сегмент [Virtual | From the HA list] (те же радио
  bmode) и кнопка выбора #marker-binding с панелью в потоке (sourcePicker
  набора), чекбокс «Show entities» — внутри панели рядом с поиском (решение
  владельца Q8); Room селектом с подсказкой «по зоне / переопределяет»;
  секции радара и пылесоса — своими модулями без изменений;
- Tap action — селект с подсказкой Target/Now, «Ask for confirmation» —
  строка-тумблер только при toggle/run (Q8), Entity to toggle и What to run
  теми же условиями (список целей — панель набора), «Controls other light
  sources» — чипы набора и поиск с панелью в потоке;
- Light and glow — «Include the device temperature…» строкой-тумблером;
  роль — сегмент [Auto | Always | Never] с подсказкой результата Auto;
  Leading light entity по условиям; блок свечения (.hpf-block): подзаголовок
  с «?», сегмент [From source | Set colour | Colour and brightness] с
  отключёнными вариантами и aria-describedby на объяснение, плашка цвета
  вокруг прежнего hp-color-opacity (Q5), яркость и радиус строками с
  числом и единицей, при Never блок неактивен целиком (aria-disabled) с
  callout'ом причины;
- Appearance — поле иконки с превью, очисткой и ссылкой Pin в подсказке;
  Display селектом с подсказкой режима; пульс — плашка цвета и строка
  размера; Value source по условиям; подраздел Value badge — компактный
  тумблер, Value селектом с техническим идентификатором, Position сегментом
  [Above | Right | Below | Left]; предупреждения бейджа — заметки и
  callout'ы; Display preview — прежний hp-device-preview целиком в блоке на
  тинте с тегом Now (решение владельца Q3); «Icon size and rotation» —
  две строки range-line в сетке;
- Details — Model и Link в ряд, Description textarea набора, Manuals чипами
  и строкой действий Attach…, «Additional actions» подзаголовком набора.

Футер: Hide и Delete слева, статус, Cancel и Save справа. К10: снимок
черновика при _openMarkerDialog (marker-form-state.ts поверх
dialog-baseline; транзиентные ключи — раскрытие и фильтры списков, busy,
объявление подсказки, autoIcon, uploadId); в edit Save только при
изменениях, закрытие с изменениями спрашивает через _confirmDanger. Прежние
запреты Save (busy, калибровка радара, привязка не выбрана/не подтверждена)
остались; привязка и радиус свечения названы словами под полем
(marker.error_binding, marker.error_glow_radius), «Review N fields» ведёт к
полю. Тумблеры диалога — строки набора (нативный чекбокс, К7), а не
ha-switch; слайдеры — прежний _rangeInput (ha-slider в HA).

Набор: sourcePicker получил toolbar и disabled; сегмент — value у радио,
disabled и describedBy у варианта; callout — role note и id; unitInput —
describedBy; rangeLine — id; новые классы .hpf-block/.hpf-disabled,
.hpf-toolbar/.hpf-check, .hpf-iconfield/.hpf-iconpreview, .hpf-mono,
.hpf-note, .hpf-hint-line, textarea.hpf-input. settingsCopy вынесен в
editors/settings-copy.ts (общий для пространства, комнаты, устройства).

Смоки переписаны осознанно, с пометкой #600: binding_ui (#marker-binding,
.hpf-panel, #marker-show-entities), tap_run (панель набора, #marker-tap-
confirm), value_face_source (.hpf-cand.sel), static_icon (callout'ы),
climate_temp (#marker-use-climate-temp), help_affordance (#marker-name,
.hpf-block.hpf-disabled вместо fieldset[disabled]; второй Escape в грязном
диалоге спрашивает — К10), size_angle_parity (сетка Appearance),
color_picker_consumers (подписи полей), device_preview_parity (сегмент
стороны), ha_controls (тумблеры набора — нативные чекбоксы по К7, ha-slider
прежний). Golden-harness device — .hpf-field/.hpf-block, скроллер .content.
Новый фокусный свидетель demo/smoke_device_settings_form.mjs: пять карточек
без старой разметки, оболочка, привязка/панель/Show entities, Q8, сегменты и
блок свечения, плашки, иконка, бейдж, превью на тинте, размеры, Details,
цели 44, Save/dirty/вопрос при закрытии, create-режим, семь полос экранов.

Мутанты: marker-save-enabled-without-changes, marker-discard-without-asking,
marker-confirm-row-shown-for-do-nothing, marker-never-keeps-glow-block-live,
marker-badge-position-forgets-touch; якоря same-binding-click-resets-source,
state-callout-hidden-under-help, form-kit-segment-drops-radio-semantics
переставлены на новую разметку.

Контрактные тесты: color-picker (два поля набора вместо flex-строк),
native-select-contract (сторона бейджа — сегмент), i18n dead-keys (34 → 38
подсказок), German=English (marker.name_label). Словарь i18n/settings: 21
ключ marker.* на четыре локали; в основном каталоге marker.name_label
«Name», marker.size_label «Icon size and rotation», удалены мёртвые
marker.room_override, marker.light_role_always/never. Фикстуры листа
диалогов набора пересняты намеренно (панель — байт в байт, AC11).

Бюджет: стартовый граф 290 489 (потолок 291 600 без изменений); ленивый
редакторский 243 173 — потолок 240 100 → 243 900 с разбором в
bundle-budget.mjs; старые стили диалога устройства снимаются в финале.

Гейты: typecheck, build, bundle:sync (три копии байт в байт), npm test
2790+ pass по четырём частям, mutation-gate --check, 39 смоков диалога
устройства + новый + три формы серий 1–3.

Issue: #600
User-Visible: no
2026-09-20 12:18:12 +03:00

555 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import {
FALLBACK_LANGUAGE_CODE,
LANGUAGE_REGISTRY,
buildLanguageLookup,
dictionaryFor,
ensureLanguage,
languageEntry,
languageOptions,
normalizeLanguageTag,
resolveLanguageCode,
} from '../test-build/i18n/registry.js';
import { langOf } from '../test-build/i18n.js';
import { readHouseplanProductionSource } from './houseplan-source.mjs';
await Promise.all(LANGUAGE_REGISTRY.map(({ code }) => ensureLanguage(code)));
const dictionaries = new Map(LANGUAGE_REGISTRY.map(({ code }) => [code, dictionaryFor(code)]));
const en = dictionaries.get('en');
const ru = dictionaries.get('ru');
const de = dictionaries.get('de');
const fr = dictionaries.get('fr');
const cardSource = readHouseplanProductionSource();
const onboardingSource = readFileSync(
new URL('../src/houseplan-onboarding-runtime.ts', import.meta.url),
'utf8',
);
const helpSource = `${cardSource}\n${onboardingSource}`;
const supportDictionaries = new Map(LANGUAGE_REGISTRY.map(({ code }) => [
code,
JSON.parse(readFileSync(
new URL(`../src/i18n/support/${code}.json`, import.meta.url),
'utf8',
)),
]));
test('i18n: registry codes and English fallback are valid', () => {
const codes = LANGUAGE_REGISTRY.map(({ code }) => code);
const normalizedCodes = codes.map(normalizeLanguageTag);
assert.equal(FALLBACK_LANGUAGE_CODE, 'en');
assert.ok(dictionaries.has(FALLBACK_LANGUAGE_CODE));
assert.equal(new Set(codes).size, codes.length, 'registry codes must be unique');
assert.equal(
new Set(normalizedCodes).size,
normalizedCodes.length,
'registry codes must be unique after locale normalization',
);
for (const { code, nativeLabel, dictionary, loadDictionary } of LANGUAGE_REGISTRY) {
assert.equal(code, code.trim(), `${code} has surrounding whitespace`);
assert.doesNotMatch(code, /_/u, `${code} must use BCP 47 hyphens`);
assert.match(code, /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u);
assert.ok(nativeLabel.trim(), `${code} has no native label`);
assert.ok(
(dictionary && typeof dictionary === 'object') || typeof loadDictionary === 'function',
`${code} has neither an eager dictionary nor a lazy loader`,
);
}
});
test('i18n: canonical regional codes use normalized lookup keys', () => {
const regional = { code: 'pt-BR', nativeLabel: 'Português (Brasil)', dictionary: {} };
const lookup = buildLanguageLookup([regional]);
assert.equal(lookup.get('pt-br'), regional);
assert.equal(lookup.get(normalizeLanguageTag('PT_br')), regional);
for (const entry of LANGUAGE_REGISTRY) {
assert.equal(languageEntry(entry.code.toUpperCase()), entry);
}
});
test('i18n: registry matches frontend and backend locale files', () => {
const registryCodes = LANGUAGE_REGISTRY.map(({ code }) => code).sort();
const localeCodes = (url) => readdirSync(url)
.filter((name) => name.endsWith('.json'))
.map((name) => name.slice(0, -'.json'.length))
.sort();
assert.deepEqual(
localeCodes(new URL('../src/i18n/', import.meta.url)),
registryCodes,
'frontend locale files and registry differ',
);
assert.deepEqual(
localeCodes(new URL('../custom_components/houseplan/translations/', import.meta.url)),
registryCodes,
'backend locale files and registry differ',
);
});
test('i18n: backend dictionaries preserve the English structure and placeholders', () => {
const flatten = (value, prefix = '', result = new Map()) => {
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (child && typeof child === 'object' && !Array.isArray(child)) flatten(child, path, result);
else result.set(path, child);
}
return result;
};
const placeholders = (value) => (String(value).match(/\{\w+\}/gu) || []).sort();
const backend = new Map(LANGUAGE_REGISTRY.map(({ code }) => [
code,
flatten(JSON.parse(readFileSync(
new URL(`../custom_components/houseplan/translations/${code}.json`, import.meta.url),
'utf8',
))),
]));
const english = backend.get('en');
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = backend.get(code);
assert.deepEqual([...dictionary.keys()].sort(), [...english.keys()].sort(), `${code} backend key set differs`);
for (const [key, value] of english) {
assert.deepEqual(
placeholders(dictionary.get(key)),
placeholders(value),
`${code} backend placeholder mismatch at ${key}`,
);
}
}
});
test('i18n: every registered dictionary carries the English key set', () => {
const enKeys = Object.keys(en).sort();
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.deepEqual(Object.keys(dictionary).sort(), enKeys, `${code} key set differs`);
}
});
test('i18n: lazy editor dictionaries carry matching keys and placeholders', () => {
const supportEn = supportDictionaries.get('en');
const keys = Object.keys(supportEn).sort();
const placeholders = (value) => (String(value).match(/\{\w+\}/gu) || []).sort();
// #598: `gs.hint` переехал из support в основной каталог как
// `gs.card_fills.help` — «?» карточки читает его через `_t`, а не `supportT`.
// 78 → 77.
assert.equal(keys.length, 77);
assert.equal(Object.keys(en).filter((key) => key.startsWith('support.')).join(','), 'support.title');
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = supportDictionaries.get(code);
assert.deepEqual(Object.keys(dictionary).sort(), keys, `${code} support key set differs`);
for (const key of keys) {
assert.ok(dictionary[key], `${code}:${key} is empty`);
assert.deepEqual(
placeholders(dictionary[key]),
placeholders(supportEn[key]),
`${code} support placeholder mismatch at ${key}`,
);
}
}
});
test('i18n: no empty values', () => {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
for (const [k, v] of Object.entries(dictionary)) {
assert.ok(typeof v === 'string' && v.length > 0, `${code}:${k} is empty`);
}
}
});
test('i18n: placeholders match between languages', () => {
const ph = (s) => (s.match(/\{\w+\}/g) || []).sort().join(',');
for (const k of Object.keys(en)) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.equal(ph(en[k]), ph(dictionary[k]), `placeholder mismatch in ${code}:${k}`);
}
}
});
test('i18n: resolver supports exact, primary, explicit and fallback paths', () => {
const supported = ['en', 'ru', 'de', 'fr', 'pt-BR'];
const cases = [
{ explicit: 'RU', ha: 'en-US', expected: 'ru' },
{ explicit: 'pt_BR', ha: 'ru-RU', expected: 'pt-BR' },
{ explicit: 'unknown', ha: 'ru_RU', expected: 'ru' },
{ explicit: '', ha: 'pt-br', expected: 'pt-BR' },
{ explicit: null, ha: 'ru-RU', expected: 'ru' },
{ explicit: 'ru-RU', ha: 'en-GB', expected: 'en' },
{ explicit: undefined, ha: 'pt-PT', expected: 'en' },
{ explicit: undefined, ha: 'de-DE', expected: 'de' },
{ explicit: undefined, ha: 'de-AT', expected: 'de' },
{ explicit: undefined, ha: 'de-CH', expected: 'de' },
{ explicit: undefined, ha: 'fr-FR', expected: 'fr' },
{ explicit: undefined, ha: 'fr-CA', expected: 'fr' },
{ explicit: undefined, ha: 'fr_BE', expected: 'fr' },
{ explicit: undefined, ha: 'fr-CH', expected: 'fr' },
];
for (const { explicit, ha, expected } of cases) {
assert.equal(resolveLanguageCode(explicit, ha, supported, 'en'), expected);
}
});
test('i18n: langOf wires card config and both HA locale shapes to the registry', () => {
assert.equal(langOf({ locale: { language: 'ru_RU' }, language: 'en' }), 'ru');
assert.equal(langOf({ locale: { language: 'en-GB' }, language: 'ru' }, 'RU'), 'ru');
assert.equal(langOf({ language: 'ru-RU' }, 'unknown'), 'ru');
assert.equal(langOf({ locale: { language: 'de-DE' } }, 'unknown'), 'de');
assert.equal(langOf({ locale: { language: 'fr-CA' } }, 'unknown'), 'fr');
});
test('i18n: editor options follow registry order and preserve unknown raw values', () => {
assert.deepEqual(languageOptions('Auto'), [
{ value: '', label: 'Auto' },
...LANGUAGE_REGISTRY.map(({ code, nativeLabel }) => ({ value: code, label: nativeLabel })),
]);
assert.deepEqual(languageOptions('Auto', 'ru'), languageOptions('Auto'));
assert.deepEqual(languageOptions('Auto', 'de'), languageOptions('Auto'));
assert.deepEqual(
languageOptions('Auto', ' RU ').at(-1),
{ value: ' RU ', label: 'Русский' },
);
});
test('issue 251 unavailable controls toast has exact singular and plural copy', () => {
assert.equal(
en['toast.toggle_target_unavailable'],
'Target “{name}” is unavailable — no action was performed',
);
assert.equal(
ru['toast.toggle_target_unavailable'],
'Цель «{name}» недоступна — действие не выполнено',
);
assert.equal(
en['toast.toggle_targets_unavailable'],
'Targets are unavailable: {names}. No action was performed',
);
assert.equal(
ru['toast.toggle_targets_unavailable'],
'Цели недоступны: {names}. Действие не выполнено',
);
assert.match(cardSource, /toast\.toggle_target_unavailable/);
assert.match(cardSource, /toast\.toggle_targets_unavailable/);
});
test('Optimize distinguishes updated spaces from cleaned coordinate noise', () => {
assert.equal(
en['gs.optimize_changes'],
'Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; merged zero-thickness wall fragments: {s}; independent walls: {i}.',
);
assert.equal(
ru['gs.optimize_changes'],
'Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; объединено отрезков стен нулевой толщины: {s}; независимых: {i}.',
);
assert.equal(en['gs.zero_walls_migrated'], 'Virtual wall spans converted: {n}.');
assert.equal(ru['gs.zero_walls_migrated'], 'Преобразовано виртуальных участков: {n}.');
assert.match(cardSource, /gs\.zero_walls_migrated/);
assert.match(cardSource, /p: String\(r\.coordsCanonicalized\)/);
// #229: the independent-wall counter is reported, not silently accumulated
assert.match(cardSource, /i: String\(r\.partitionsMerged\)/);
assert.match(cardSource, /gs\.optimize_coincident_partitions/);
assert.match(cardSource, /gs\.optimize_openings_rehosted/);
assert.match(cardSource,
/d\.report\.coordsCanonicalized \+ d\.report\.latticeCoordinatesCanonicalized/);
});
test('issue 306 zero-wall failures have dedicated symmetric copy', () => {
const expected = {
'toast.zero_wall_opening_conflict': [
'Remove the opening on this wall segment first.',
'Сначала удалите проём на этом участке стены.',
],
'toast.zero_wall_ambiguous': [
'The wall segment is ambiguous. Simplify or adjust the junction.',
'Не удалось однозначно выбрать участок стены. Уточните геометрию узла.',
],
'toast.zero_wall_migration_blocked': [
'The space was not converted: {reason}. No data was changed.',
'Пространство не преобразовано: {reason}. Данные не изменены.',
],
};
for (const [key, [english, russian]] of Object.entries(expected)) {
assert.equal(en[key], english);
assert.equal(ru[key], russian);
assert.match(cardSource, new RegExp(key.replaceAll('.', '\\.')));
}
});
test('issue 291 Optimize reports lattice cleanup separately in both languages', () => {
assert.equal(
en['gs.optimize_lattice_summary'],
'Noisy coordinate values canonicalized: {n}; maximum movement: {cm} cm.',
);
assert.equal(
ru['gs.optimize_lattice_summary'],
'Канонизировано шумовых значений координат: {n}; максимальный сдвиг: {cm} см.',
);
assert.equal(
en['gs.optimize_lattice_space'],
'{space}: coordinate values canonicalized: {n}; off-grid values left unchanged: {far}.',
);
assert.equal(
ru['gs.optimize_lattice_space'],
'{space}: канонизировано значений координат: {n}; оставлено значений вне сетки: {far}.',
);
assert.match(cardSource, /formatLatticeShiftCm\(r\.latticeMaxShiftCm\)/);
assert.match(cardSource, /r\.latticeSpaces\.map/);
});
test('issue 252 Optimize keeps internal ids out of the main orphan report', () => {
assert.equal(
en['gs.optimize_orphans_removed'],
'Forgotten records removed: {total} — room labels: {rooms}; devices: {devices}; group markers: {groups}. They belonged to spaces deleted earlier.',
);
assert.equal(
ru['gs.optimize_orphans_removed'],
'Убрано забытых записей: {total} — подписи комнат: {rooms}; устройства: {devices}; групповые метки: {groups}. Все они принадлежали пространствам, удалённым ранее.',
);
assert.match(cardSource, /gs\.optimize_orphans_removed/);
assert.match(cardSource, /<details class="optimize-details">/);
assert.doesNotMatch(cardSource, /this\._t\('gs\.optimize_reference_warning'/);
for (const key of [
'gs.optimize_orphans_removed', 'gs.optimize_live_positions',
'gs.optimize_unverified', 'gs.optimize_vacuum_warning',
]) {
assert.doesNotMatch(en[key], /\b(?:layout|owner|nested mapping|space id|marker id)\b/i);
assert.doesNotMatch(ru[key], /\b(?:layout|owner|id|вложенн)/i);
}
});
test('i18n: every literal help call has body and full aria keys in every language', () => {
const allCalls = helpSource.match(/this\._help\(/g) || [];
const helpKeys = [...helpSource.matchAll(/this\._help\('([^']+\.help)'\)/g)].map((match) => match[1]);
assert.equal(helpKeys.length, allCalls.length, 'every _help call must use one string literal ending in .help');
assert.ok(helpKeys.length > 0, 'the help affordance pilot disappeared');
for (const key of helpKeys) {
for (const { code } of LANGUAGE_REGISTRY) {
const dictionary = dictionaries.get(code);
assert.ok(dictionary[key]?.trim(), `${code}:${key} is missing or empty`);
assert.ok(dictionary[`${key}.aria`]?.trim(), `${code}:${key}.aria is missing or empty`);
}
}
});
test('issue 86 Party 1 has the exact help inventory and canonical RU/EN copy', () => {
const copy = {
'space.cell_cm.help': [
'Links the grid to real dimensions: lengths, areas, wall thickness, opening sizes and glow radius depend on it. Changing it after drawing changes the calculated dimensions of the whole space without moving its points on the plan.',
'Связывает сетку с реальными размерами: от значения зависят длины и площади, толщина стен, размеры проёмов и радиус свечения. Изменение после разметки меняет расчётные размеры всего пространства, но не двигает его точки на плане.',
],
'gs.glow_radius.help': [
'Sets the default glow radius in metres or feet; a device-specific radius overrides it.',
'Задаёт общий радиус светового пятна в метрах или футах; персональный радиус устройства заменяет это значение.',
],
'space.fill_mode.help': [
'“Custom colour” is styling, while “Light”, “Temperature” and “LQI” use current Home Assistant data; Glow is enabled separately.',
'«Свой цвет» задаёт оформление, а «Свет», «Температура» и «LQI» используют текущие данные Home Assistant; Glow включается отдельно.',
],
'marker.light_role.help': [
'“Auto” uses the bound device\'s resolved role, “Always” forces its own source and “Never” excludes it; linked lights above remain independent.',
'«Авто» использует фактическую роль привязанного устройства, «Всегда» принудительно создаёт собственный источник, а «Никогда» исключает его; связанные лампы выше остаются независимыми.',
],
'marker.controls.help': [
'With the “Toggle state” action, the listed sources toggle together with this marker. Their glow stays at their own markers, and the link alone does not turn the controlling marker into a light source.',
'При действии «Переключить состояние» перечисленные источники переключаются вместе с этим маркером. Их световые пятна остаются у собственных маркеров, а эта связь сама по себе не делает управляющий маркер источником света.',
],
'gs.north.help': [
'The angle is measured clockwise from the plan\'s upward vertical; north is required for window rays, not for the “Follows the Sun” background.',
'Угол отсчитывается по часовой стрелке от верхней вертикали плана; север нужен для оконных лучей, а не для фона «Следует за Солнцем».',
],
'space.north.help': [
'Overrides general north for this space; the angle is measured clockwise from the plan\'s upward vertical and is used by window rays.',
'Переопределяет общий север для этого пространства; угол отсчитывается по часовой стрелке от верхней вертикали плана и используется оконными лучами.',
],
'gs.bg_mode.help': [
'“Follows the Sun” uses sun.sun, falling back to the browser\'s local clock; Static always shows the selected colour.',
'«Следует за Солнцем» использует sun.sun, а при недоступности — локальные часы браузера; статичный режим всегда показывает выбранный цвет.',
],
'space.bg_mode.help': [
'Inherit the general background or override this space with a static colour or “Follows the Sun”.',
'Выберите наследование общего фона либо переопределите это пространство статичным цветом или режимом «Следует за Солнцем».',
],
'space.zero_wall_style.help': [
'Dashed zero-thickness walls pass Glow and sun rays, while solid ones block them as zero-area barriers. The choice affects every such wall in the space even when its line is hidden in View.',
'Пунктирные стены нулевой толщины пропускают Glow и солнечные лучи, сплошные блокируют их как барьер нулевой площади. Выбор меняет все такие стены пространства, даже когда их линии скрыты в просмотре.',
],
'device_inbox.show_hidden.help': [
'Temporarily shows hidden and disabled markers as editor-only ghosts on the plan. Saved device visibility does not change.',
'Временно показывает на плане скрытые и деактивированные маркеры служебными призраками только в редакторе устройств. Сохранённая видимость устройств не меняется.',
],
};
const calls = [...helpSource.matchAll(/this\._help\('([^']+\.help)'\)/g)]
.map((match) => match[1]);
for (const [key, [english, russian]] of Object.entries(copy)) {
assert.ok(calls.includes(key), `${key} has no help trigger`);
assert.equal(en[key], english, `canonical English copy changed for ${key}`);
assert.equal(ru[key], russian, `canonical Russian copy changed for ${key}`);
}
assert.equal(Object.keys(copy).length, 11);
});
test('issue 86 removes explanatory hints replaced by contextual help', () => {
for (const key of [
'marker.controls_hint', 'gs.bg_daynight_hint', 'gs.north_hint', 'space.zero_wall_help',
]) {
for (const { code } of LANGUAGE_REGISTRY) {
assert.equal(dictionaries.get(code)[key], undefined, `${code}:${key} should be removed`);
}
assert.ok(!helpSource.includes(`'${key}'`), `${key} is still rendered`);
}
});
test('light pilot has no duplicate legacy hint copy', () => {
for (const key of [
'marker.light_role_tip', 'marker.glow_color_tip',
'marker.glow_brightness_hint', 'marker.glow_radius_hint',
]) {
assert.equal(en[key], undefined, `en:${key} should be removed`);
assert.equal(ru[key], undefined, `ru:${key} should be removed`);
assert.ok(!cardSource.includes(`'${key}'`), `${key} is still rendered`);
}
});
test('help hosts do not duplicate their explanation in a title attribute', () => {
const calls = [...helpSource.matchAll(/this\._help\('([^']+\.help)'\)/g)];
assert.ok(calls.length > 0, 'the help affordance pilot disappeared');
for (const call of calls) {
const prefix = helpSource.slice(Math.max(0, call.index - 700), call.index);
// #594: у подсказки, переданной в карточку набора (`help:`), хозяин не на
// месте вызова, а один на всех — в form-kit.ts. Такие вызовы проверяются
// ниже одним утверждением: хозяина нельзя испортить по одному месту, его
// можно испортить только целиком.
if (/\bhelp:\s*$/.test(prefix)) continue;
const hosts = [...prefix.matchAll(/<(legend|label|div)\b([^>]*)>/g)];
const host = hosts.at(-1);
assert.ok(host, `no host element found for ${call[1]}`);
assert.doesNotMatch(host[2], /\btitle\s*=/i, `${call[1]} duplicates help in title=`);
}
assert.ok(!cardSource.includes('markerlighttip'), 'legacy help copy is still rendered');
});
test('#594 the shared form kit hosts its help without a duplicating title', () => {
const kit = readFileSync(new URL('../src/editors/form-kit.ts', import.meta.url), 'utf8');
const head = /<div class="hpf-head"([^>]*)>/.exec(kit);
assert.ok(head, 'the form kit no longer renders a heading host for help');
assert.doesNotMatch(head[1], /\btitle\s*=/i, 'the kit heading duplicates help in title=');
assert.match(kit, /\$\{help \?\? nothing\}/, 'the kit heading no longer renders the help slot');
});
test('vac toasts never mention the removed point calibration (HP-1540-06)', () => {
for (const { code: lang } of LANGUAGE_REGISTRY) {
const d = dictionaries.get(lang);
for (const k of ['vac.autocal_no_rooms', 'vac.autocal_no_match', 'vac.residual_message']) {
assert.ok(!/point|точк/i.test(d[k]), `${lang}:${k} still points at point calibration`);
}
}
});
test('i18n: German catalog keeps the product glossary and has no translation sentinels', () => {
assert.equal(de['btn.save'], 'Speichern');
assert.equal(de['btn.cancel'], 'Abbrechen');
assert.equal(de['btn.delete'], 'Löschen');
assert.equal(de['space.header'], 'Bereich');
assert.equal(de['physical.partition_properties'], 'Eigenschaften der Trennwand');
assert.equal(de['opening.passage'], 'Offener Durchgang');
for (const [key, value] of Object.entries(de)) {
assert.doesNotMatch(value, /ZXQPH|QXZ|⟦HP/u, `de:${key} contains a translator sentinel`);
assert.doesNotMatch(value, /[А-Яа-яЁё]/u, `de:${key} contains Cyrillic text`);
}
});
test('i18n: German values equal to English are explicitly reviewed', () => {
const allowed = new Set([
'color_picker.confirm',
'editor.palette',
'confirm.delete_partition_openings_item',
'marker.name_ph',
'marker.name_label', // #600: «Name» — одно слово в обоих языках
'marker.pulse_a11y_alarm',
'marker.state_a11y_alarm',
'marker.link_label',
'rules.icon_ph',
'decor.ellipse',
'decor.text',
'decor.text_label',
'gs.unit_m',
'gs.unit_ft',
'wallthick.unit_cm',
'wallthick.unit_in',
'marker.value_badge_position',
'marker.value_badge_attr_current_position',
'gs.north_letter',
'gs.optimize_live_names',
'gs.optimize_details',
'furn.symbol',
// #598: 'Plan' и 'Details' — одни и те же слова в EN и DE
'gs.card_plan',
'marker.card_details',
// #593: 'Computer' одинаков в EN и DE — это немецкое слово, не пропуск перевода
'furn.cat_computer',
'furn.sym_computer',
'furn.cat_sofa',
'furn.cat_bidet',
'vac.diag_platform',
'vac.diag_status',
'vac.diag_position',
'version_mismatch.backend',
'radar.source_optional',
]);
const equalKeys = Object.keys(en).filter((key) => en[key] === de[key]);
assert.deepEqual(new Set(equalKeys), allowed);
});
test('i18n: French catalog keeps the product glossary and has no translation sentinels (#371)', () => {
assert.equal(fr['btn.save'], 'Enregistrer');
assert.equal(fr['btn.cancel'], 'Annuler');
assert.equal(fr['btn.delete'], 'Supprimer');
assert.equal(fr['space.header'], 'Espace');
assert.equal(fr['opening.passage'], 'Passage ouvert');
for (const [key, value] of Object.entries(fr)) {
assert.doesNotMatch(value, /ZXQPH|QXZ|⟦HP/u, `fr:${key} contains a translator sentinel`);
assert.doesNotMatch(value, /[А-Яа-яЁё]/u, `fr:${key} contains Cyrillic text`);
}
});
test('i18n: French values equal to English are explicitly reviewed (#371)', () => {
// Every entry here is a legitimate French/English homograph or a unit,
// template or brand token — reviewed with the contributed dictionary.
const allowed = new Set([
'color_picker.confirm',
'color_picker.saturation',
'confirm.delete_partition_openings_item',
// #598: 'Plan' — одно и то же слово в EN, DE и FR
'gs.card_plan',
// #593: 'Cactus' пишется одинаково по-английски и по-французски
'furn.sym_cactus',
'decor.rect',
'decor.image',
'err.code',
'furn.cat_bidet',
'gs.about_version',
'gs.north_letter',
'gs.unit_m',
'marker.desc_label',
'marker.preview.multiple_sources',
'marker.value_badge_attr_current_position',
'marker.value_badge_attr_volume_level',
'marker.value_badge_position',
'opening.type_label',
'rules.icon_ph',
'run.script',
'space.plan_alt',
'vac.cap_position',
'vac.diag_position',
'vac.diag_source',
'vac.documentation',
'wallthick.unit_cm',
'radar.heading',
'radar.radians',
]);
const equalKeys = Object.keys(en).filter((key) => en[key] === fr[key]);
assert.deepEqual(new Set(equalKeys), allowed);
});