Files
houseplan-card/test/i18n-lazy-namespaces.test.mjs
T
Codex d21fba11d6 feat(card): the LED strip tool in the Devices editor (#780)
Stage 4 of #780 (ТЗ §4–§5). «LED strip» next to «Add» draws a chain with
clean clicks only (pan, pinch, a second finger, cancel and the synthetic
click after navigation add nothing), snaps to the grid and to physical wall
faces / zero-wall axes, stops at the first face of masonry, partitions,
columns and windows (doors, gates and passages are cut by geometry), Shift
gives 45°. Ctrl+Z removes the chain's own point first, Esc finishes, a
double click or a click on the first point (≥3 vertices) closes; fewer than
two distinct points write nothing. A finished strip opens the device picker
(lights first, taken markers explained, «New device…» binds in that
dialog's own write, «Later» keeps unbound geometry).

A selected strip shows its vertex handles (a drag re-checks both neighbours
and the whole path) and a new Devices tray branch: device settings,
bind/change, unbind, show as icon, delete. The device dialog gets the
representation section: show as strip (restores a hidden shape at once or
draws one for this marker, behind the dialog's own save/discard guard),
show as icon, unbind/delete for a hidden shape. Every geometry or
representation change is one optimistic write and one LED command of the
device history; Undo/Redo restores only its own strip record and refuses
when a newer change sits on it. A rebinding renames the link in the marker
save, a deleted marker leaves an unbound strip, a bound marker may not move
to another space. Plan/Background show strips as a passive translucent mark.

The tool, its `led` dictionary (en static, ru/de/fr lazy) and placement
geometry are a new lazy `led-strip-editor` chunk (9.5 KB gzip), loaded only
on the tool, an editable strip in the shown space or a strip device's
dialog. The initial graph gets the loader and delegation only
(src/led-strip-card.ts); the lazy editor graph +122 B, inside its ceiling.
Mutant anchors follow the moved code (marker dialog guard, static LED layer).

Issue: #780
User-Visible: no
2026-10-02 20:54:50 +03:00

114 lines
6.9 KiB
JavaScript
Raw 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.
// #627 AC3: словари пространств `settings`, `support`, `topology` — английский
// статически (синхронный слой отката), ru/de/fr — отдельные ленивые чанки, по
// одному на пару «пространство × язык», у каждого загрузчик с двумя попытками.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { NAMESPACE_LOCALE_CHUNKS } from '../scripts/bundle-manifest.mjs';
const NAMESPACES = ['settings', 'support', 'topology', 'tools', 'led'];
const LAZY_LANGUAGES = ['ru', 'de', 'fr'];
const source = (path) => readFileSync(new URL(`../src/i18n/${path}`, import.meta.url), 'utf8');
const json = (path) => JSON.parse(source(path));
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
test('#627 AC3 ни один ru/de/fr словарь пространства не импортируется статически', () => {
for (const namespace of NAMESPACES) {
const text = source(`${namespace}.ts`);
const staticImports = [...text.matchAll(/^import\s[^;]*?from\s+['"]([^'"]+)['"]/gm)]
.map((match) => match[1]);
assert.ok(staticImports.includes(`./${namespace}/en.json`),
`${namespace}: английский слой отката обязан быть статическим`);
for (const language of LAZY_LANGUAGES) {
const lazy = new RegExp(`^\\./${namespace}/(?:${language}\\.json|${namespace}-${language})$`);
assert.deepEqual(staticImports.filter((path) => lazy.test(path)), [],
`${namespace}/${language}: статический импорт вернул бы словарь в граф редактора ×4`);
}
// Ни одного JSON, кроме английского, в модуле пространства вообще.
assert.deepEqual(staticImports.filter((path) => path.endsWith('.json') && path !== `./${namespace}/en.json`), []);
}
});
test('#627 AC3 у каждого из девяти чанков — загрузчик с двумя попытками и свой retry-токен', () => {
assert.equal(NAMESPACE_LOCALE_CHUNKS.length, NAMESPACES.length * LAZY_LANGUAGES.length);
for (const namespace of NAMESPACES) {
const text = source(`${namespace}.ts`);
for (const language of LAZY_LANGUAGES) {
const entry = NAMESPACE_LOCALE_CHUNKS.find((candidate) => candidate.namespace === namespace
&& candidate.language === language);
assert.ok(entry, `${namespace}-${language}: запись манифеста`);
assert.equal(entry.module, `/src/i18n/${namespace}/${namespace}-${language}.ts`);
assert.match(text, new RegExp(
`${language}: \\(attempt\\) => \\(attempt === 0 \\? import\\('${escape(`./${namespace}/${namespace}-${language}`)}'\\)\\n`
+ `\\s+: import\\(/\\* @vite-ignore \\*/ retryUrl\\('${entry.token}'\\)\\)\\)`,
), `${namespace}-${language}: первая попытка — ребро rollup, вторая — точный hashed-адрес`);
assert.equal(text.split(entry.token).length - 1, 1, `${entry.token}: ровно одно вхождение`);
// Чанк несёт свой язык и отпечаток сборки.
const loader = new URL(`../src/i18n/${namespace}/${namespace}-${language}.ts`, import.meta.url);
assert.ok(existsSync(loader), `${namespace}-${language}.ts существует`);
const loaderText = readFileSync(loader, 'utf8');
assert.match(loaderText, new RegExp(`^import dictionary from '\\./${language}\\.json' with \\{ type: 'json' \\};`, 'm'));
assert.match(loaderText, /export const fingerprint = '__HOUSEPLAN_SOURCE_FINGERPRINT__';/);
}
}
});
test('#627 рантайм пространства: en готов сразу, ru/de/fr — после ensure и ровно своим словарём', async () => {
const modules = {
settings: await import('../test-build/i18n/settings.js'),
support: await import('../test-build/i18n/support.js'),
topology: await import('../test-build/i18n/topology.js'),
tools: await import('../test-build/i18n/tools.js'),
led: await import('../test-build/i18n/led.js'),
};
const runtimes = {
settings: modules.settings.SETTINGS_LANGUAGE_RUNTIME,
support: modules.support.SUPPORT_LANGUAGE_RUNTIME,
topology: modules.topology.TOPOLOGY_LANGUAGE_RUNTIME,
tools: modules.tools.TOOLS_LANGUAGE_RUNTIME,
led: modules.led.LED_LANGUAGE_RUNTIME,
};
const translate = {
settings: modules.settings.settingsT,
support: modules.support.supportT,
topology: modules.topology.topologyT,
tools: modules.tools.toolsT,
led: modules.led.ledT,
};
for (const namespace of NAMESPACES) {
const runtime = runtimes[namespace];
const english = json(`${namespace}/en.json`);
assert.equal(runtime.state('en'), 'ready', `${namespace}: английский статический`);
for (const language of LAZY_LANGUAGES) {
const expected = json(`${namespace}/${language}.json`);
const key = Object.keys(expected).find((candidate) => expected[candidate] !== english[candidate]
&& !/\{/.test(expected[candidate]));
assert.ok(key, `${namespace}/${language}: есть переведённый ключ без плейсхолдеров`);
assert.equal(runtime.state(language), 'pending', `${namespace}/${language}: не загружен до ensure`);
assert.equal(translate[namespace](language, key), english[key],
`${namespace}/${language}: до загрузки — синхронный английский откат, не сырой ключ`);
await runtime.ensure(language);
assert.equal(runtime.state(language), 'ready');
assert.deepEqual(runtime.dictionary(language), expected,
`${namespace}/${language}: загружен словарь своего языка`);
assert.equal(translate[namespace](language, key), expected[key]);
}
}
});
test('#627 рантайм пространства — тот же проверенный класс и тот же слушатель отказа', async () => {
const { LanguageRuntime } = await import('../test-build/i18n/language-runtime.js');
const namespaceSource = source('namespace-language.ts');
assert.match(namespaceSource,
/\], BUILD_FINGERPRINT, console\.warn, notifyLanguageLoadFailures\);/,
'отказ словаря пространства идёт тем же тостом, что и основного каталога');
for (const [path, name] of [
['settings.js', 'SETTINGS_LANGUAGE_RUNTIME'], ['support.js', 'SUPPORT_LANGUAGE_RUNTIME'],
['topology.js', 'TOPOLOGY_LANGUAGE_RUNTIME'],
]) {
const module = await import(`../test-build/i18n/${path}`);
assert.ok(module[name] instanceof LanguageRuntime, `${name}: LanguageRuntime, а не двойник`);
}
});