mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
#500 gave `_serverCfg` and `_layout` prototype accessors but left them in
`static properties`. Lit marks such a property `wrapped` and, on the FIRST
update, force-writes it into `changedProperties` with an `undefined` old
value even though nobody assigned anything (`reactive-element.js:249-252`
and `:880-886`). `willUpdate` reads that as a config replacement, raises
`_cfgEpoch`, the memoized model key changes, and a 60-room house builds and
paints its model a second time: measured 19 update cycles, 4 builds and 4
epochs against 18 / 3 / 3 before #500, worth ~550 ms of `modelReadyMs` and
the same on `firstStableRenderMs` (3355 against a 3000 ceiling).
The declaration goes; the bodies stay reactive through the owner —
`_adoption` → `onBodyReplaced` → `requestUpdate(field, previous)` — which
needs no declaration: `getPropertyOptions` falls back to the default and
`changed.has('_serverCfg')` works as before. `noAccessor: true` would not
help, `wrapped` is set before that flag is read. The trap is written above
`static properties`, where someone would put the declaration back.
`cache.entries.cleanFloor` returns to 100 in both interaction budgets: the
120 entries were the extra epoch re-keying the per-room cache, not a
property of the design — the reasoning in 914e8402 was wrong.
Witness: test/config-adoption-ownership.test.mjs pins that neither body is
declared; the mutant `adoption-bodies-declared-reactive` puts the
declaration back and reddens it.
The boot diagnostics of the previous three commits touch four private
members, so they are declared in the performance contract: `_buildModel` and
`_cfgEpoch` outright (both exist in every supported comparison base), and the
adoption entry point as a current/legacy pair — #500 turned the private
`_adoptStructuralResponses` into the public `_adoptAuthoritative`, and an
undeclared rename would have the counter report zero adoptions instead of
failing.
The same commits carried a `node_modules` symlink: `.gitignore` had the
pattern with a trailing slash, which does not cover a symbolic link, and
`git add -A` in a sandbox worktree committed it. The link is removed and the
pattern loses the slash; a mutant run on this branch failed with `EEXIST` on
it.
Issue: #520
User-Visible: no
131 lines
6.8 KiB
JavaScript
131 lines
6.8 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||
import { join, relative } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
const OWNER = 'src/config-adoption.ts';
|
||
|
||
/**
|
||
* #500 — one owner for config/layout identity.
|
||
*
|
||
* Revision and fingerprint of the server config and of the device layout are
|
||
* written by `ConfigAdoption` and nowhere else: a revision taken apart from
|
||
* the body it belongs to is how #490 F1 lost a concurrent edit. Bodies may
|
||
* still be staged locally before a write (`_serverCfg = candidate`,
|
||
* `_layout = { ...pos }`) — that is the editor's job — but only in the files
|
||
* pinned below, and the counts are a ratchet: they may go down, never up.
|
||
*/
|
||
|
||
const tsFiles = (dir) => readdirSync(dir).flatMap((name) => {
|
||
const path = join(dir, name);
|
||
if (statSync(path).isDirectory()) return tsFiles(path);
|
||
return name.endsWith('.ts') ? [path] : [];
|
||
});
|
||
const sources = tsFiles(join(repoRoot, 'src'))
|
||
.map((path) => ({ file: relative(repoRoot, path).replaceAll('\\', '/'), text: readFileSync(path, 'utf8') }))
|
||
.filter(({ file }) => file !== OWNER);
|
||
|
||
const IDENTITY_WRITE = /\b(?:_cfgRev|_layoutRev|_cfgContentFingerprint|_layoutContentFingerprint)\s*(?:=(?!=)|\+\+|--|\+=|-=)/g;
|
||
const BODY_WRITE = {
|
||
_serverCfg: /\b_serverCfg\s*=(?!=)/g,
|
||
_layout: /\b_layout\s*=(?!=)/g,
|
||
};
|
||
|
||
/**
|
||
* Local staging of bodies before a write. Reading these numbers: the editor
|
||
* replaces the working config for settings dialogs and marker saves; the card
|
||
* stages device positions (`applyDevicePlacement`). Lowering a count is a
|
||
* win to record here; raising one is a review finding.
|
||
*/
|
||
const BODY_STAGING_ALLOWLIST = {
|
||
'src/houseplan-editor-runtime.ts': { _serverCfg: 5, _layout: 5 },
|
||
'src/houseplan-card.ts': { _serverCfg: 3, _layout: 9 },
|
||
};
|
||
|
||
const count = (text, re) => (text.match(re) || []).length;
|
||
|
||
test('AC1: revision and fingerprint identity is written only by the owner', () => {
|
||
const offenders = sources
|
||
.flatMap(({ file, text }) => (text.match(IDENTITY_WRITE) || []).map((hit) => `${file}: ${hit.trim()}`));
|
||
assert.deepEqual(offenders, [], 'a revision belongs to the body it came with — go through ConfigAdoption');
|
||
});
|
||
|
||
test('AC1: body staging outside the owner stays within the pinned files and counts (ratchet)', () => {
|
||
const problems = [];
|
||
for (const { file, text } of sources) {
|
||
const pinned = BODY_STAGING_ALLOWLIST[file];
|
||
for (const [field, re] of Object.entries(BODY_WRITE)) {
|
||
const actual = count(text, re);
|
||
const allowed = pinned?.[field] ?? 0;
|
||
if (actual > allowed) problems.push(`${file}: ${field} assigned ${actual}×, allowed ${allowed}`);
|
||
else if (actual < allowed) problems.push(`${file}: ${field} assigned ${actual}×, pin ${allowed} — lower the pin, keep the win`);
|
||
}
|
||
}
|
||
assert.deepEqual(problems, []);
|
||
});
|
||
|
||
test('AC1: the harness seam `seedIdentity` is reachable only through the card delegate setters', () => {
|
||
const callers = sources.filter(({ text }) => text.includes('seedIdentity('));
|
||
assert.deepEqual(callers.map(({ file }) => file), ['src/houseplan-card.ts']);
|
||
const lines = callers[0].text.split('\n').filter((line) => line.includes('seedIdentity('));
|
||
assert.equal(lines.length, 4);
|
||
for (const line of lines) assert.match(line.trim(), /^private set _(?:cfgRev|layoutRev|cfgContentFingerprint|layoutContentFingerprint)\(/);
|
||
});
|
||
|
||
test('AC2: the former host seam is gone and every module adopts through the one gated entry', () => {
|
||
const seam = sources.filter(({ text }) => /\b_adoptStructuralResponses\b/.test(text)).map(({ file }) => file);
|
||
assert.deepEqual(seam, [], '_adoptStructuralResponses must not come back as a host method');
|
||
const adopters = sources.filter(({ text }) => /_adoptAuthoritative\(\{/.test(text)).map(({ file }) => file).sort();
|
||
assert.deepEqual(adopters, [
|
||
'src/houseplan-card.ts',
|
||
'src/houseplan-editor-runtime.ts',
|
||
'src/houseplan-onboarding-runtime.ts',
|
||
'src/summary-panel-runtime-loaded.ts',
|
||
]);
|
||
const owner = readFileSync(join(repoRoot, OWNER), 'utf8');
|
||
for (const reason of ['structural-response', 'config-reload', 'summary-recovery', 'space-delete', 'optimize-undo', 'import-apply']) {
|
||
assert.ok(owner.includes(`'${reason}'`), `${reason} is a declared adoption reason`);
|
||
assert.ok(sources.some(({ text }) => text.includes(`reason: '${reason}'`)), `${reason} has a caller`);
|
||
}
|
||
});
|
||
|
||
test('AC2: feature-runtime host contracts expose one adoption method, not its eight steps', () => {
|
||
const summaryHost = readFileSync(join(repoRoot, 'src/summary-panel-host.ts'), 'utf8');
|
||
for (const step of [
|
||
'_beginContinuityCandidate', '_syncDecorAssets', '_adoptInitialSpace', '_resumePendingNavMode',
|
||
'_candidateBackdrop', '_scheduleLoadRetry', '_signer', '_continuity', '_cfgContentFingerprint',
|
||
]) {
|
||
assert.ok(!summaryHost.includes(step), `SummaryPanelHost must not expose ${step}`);
|
||
}
|
||
assert.match(summaryHost, /_adoptAuthoritative\(input: GatedAdoptionInput\)/);
|
||
const editorPort = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8');
|
||
assert.match(editorPort, /readonly _cfgRev: number;/);
|
||
assert.match(editorPort, /readonly _layoutRev: number;/);
|
||
assert.match(editorPort, /readonly _adoption: ConfigAdoption;/);
|
||
const rollbackCallers = sources.filter(({ text }) => /(?<![_\w])rollbackOptimistic\(/.test(text)).map(({ file }) => file).sort();
|
||
assert.deepEqual(rollbackCallers, ['src/houseplan-card.ts'], 'only the host wrapper reaches the owner\'s rollback');
|
||
});
|
||
|
||
test('#520: the adoption bodies are not declared as Lit reactive properties', () => {
|
||
// Lit marks a declared property whose prototype already has an accessor as
|
||
// `wrapped` and force-writes it into `changedProperties` on the FIRST update
|
||
// with an `undefined` old value — even when nobody assigned anything. The
|
||
// card reads that as a config replacement, raises `_cfgEpoch`, invalidates
|
||
// the memoized model and rebuilds a 60-room house a second time: +550 ms to
|
||
// the first stable frame. The bodies stay reactive through
|
||
// `_adoption` → `onBodyReplaced` → `requestUpdate`, which needs no declaration.
|
||
const card = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||
const block = card.slice(card.indexOf('static properties = {'));
|
||
const declarations = block.slice(0, block.indexOf('\n };'));
|
||
for (const body of ['_serverCfg', '_layout']) {
|
||
assert.ok(!new RegExp(`^\\s*${body}:`, 'm').test(declarations),
|
||
`${body} must not be declared in static properties (#520)`);
|
||
}
|
||
// The delegates and the notification that replace the declaration are here.
|
||
assert.match(card, /private get _serverCfg\(\)/);
|
||
assert.match(card, /private get _layout\(\)/);
|
||
assert.match(card, /\(field, previous\) => this\.requestUpdate\(field, previous\)/);
|
||
});
|