mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +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
192 lines
6.5 KiB
JavaScript
192 lines
6.5 KiB
JavaScript
/**
|
|
* Private houseplan-card surface consumed by the performance runners.
|
|
*
|
|
* The candidate runner profiles both the candidate bundle and a bundle built
|
|
* from the comparison SHA. Keep these lists explicit so a private rename in
|
|
* either tree fails before measurements instead of silently reporting zeroes.
|
|
*/
|
|
const CACHE_FIELDS = Object.freeze([
|
|
'_cleanFloorCache',
|
|
'_glowClipCache',
|
|
'_wallUnionCache',
|
|
'_openingTunnelCache',
|
|
'_openingWallIndexCache',
|
|
]);
|
|
|
|
export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
|
label: 'large-house-v1',
|
|
methods: Object.freeze([
|
|
'_baseVb',
|
|
'_bindingStatus',
|
|
'_buildModel',
|
|
'_cancelDecorGesture',
|
|
'_checkSpacePhysicalGeometry',
|
|
'_decorBoxOf',
|
|
'_dtMeasure',
|
|
'_dtMove',
|
|
'_dtStart',
|
|
'_openSettingsDialog',
|
|
'_pickSpace',
|
|
'_pos',
|
|
'_renderBody',
|
|
'_rszCancelDrag',
|
|
'_rszEdgeDown',
|
|
'_rszMove',
|
|
'_rszRooms',
|
|
'_setMode',
|
|
'_scenePoint',
|
|
'_viewOr',
|
|
]),
|
|
fields: Object.freeze([
|
|
'_booting',
|
|
'_bootSoft',
|
|
'_cameraTransition',
|
|
'_cfgEpoch',
|
|
...CACHE_FIELDS,
|
|
'_continuity',
|
|
'_cursorPt',
|
|
'_decorList',
|
|
'_decorSel',
|
|
'_decorTool',
|
|
'_devices',
|
|
'_gridPitch',
|
|
'_hassSequence',
|
|
'_loadOk',
|
|
'_model',
|
|
'_modeTransitionBusy',
|
|
'_path',
|
|
'_serverCfg',
|
|
'_space',
|
|
'_settingsDialog',
|
|
'_tool',
|
|
]),
|
|
// A comparison SHA before #89 is intentionally flat; the isometric runner
|
|
// checks these members only when the target source tree supports Stage 1.
|
|
optionalFields: Object.freeze([
|
|
'_activeWallChainId', '_activeWallChainPartitionIds', '_wallChainSegmentCms',
|
|
'_effectiveProjection', '_ensureIsoSceneRuntime',
|
|
'_isoGeometryCache', '_isoStructuralBuildCount', '_offerWallFaces',
|
|
'_liveEditorPaintCount', '_onLabsSnapshot',
|
|
'_planSnapGeometryCache', '_roomDialog', '_setProjection', '_wallFaceBatch',
|
|
'_wallFaceGraphCache',
|
|
]),
|
|
// #380: v1.68.1 owns the same resize session directly on the card; newer
|
|
// bundles moved it into ResizeController. A comparison target must expose
|
|
// one of the two explicit shapes; the current member retains its object
|
|
// type check and is also verified against current production source.
|
|
// #500 replaced the private structural adoption with a gated public entry
|
|
// point. The boot diagnostics (#520) count adoptions through whichever of
|
|
// the two the measured bundle owns; an undeclared rename would silently
|
|
// report zero adoptions instead of failing.
|
|
fieldAlternatives: Object.freeze([
|
|
Object.freeze({ current: '_resize', legacy: '_rszDrag' }),
|
|
Object.freeze({ current: '_adoptAuthoritative', legacy: '_adoptStructuralResponses' }),
|
|
]),
|
|
fieldTypes: Object.freeze({
|
|
_adoptAuthoritative: 'function',
|
|
_booting: 'boolean',
|
|
_bootSoft: 'boolean',
|
|
_cameraTransition: 'object',
|
|
_cfgEpoch: 'number',
|
|
_cleanFloorCache: 'map',
|
|
_devices: 'array',
|
|
_continuity: 'object',
|
|
_decorList: 'array',
|
|
_decorTool: 'string',
|
|
_activeWallChainPartitionIds: 'array',
|
|
_wallChainSegmentCms: 'array',
|
|
_effectiveProjection: 'function',
|
|
_ensureIsoSceneRuntime: 'function',
|
|
_glowClipCache: 'map',
|
|
_gridPitch: 'number',
|
|
_hassSequence: 'number',
|
|
_loadOk: 'boolean',
|
|
_liveEditorPaintCount: 'number',
|
|
_model: 'array',
|
|
_onLabsSnapshot: 'function',
|
|
_offerWallFaces: 'function',
|
|
_path: 'array',
|
|
_isoGeometryCache: 'map',
|
|
_isoStructuralBuildCount: 'number',
|
|
_planSnapGeometryCache: 'object',
|
|
_roomDialog: 'boolean',
|
|
_resize: 'object',
|
|
_setProjection: 'function',
|
|
_serverCfg: 'object',
|
|
_space: 'string',
|
|
_tool: 'string',
|
|
_wallFaceGraphCache: 'array',
|
|
}),
|
|
});
|
|
|
|
export const GLOW_CARD_CONTRACT = Object.freeze({
|
|
label: 'Glow performance profiles',
|
|
methods: Object.freeze([]),
|
|
fields: Object.freeze([
|
|
...CACHE_FIELDS,
|
|
'_devices',
|
|
'_loadOk',
|
|
]),
|
|
// Additive blending was introduced after the first supported performance
|
|
// bases. Its absence is safe: the runner keeps the historical normal blend.
|
|
optionalFields: Object.freeze(['_glowScreenBlend']),
|
|
fieldTypes: Object.freeze({
|
|
_cleanFloorCache: 'map',
|
|
_devices: 'array',
|
|
_glowClipCache: 'map',
|
|
_glowScreenBlend: 'boolean',
|
|
_loadOk: 'boolean',
|
|
}),
|
|
});
|
|
|
|
export const SPACE_GLOW_CARD_CONTRACT = Object.freeze({
|
|
label: 'Static-card Glow performance profiles',
|
|
methods: Object.freeze([]),
|
|
fields: Object.freeze(['_devices', '_loading', '_snap']),
|
|
optionalFields: Object.freeze(['_glowRuntimeState', '_glowScreenBlend']),
|
|
fieldTypes: Object.freeze({
|
|
_devices: 'array',
|
|
_loading: 'boolean',
|
|
_glowRuntimeState: 'object',
|
|
_glowScreenBlend: 'boolean',
|
|
}),
|
|
});
|
|
|
|
/** Single fail-fast implementation injected into both browser runners. Keep
|
|
* this function self-contained: runners serialize it with `toString()`. */
|
|
export function assertCardContract(card, contract) {
|
|
const matches = (value, expected) => {
|
|
if (expected === 'array') return Array.isArray(value);
|
|
if (expected === 'map') return value instanceof Map;
|
|
return typeof value === expected;
|
|
};
|
|
const missingMethods = contract.methods
|
|
.filter((name) => typeof card[name] !== 'function')
|
|
.map((name) => `${name}()`);
|
|
const missingFields = contract.fields
|
|
.filter((name) => !(name in card) || card[name] === undefined);
|
|
const missingAlternatives = (contract.fieldAlternatives || [])
|
|
.filter((choice) => !Object.values(choice)
|
|
.some((name) => name in card && card[name] !== undefined))
|
|
.map((choice) => Object.values(choice).join('|'));
|
|
const alternativeFields = (contract.fieldAlternatives || [])
|
|
.flatMap((choice) => Object.values(choice));
|
|
const invalidFields = [
|
|
...contract.fields, ...(contract.optionalFields || []), ...alternativeFields,
|
|
]
|
|
.filter((name) => name in card && contract.fieldTypes?.[name]
|
|
&& !matches(card[name], contract.fieldTypes[name]))
|
|
.map((name) => `${name}:${contract.fieldTypes[name]}`);
|
|
const missing = [...missingMethods, ...missingFields, ...missingAlternatives];
|
|
if (missing.length || invalidFields.length) {
|
|
const details = [
|
|
missing.length ? `missing private API: ${missing.join(', ')}` : '',
|
|
invalidFields.length ? `invalid private API types: ${invalidFields.join(', ')}` : '',
|
|
].filter(Boolean).join('; ');
|
|
throw new Error(
|
|
`${contract.label} harness is incompatible with this houseplan-card bundle; ${details}. `
|
|
+ 'Update the explicit candidate/base compatibility contract before profiling.',
|
|
);
|
|
}
|
|
}
|