Files
houseplan-card/test/styles-split.test.mjs
T
Codex 0b782ee714 fix: host-gated groups belong to their owner surface; split mixed media wrappers (#266)
CI-шард смока поймал сдвиг каскада, невидимый golden-набору:
smoke_device_icon_design — alert-shell стал серым, dark-unavailable core
светлым. Причина: классификатор считал ведущий :host(...) владельцем
селектора и уносил гейтнутые группы устройств в base — ВПЕРЁД их поверхности,
меняя победителя при равной специфичности. Теперь :host-префикс — гейт, а не
владелец: владелец — первый значимый токен после него; смешанные @media
режутся на последовательные по-зонные копии обёртки (reduced-motion обёрток
стало 12 поверх тех же правил 10 исходных — юнит заякорен на факт, сверка
scope-ключом доказывает, что ни одно правило обёртку не потеряло). Два
мутантных якоря вернулись в devices.styles.ts; исключение юнита
непересечения опустело.

Гейты: refactor-proof diff пуст · golden 129/129 без переприёмки · смоки
device_icon_design, plan_snap_overlay, preloader OK · npm test 1318/0.

Issue: #266
User-Visible: no
2026-08-26 01:55:00 +03:00

76 lines
3.3 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';
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', () => {
assert.deepEqual(cardStyles, [baseStyles, planStyles, devicesStyles, chromeStyles, dialogsStyles],
'the cascade order is a contract — the golden set was accepted against it');
});
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 source wrappers; two of them were MIXED-zone and are split into
// per-zone copies by the generator (cascade fix), hence 12 wrappers over
// the same 10 wrappers' worth of rules — the scope-keyed refactor diff
// proves no rule lost its wrapper.
assert.equal((all.match(/@media \(prefers-reduced-motion: reduce\)/g) || []).length, 12,
'reduced-motion wrappers must survive — golden always shoots reduced');
});