Files
houseplan-card/test/i18n-lazy-namespaces.test.mjs
T
claude[bot]andCodex 3a1cd7b1ad feat(card): LED strips in View — lazy chunk, stripe, linear light (#780)
Stage 3a of #780.

- src/led-strip-gate.ts is the only initial-graph foothold (ТЗ §13.1): which
  markers a space shows as a strip (active and bound), the half-length
  anchor that replaces their icon position, and one page-wide load of the
  lazy led-strip-runtime chunk (fingerprint handshake, a hashed-URL retry on
  the next explicit entry, never in a loop).
- The card: a represented marker takes no auto slot, draws no icon, casts no
  round pool and is placed at the anchor; two call sites render the stripe
  layer and the linear field from the chunk. The space model carries the
  stored strips untouched; scaling, validation and geometry are in the chunk.
- src/led-strip-runtime.ts: the stripe is two strokes of one derived path
  (outline #383838 t, core t/2, round joins and caps, t = 0.08/0.12 D),
  white off, white core + field on with Glow, source-colour core without
  Glow, grey dashed unavailable without a field. The hit path takes the
  card's own device handlers (one action path) with radius max(22 px, t/2)
  and the nearest stripe as owner. The linear field is the exact distance
  field with the shared falloff: opaque grey bands of a luminance mask per
  piece, pieces joined by lighten (the maximum), each piece clipped to what
  its own emitters see, so a hidden part never lights through another part's
  visibility; buried emitters emit nothing; a failed clip is dark. A bounded
  per-space cache (50) counts geometry rebuilds.
- The initial View graph had 501 B of headroom. The furniture library copy
  (furn.*, 104 keys × 4 languages, editor-only) moves into a new lazy `tools`
  namespace (#627 mechanism) that the editor runtime awaits; the initial
  View graph is 298 686 B gzip with the LED gate in it (−1 879 B vs the base).

Tests: test/led-strip-runtime.test.mjs (6: representation, gate anchor =
geometry anchor, falloff, states and radius, per-piece clipping and buried
strips, bounded cache), bundle and i18n fixtures for the LED chunk and the
fourth namespace.

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

111 lines
6.7 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'];
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'),
};
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,
};
const translate = {
settings: modules.settings.settingsT,
support: modules.support.supportT,
topology: modules.topology.topologyT,
tools: modules.tools.toolsT,
};
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, а не двойник`);
}
});