Files
houseplan-card/test/styles-split.test.mjs
Claude e5ab4659f6 feat: 2.5D stage 6 — public setting, raised tiles, soft sun, theme-free walls (#649)
- settings.volumetric_view (General settings › Display, third switch) replaces
  the alpha entry, the header projection toggle and the phone-menu item; one
  rule for card, sidebar page and kiosk; editors stay Flat; backend accepts
  only a boolean, support package copies it.
- Raised tiles in 2.5D View: no ring, rounded body min(0.275 D, 0.3 h), edge
  0.1 D with the lab filters, ×1.12 size also in layout/collision, lift
  0.075 D, one floor-shadow layer under all markers (theme × floor table),
  frames hugging tile and edge (Alert > Focus > Selected > Hover), forced
  colours without edge and shadow (src/iso-tiles.ts, styles/iso-tiles).
- Soft sun wash instead of projected Flat wedges (src/iso-sun.ts): same gates
  and windows as sun_rays, length from elevation, parallelogram along the sun,
  tone and streaks by floor lightness, sill line.
- Walls take the user's wall colour (top opaque, sides × .77/.68/.60); theme
  rules for walls/openings/labels removed; furniture uses the Flat stroke rule.
- Smokes smoke_iso_tiles/iso_sun/iso_theme_walls/volumetric_setting, 16 new
  mutants, acceptance scenes STAGE6_ACCEPTANCE_SCENARIOS (golden with their
  Linux CI baselines), ACCEPTANCE.md side by side.

Issue: #649
User-Visible: yes
2026-09-25 18:55:14 +03:00

128 lines
6.2 KiB
JavaScript

/** #266: invariants of the styles split. */
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { cardStyles, baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles }
from '../test-build/styles.js';
import { CARD_DIALOG_FORM_KIT, formKitCss } from '../test-build/styles/form-kit.styles.js';
import { isoTilesStyles } from '../test-build/styles/iso-tiles.styles.js';
const FILES = ['base', 'plan', 'devices', 'chrome', 'dialogs'];
const sourceOf = (name) =>
readFileSync(new URL(`../src/styles/${name}.styles.ts`, import.meta.url), 'utf8');
const selectorsOf = (tsSource) => {
// Only the css`` payload: the TS import/export scaffolding is not CSS.
const css = [...tsSource.matchAll(/css`([\s\S]*?)`/g)].map((m) => m[1]).join('\n');
const text = css.replace(/\/\*[\s\S]*?\*\//g, '');
const out = new Set();
const walk = (chunk, scope) => {
let i = 0;
while (i < chunk.length) {
const open = chunk.indexOf('{', i);
if (open === -1) break;
const header = chunk.slice(i, open).replace(/\s+/g, ' ').trim();
let depth = 1, j = open + 1;
while (j < chunk.length && depth > 0) {
if (chunk[j] === '{') depth++;
else if (chunk[j] === '}') depth--;
j++;
}
if (header.startsWith('@media') || header.startsWith('@supports')) {
walk(chunk.slice(open + 1, j - 1), `${scope}${header} :: `);
} else if (!header.startsWith('@')) {
// The key includes the media scope: the SAME selector inside and
// outside a wrapper are different cascade entries by construction
// (a multi-zone @media block lands in base as a whole).
for (const sel of header.split(',')) out.add(`${scope}${sel.trim()}`);
}
i = j;
}
};
walk(text, '');
return out;
};
test('issue 266 the aggregator is exactly the five surface files in the cascade order', () => {
// 2026-09-18, #594: набор контролов формы в этот массив НЕ входит — он живёт
// в ленивом редакторском графе и вносится в теневой корень при открытии
// диалога. Пятёрка и её порядок неприкосновенны.
// #649: the 2.5D tile sheet follows the five and is scoped to the 2.5D View
// only (next test), so no accepted Flat frame can change through it.
assert.deepEqual(cardStyles, [baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles, isoTilesStyles],
'the cascade order is a contract — the golden set was accepted against it');
});
test('#649 every 2.5D tile rule is scoped to the 2.5D View stage', () => {
const text = isoTilesStyles.map((sheet) => sheet.cssText).join('\n').replace(/\/\*[\s\S]*?\*\//g, '');
const headers = [];
const walk = (chunk) => {
let i = 0;
while (i < chunk.length) {
const open = chunk.indexOf('{', i);
if (open === -1) break;
const header = chunk.slice(i, open).replace(/\s+/g, ' ').trim();
let depth = 1, j = open + 1;
while (j < chunk.length && depth > 0) { if (chunk[j] === '{') depth++; else if (chunk[j] === '}') depth--; j++; }
if (header.startsWith('@')) walk(chunk.slice(open + 1, j - 1)); else headers.push(header);
i = j;
}
};
walk(text);
assert.ok(headers.length > 40, `expected the generated tables, got ${headers.length} rules`);
for (const header of headers) {
const parts = [];
let depth = 0, from = 0;
for (let k = 0; k < header.length; k++) {
if (header[k] === '(') depth++; else if (header[k] === ')') depth--;
else if (header[k] === ',' && depth === 0) { parts.push(header.slice(from, k)); from = k + 1; }
}
parts.push(header.slice(from));
for (const selector of parts) {
assert.match(selector, /\.stage\.projection-iso\.mode-view /, `unscoped 2.5D rule: ${selector}`);
}
}
});
test('#594 the form kit adds selectors instead of overriding existing ones', () => {
// Набор вносится последним листом в тот же теневой корень, поэтому его правила
// обязаны только добавлять: совпавший селектор молча переопределил бы принятый
// кадр и не покраснел бы нигде, кроме глаз.
const existing = new Set();
for (const name of FILES) for (const sel of selectorsOf(sourceOf(name))) existing.add(sel);
const kit = selectorsOf(`css\`${formKitCss(CARD_DIALOG_FORM_KIT)}\``);
assert.ok(kit.size > 0, 'the form kit emits no selectors at all');
const clashes = [...kit].filter((sel) => existing.has(sel));
assert.deepEqual(clashes, [],
'a kit rule lands on a selector that already exists: the last position in the cascade '
+ 'would then silently change accepted frames');
});
test('issue 266 surface files do not share a single selector', () => {
const sets = FILES.map((name) => ({ name, set: selectorsOf(sourceOf(name)) }));
const clashes = [];
for (let i = 0; i < sets.length; i++) {
for (let j = i + 1; j < sets.length; j++) {
for (const sel of sets[i].set) {
if (sets[j].set.has(sel)) clashes.push(`${sel} (${sets[i].name} ∩ ${sets[j].name})`);
}
}
}
// Spec §1.3.2: the exception list is empty — a leading :host(...) gate is
// not ownership, so gated device groups live with their surface and no
// selector is shared between files.
assert.deepEqual(clashes, []);
});
test('issue 266 the media wrappers survived the move', () => {
const all = FILES.map((name) => sourceOf(name)).join('\n');
assert.equal((all.match(/@media \(forced-colors: active\)/g) || []).length, 2,
'both forced-colors blocks must survive — golden never emulates them');
// 10 historical source wrappers; two were MIXED-zone and split into
// per-zone copies by the generator. #437's overlay stylesheet is loaded
// with its lazy runtime and has its own source contract, so the five eager
// surface owners remain at the historical 12 wrappers.
assert.equal((all.match(/@media \(prefers-reduced-motion: reduce\)/g) || []).length, 12,
'reduced-motion wrappers must survive — golden always shoots reduced');
});