mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 06:38:57 +00:00
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
114 lines
6.9 KiB
JavaScript
114 lines
6.9 KiB
JavaScript
// #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, а не двойник`);
|
||
}
|
||
});
|