mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
- scripts/no-new-private-writes.mjs: смоки и demo/helpers/** не добавляют записей в приватное состояние карточки (присваивание, ++/--, delete по цепочке с сегментом _x; от this — нет) и вызовов _setMode/_openRoomEdit/ _openMarkerDialog/_openSpaceDialog. Зачёт правки по полю, перенос блока — movedLinesByFile из no-new-any; исключение // private-ok: <причина>. --count — остаток на HEAD. Подключён в gate:small и в шаг frontend рядом с no-new-any, с той же базой. - demo/helpers/hp-test.mjs: 10 операций через контрактные хуки и события фикстуры (setMode, setTool, switchSpace, openRoomEdit, openMarkerDialog, openSpaceDialog, setServerConfig, setLayout, input, close); ставится launch*() из demo/serve.mjs. В бандле фасада нет. - demo/srv/demo.html: доставка houseplan_config_updated/_layout_updated, __pushServerConfig/__pushServerLayout; после доставки запись со старым expected_rev — conflict, как у настоящего сервера. - HP_SMOKE_CHECKS=1 печатает имена проверок в finish(). - smoke_area_relocation, smoke_glow, smoke_grid_snap переведены на фасад без потери утверждений; новый smoke_test_facade доказывает каждую операцию. - 7 мутантов, docs/TESTING.md (раздел + правило №6), PROCESS.md §2.7, AGENTS.md. Issue: #629 User-Visible: no
79 lines
5.1 KiB
JavaScript
79 lines
5.1 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
|
||
import { READS, SELECTORS } from '../demo/helpers/hp-test.mjs';
|
||
import { privateWriteSites } from '../scripts/no-new-private-writes.mjs';
|
||
|
||
// #629 AC7. Фасад харнесса входит в карточку только через публичный контракт
|
||
// #489: каждый его селектор объявлен в docs/data-hp-contract.json с аудиторией
|
||
// `test`, а из приватных членов карточки он читает ровно те, что читает и сам
|
||
// продукт. Иначе фасад стал бы тем самым приватным входом, от которого уводит.
|
||
|
||
const root = new URL('../', import.meta.url);
|
||
const contract = JSON.parse(readFileSync(new URL('docs/data-hp-contract.json', root), 'utf8'));
|
||
const facadeText = readFileSync(new URL('demo/helpers/hp-test.mjs', root), 'utf8');
|
||
|
||
/** Нарушения контракта в таблице селекторов; пусто — таблица чиста. */
|
||
function contractViolations(selectors) {
|
||
const problems = [];
|
||
for (const [name, selector] of Object.entries(selectors)) {
|
||
let hooks = 0;
|
||
for (const match of selector.matchAll(/\[data-hp="([^"]+)"\]((?:\[[a-z-]+="[^"]*"\])*)/g)) {
|
||
hooks += 1;
|
||
const entry = contract.hooks[match[1]];
|
||
if (!entry) { problems.push(`${name}: data-hp="${match[1]}" не объявлен`); continue; }
|
||
if (!entry.audience.includes('test')) problems.push(`${name}: data-hp="${match[1]}" без аудитории test`);
|
||
for (const attr of match[2].matchAll(/\[([a-z-]+)="([^"]*)"\]/g)) {
|
||
const [, key, value] = attr;
|
||
if (key === 'data-id' || key === 'data-room') continue; // идентификаторы плана, не словарь
|
||
const allowed = entry.attributes?.[key];
|
||
if (!allowed) { problems.push(`${name}: ${key} не объявлен у ${match[1]}`); continue; }
|
||
if (!/^\{\w+\}$/.test(value) && !allowed.includes(value)) {
|
||
problems.push(`${name}: ${key}="${value}" вне словаря ${match[1]}`);
|
||
}
|
||
}
|
||
}
|
||
for (const match of selector.matchAll(/(?:^|[\s,])(ha-card)\[(data-hp-[a-z-]+)[=\]]/g)) {
|
||
hooks += 1;
|
||
if (contract.rootAttributes[match[2]]?.element !== match[1]) problems.push(`${name}: ${match[2]} не объявлен на ha-card`);
|
||
}
|
||
// Селектор без контрактного хука — это привязка к вёрстке (классу, тегу).
|
||
if (!hooks) problems.push(`${name}: нет ни одного контрактного хука`);
|
||
const stripped = selector
|
||
.replace(/\[data-hp(?:-[a-z-]+)?(?:="[^"]*")?\]/g, '')
|
||
.replace(/\[data-(?:id|room|mode|tool|kind)="[^"]*"\]/g, '')
|
||
.replace(/\bha-card\b/g, '');
|
||
if (/[.#a-z]/i.test(stripped)) problems.push(`${name}: лишнее в селекторе — «${stripped.trim()}»`);
|
||
}
|
||
return problems;
|
||
}
|
||
|
||
test('#629 AC7: каждый селектор фасада — контрактный хук с аудиторией test', () => {
|
||
assert.ok(Object.keys(SELECTORS).length >= 10, 'таблица фасада должна покрывать все операции');
|
||
assert.deepEqual(contractViolations(SELECTORS), []);
|
||
});
|
||
|
||
test('#629 AC7: чужой селектор в таблице фасада отвергается тем же валидатором', () => {
|
||
assert.match(contractViolations({ ...SELECTORS, gear: '.rlgearbtn' }).join('\n'), /gear: нет ни одного контрактного хука/);
|
||
assert.match(contractViolations({ ...SELECTORS, x: '[data-hp="not-a-hook"]' }).join('\n'), /не объявлен/);
|
||
assert.match(contractViolations({ ...SELECTORS, y: '[data-hp="tool"][data-tool="lasso"]' }).join('\n'), /вне словаря/);
|
||
assert.match(contractViolations({ ...SELECTORS, z: '[data-hp="dialog"] .body' }).join('\n'), /лишнее в селекторе/);
|
||
});
|
||
|
||
test('#629 AC7, F3: фасад читает только разрешённые члены карточки и ничего не пишет', () => {
|
||
const members = new Set([...facadeText.matchAll(/\.(_[A-Za-z$][\w$]*)/g)].map((m) => m[1]));
|
||
assert.deepEqual([...members].filter((name) => !READS.includes(name)), [],
|
||
'новый приватный член в фасаде — это новый приватный вход; добавьте DOM-хук вместо него');
|
||
assert.deepEqual(privateWriteSites('demo/helpers/hp-test.mjs', facadeText), []);
|
||
// отрицательный случай — тем же разбором
|
||
const mutant = `${facadeText}\nwindow.__card._tool = 'draw';`;
|
||
assert.equal(privateWriteSites('demo/helpers/hp-test.mjs', mutant).length, 1);
|
||
});
|
||
|
||
test('#629 P1: хук вкладки режима объявлен со словарём режимов редакторов', () => {
|
||
assert.deepEqual(contract.hooks['mode-tab'].attributes['data-mode'], ['plan', 'devices', 'decor']);
|
||
assert.deepEqual(contract.hooks['mode-tab'].audience, ['test']);
|
||
assert.ok(SELECTORS.modeTab.includes('[data-hp="mode-tab"]'));
|
||
});
|