Files
houseplan-card/test/config-adoption-ownership.test.mjs
T
Claude e181b08f2b fix: the adoption bodies are no longer declared Lit properties (#520)
#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
2026-09-10 18:58:24 +03:00

131 lines
6.8 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.
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\)/);
});