mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -15,6 +15,19 @@ the same profile. The dedicated
|
||||
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
|
||||
evidence; a local report is diagnostic.
|
||||
|
||||
Issue #160 keeps that historical profile and budget unchanged, then adds the
|
||||
separate `isometric-stage3-dense-v1` profile. Its derived performance-only
|
||||
fixture puts all 200 devices close to walls or corners and adds deterministic
|
||||
value, LQI and new-device badges plus contact/lock-bound door, window and gate
|
||||
examples. The base invocation explicitly allows a Stage 2 renderer so an older
|
||||
`dev` SHA remains measurable; the candidate invocation fails closed unless it
|
||||
reports effective Iso, the Stage 3 revision, all required raised overlay kinds,
|
||||
at least one real nudge, a bounded shared material-definition set and zero
|
||||
structural rebuilds during HA, opening and hover/focus updates. The two extra
|
||||
timing windows use limits no softer than the historical HA-update budget. This
|
||||
does not change the fixture, measured windows or budget of
|
||||
`large-house-isometric-v1`, which remains the #124 regression witness.
|
||||
|
||||
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
|
||||
budgets of the original profile. The same 60-room/60-partition fixture gains
|
||||
six saved open outlines, renders the Plan snap overlay, and sends 120 real
|
||||
@@ -164,6 +177,8 @@ Build and copy a fresh demo bundle first, then run:
|
||||
|
||||
```bash
|
||||
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
|
||||
npm run benchmark:large-house-isometric -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-local.json
|
||||
npm run benchmark:isometric-stage3-dense -- --samples=7 --warmups=1 --output=artifacts/performance/isometric-stage3-local.json
|
||||
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
|
||||
npm run benchmark:large-house-interaction -- --samples=7 --warmups=1 --output=artifacts/performance/interaction-local.json
|
||||
```
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"profile": "isometric-stage3-dense-v1",
|
||||
"minimumSamples": 7,
|
||||
"timings": {
|
||||
"modelReadyMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 200, "hardMaxMs": 3000 },
|
||||
"firstStableRenderMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 3500 },
|
||||
"viewToggleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1500 },
|
||||
"openingUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"overlayInteractionMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"spaceSwitchMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1800 },
|
||||
"stateUpdateMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 75, "hardMaxMs": 1000 },
|
||||
"resizePreviewMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 150, "hardMaxMs": 2200 },
|
||||
"panZoomMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 60, "hardMaxMs": 600 },
|
||||
"settingsDialogMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 100, "hardMaxMs": 1200 },
|
||||
"switchCycleMs": { "stat": "median", "maxRegressionRatio": 0.2, "noiseAllowanceMs": 250, "hardMaxMs": 8000 }
|
||||
},
|
||||
"longTasks": {
|
||||
"maxSingleMs": 3000,
|
||||
"maxSingleRegressionRatio": 0.2,
|
||||
"maxSingleNoiseAllowanceMs": 250,
|
||||
"maxCountP95": 30,
|
||||
"maxCountRegressionRatio": 0.2,
|
||||
"countNoiseAllowance": 3,
|
||||
"maxTotalP95Ms": 12000,
|
||||
"maxTotalRegressionRatio": 0.2,
|
||||
"noiseAllowanceMs": 150
|
||||
},
|
||||
"heap": {
|
||||
"required": true,
|
||||
"hardMaxGrowthBytes": 67108864,
|
||||
"maxRegressionRatio": 0.2,
|
||||
"noiseAllowanceBytes": 16777216
|
||||
},
|
||||
"cacheEntries": {
|
||||
"cleanFloor": 100,
|
||||
"glowClip": 200,
|
||||
"wallUnion": 1,
|
||||
"openingTunnel": 1,
|
||||
"openingWallIndex": 1,
|
||||
"isoGeometry": 8
|
||||
},
|
||||
"cacheGrowth": {
|
||||
"cleanFloor": 0,
|
||||
"glowClip": 0,
|
||||
"wallUnion": 0,
|
||||
"openingTunnel": 0,
|
||||
"openingWallIndex": 0,
|
||||
"isoGeometry": 0
|
||||
},
|
||||
"renderedDevices": 200
|
||||
}
|
||||
@@ -54,15 +54,18 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_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([
|
||||
'_activeDraftId', '_draftSegmentCms', '_isoGeometryCache', '_offerWallFaces',
|
||||
'_liveEditorPaintCount', '_onLabsSnapshot', '_planSnapGeometryCache', '_roomDialog', '_setProjection',
|
||||
'_wallFaceBatch', '_wallFaceGraphCache',
|
||||
'_activeDraftId', '_draftSegmentCms', '_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
|
||||
@@ -81,6 +84,8 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
_decorList: 'array',
|
||||
_decorTool: 'string',
|
||||
_draftSegmentCms: 'array',
|
||||
_effectiveProjection: 'function',
|
||||
_ensureIsoSceneRuntime: 'function',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_hassSequence: 'number',
|
||||
@@ -91,11 +96,13 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
_offerWallFaces: 'function',
|
||||
_path: 'array',
|
||||
_isoGeometryCache: 'map',
|
||||
_isoStructuralBuildCount: 'number',
|
||||
_planSnapGeometryCache: 'object',
|
||||
_roomDialog: 'boolean',
|
||||
_resize: 'object',
|
||||
_setProjection: 'function',
|
||||
_serverCfg: 'object',
|
||||
_space: 'string',
|
||||
_tool: 'string',
|
||||
_wallFaceGraphCache: 'array',
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,8 @@ const evaluation = evaluatePerformanceBudget({
|
||||
baseline: absoluteOnly ? null : readJson(baselinePath),
|
||||
budgets: readJson(budgetsPath),
|
||||
absoluteOnly,
|
||||
candidateSha: valueArg('candidate-sha') || null,
|
||||
baselineSha: valueArg('baseline-sha') || null,
|
||||
});
|
||||
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
|
||||
@@ -37,13 +37,37 @@ export const summarizeLongTasks = (rows) => {
|
||||
};
|
||||
|
||||
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
const exactGitSha = (value) => typeof value === 'string' && /^[0-9a-f]{40}$/i.test(value);
|
||||
const isIsometricProfile = (profile) => profile === 'large-house-isometric-v1'
|
||||
|| profile === 'isometric-stage3-dense-v1';
|
||||
|
||||
const requireReport = (report, budgets, label) => {
|
||||
const requireReport = (report, budgets, label, expectedSha = null) => {
|
||||
if (!report || report.schema !== 2) throw new Error(`${label}: unsupported report schema`);
|
||||
if (report.profile !== budgets.profile) throw new Error(`${label}: unexpected benchmark profile`);
|
||||
if (!Array.isArray(report.rows) || report.rows.length < budgets.minimumSamples) {
|
||||
throw new Error(`${label}: expected at least ${budgets.minimumSamples} measured samples`);
|
||||
}
|
||||
if (!isIsometricProfile(report.profile)) return;
|
||||
if (!exactGitSha(report.sourceSha)) throw new Error(`${label}: missing exact sourceSha`);
|
||||
if (expectedSha != null && report.sourceSha !== expectedSha) {
|
||||
throw new Error(`${label}: sourceSha does not match requested checkout`);
|
||||
}
|
||||
if (!sameJson(report.effectiveProjection, ['iso'])
|
||||
|| report.rows.some((row) => row.effectiveProjection !== 'iso')) {
|
||||
throw new Error(`${label}: effectiveProjection is not exclusively iso`);
|
||||
}
|
||||
if (label === 'candidate' && report.rows.some((row) =>
|
||||
row.isoStructuralBuilds?.supported !== true
|
||||
|| row.isoStructuralBuilds?.haUpdateDelta !== 0)) {
|
||||
throw new Error(`${label}: Iso structural build count is missing or changed on HA-only update`);
|
||||
}
|
||||
if (label === 'candidate' && report.profile === 'isometric-stage3-dense-v1') {
|
||||
if (report.stage3Required !== true) throw new Error(`${label}: Stage 3 was not required`);
|
||||
if (!sameJson(report.isoStageRevision, ['3'])
|
||||
|| report.rows.some((row) => row.isoStageRevision !== '3')) {
|
||||
throw new Error(`${label}: Stage 3 revision metadata is incomplete`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const relativeLimit = (baseline, ratio, allowance) => Math.max(
|
||||
@@ -64,10 +88,13 @@ const makeCheck = (id, actual, limit, details = {}) => ({
|
||||
* additionally compare a base-SHA report from the same runner; fast smoke
|
||||
* captures deliberately enforce only the hard candidate limits.
|
||||
*/
|
||||
export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absoluteOnly = false }) => {
|
||||
requireReport(candidate, budgets, 'candidate');
|
||||
export const evaluatePerformanceBudget = ({
|
||||
candidate, baseline, budgets, absoluteOnly = false,
|
||||
candidateSha = null, baselineSha = null,
|
||||
}) => {
|
||||
requireReport(candidate, budgets, 'candidate', candidateSha);
|
||||
if (!absoluteOnly) {
|
||||
requireReport(baseline, budgets, 'baseline');
|
||||
requireReport(baseline, budgets, 'baseline', baselineSha);
|
||||
if (!sameJson(candidate.fixture, baseline.fixture)) throw new Error('fixture mismatch');
|
||||
for (const key of ['node', 'chromium', 'platform', 'arch']) {
|
||||
if (candidate.runtime?.[key] !== baseline.runtime?.[key]) {
|
||||
@@ -214,6 +241,8 @@ export const evaluatePerformanceBudget = ({ candidate, baseline, budgets, absolu
|
||||
mode: absoluteOnly ? 'absolute' : 'relative',
|
||||
candidateFingerprint: candidate.buildFingerprint,
|
||||
baselineFingerprint: baseline?.buildFingerprint ?? null,
|
||||
candidateSha: candidate.sourceSha ?? null,
|
||||
baselineSha: baseline?.sourceSha ?? null,
|
||||
checks,
|
||||
failures,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
|
||||
export const ISOMETRIC_STAGE3_DENSE_PROFILE = 'isometric-stage3-dense-v1';
|
||||
export const ISOMETRIC_STAGE3_MATERIAL_DEFINITION_LIMIT = 16;
|
||||
|
||||
const CONTACT_ENTITY = 'binary_sensor.perf_stage3_opening';
|
||||
const LOCK_ENTITY = 'lock.perf_stage3_opening';
|
||||
const round = (value) => Number(value.toFixed(6));
|
||||
|
||||
const mergeMarker = (markers, deviceId, patch) => {
|
||||
const current = markers.get(deviceId) ?? {
|
||||
id: deviceId,
|
||||
binding: `device:${deviceId}`,
|
||||
};
|
||||
markers.set(deviceId, { ...current, ...patch });
|
||||
};
|
||||
|
||||
/**
|
||||
* Performance-only Stage 3 stress fixture.
|
||||
*
|
||||
* Keep the historical large-house fixture immutable: its isometric profile is
|
||||
* the #124 regression witness. This derived fixture deliberately puts every
|
||||
* device close to a room wall (and every fourth one close to a corner), then
|
||||
* adds a bounded representative set of wide/value/LQI/new presentations and
|
||||
* live opening bindings. Both comparison bundles receive this exact object;
|
||||
* only the candidate is required to expose the Stage 3 DOM contract.
|
||||
*/
|
||||
export const makeIsometricStage3DenseFixture = () => {
|
||||
const fixture = makeLargeHouseFixture();
|
||||
const roomsByArea = new Map(fixture.config.spaces.flatMap((space) =>
|
||||
space.rooms.map((room) => [room.area, room])));
|
||||
const markers = new Map((fixture.config.markers ?? []).map((marker) => [marker.id, marker]));
|
||||
const deviceIds = Object.keys(fixture.devices).sort((a, b) => {
|
||||
const number = (id) => Number(id.slice(id.lastIndexOf('-') + 1));
|
||||
return number(a) - number(b);
|
||||
});
|
||||
const decoratedDeviceIds = [];
|
||||
const pulseDeviceIdsBySpace = {};
|
||||
|
||||
for (const [index, deviceId] of deviceIds.entries()) {
|
||||
const room = roomsByArea.get(fixture.devices[deviceId]?.area_id);
|
||||
if (!room) throw new Error(`Stage 3 dense fixture has no room for ${deviceId}`);
|
||||
const [topLeft, , bottomRight] = room.poly;
|
||||
const epsilon = 0.002;
|
||||
const midX = (topLeft[0] + bottomRight[0]) / 2;
|
||||
const midY = (topLeft[1] + bottomRight[1]) / 2;
|
||||
const position = index % 4;
|
||||
fixture.layout[deviceId] = {
|
||||
...fixture.layout[deviceId],
|
||||
x: round(position === 0 || position === 3 ? bottomRight[0] - epsilon : midX),
|
||||
y: round(position === 1 ? bottomRight[1] - epsilon
|
||||
: position === 2 || position === 3 ? topLeft[1] + epsilon : midY),
|
||||
};
|
||||
|
||||
const entity = Object.values(fixture.entities)
|
||||
.find((candidate) => candidate.device_id === deviceId);
|
||||
const state = entity ? fixture.states[entity.entity_id] : null;
|
||||
const spaceId = fixture.layout[deviceId]?.s;
|
||||
if (state && spaceId && !pulseDeviceIdsBySpace[spaceId]
|
||||
&& entity.entity_id.startsWith('fan.') && state.state === 'on') {
|
||||
pulseDeviceIdsBySpace[spaceId] = deviceId;
|
||||
mergeMarker(markers, deviceId, { display: 'icon_ripple' });
|
||||
}
|
||||
if (state && index % 3 === 0) {
|
||||
fixture.states[entity.entity_id] = {
|
||||
...state,
|
||||
attributes: { ...state.attributes, lqi: 40 + (index * 17) % 141 },
|
||||
};
|
||||
decoratedDeviceIds.push(deviceId);
|
||||
mergeMarker(markers, deviceId, {
|
||||
value_badge: {
|
||||
enabled: true,
|
||||
source: { kind: 'entity_state', entity_id: entity.entity_id },
|
||||
position: ['right', 'bottom', 'left', 'top'][index % 4],
|
||||
},
|
||||
});
|
||||
} else if (state && index % 5 === 0) {
|
||||
decoratedDeviceIds.push(deviceId);
|
||||
mergeMarker(markers, deviceId, { display: 'value' });
|
||||
}
|
||||
}
|
||||
|
||||
for (const space of fixture.config.spaces) {
|
||||
space.settings = {
|
||||
...space.settings,
|
||||
show_lqi: true,
|
||||
label_temp: true,
|
||||
label_hum: true,
|
||||
label_lqi: true,
|
||||
label_light: true,
|
||||
};
|
||||
const windowOpening = space.openings.find((opening) => opening.type === 'window');
|
||||
const doorOpening = space.openings.find((opening) => opening.type === 'door');
|
||||
const gateOpening = space.openings.find((opening) => opening.type === 'gate');
|
||||
if (!windowOpening || !doorOpening || !gateOpening)
|
||||
throw new Error(`Stage 3 dense fixture lacks an opening kind on ${space.id}`);
|
||||
windowOpening.contact = CONTACT_ENTITY;
|
||||
doorOpening.contact = CONTACT_ENTITY;
|
||||
doorOpening.lock = LOCK_ENTITY;
|
||||
gateOpening.contact = CONTACT_ENTITY;
|
||||
gateOpening.lock = LOCK_ENTITY;
|
||||
}
|
||||
|
||||
fixture.config.markers = [...markers.values()];
|
||||
const perSpaceNewDeviceIds = fixture.config.spaces.map((space) =>
|
||||
decoratedDeviceIds.find((id) => fixture.layout[id]?.s === space.id));
|
||||
if (perSpaceNewDeviceIds.some((id) => !id))
|
||||
throw new Error('Stage 3 dense fixture has no decorated new-device source on every floor');
|
||||
const newDeviceIds = [...new Set([...perSpaceNewDeviceIds, ...decoratedDeviceIds])].slice(0, 12);
|
||||
fixture.config.settings = {
|
||||
...fixture.config.settings,
|
||||
filter_seeded: true,
|
||||
known_devices: deviceIds,
|
||||
new_device_ids: newDeviceIds,
|
||||
};
|
||||
fixture.states[CONTACT_ENTITY] = {
|
||||
entity_id: CONTACT_ENTITY,
|
||||
state: 'off',
|
||||
attributes: { device_class: 'door', friendly_name: 'Stage 3 opening contact' },
|
||||
};
|
||||
fixture.states[LOCK_ENTITY] = {
|
||||
entity_id: LOCK_ENTITY,
|
||||
state: 'locked',
|
||||
attributes: { friendly_name: 'Stage 3 opening lock' },
|
||||
};
|
||||
const stateEntityId = Object.values(fixture.entities).find((entity) =>
|
||||
fixture.layout[entity.device_id]?.s === 'perf-floor-2')?.entity_id;
|
||||
if (!stateEntityId) throw new Error('Stage 3 dense fixture has no floor-2 state source');
|
||||
const pulseDeviceIds = fixture.config.spaces.map((space) => pulseDeviceIdsBySpace[space.id]);
|
||||
if (pulseDeviceIds.some((id) => !id))
|
||||
throw new Error('Stage 3 dense fixture has no active pulse source on every floor');
|
||||
fixture.stage3Dense = {
|
||||
contactEntityId: CONTACT_ENTITY,
|
||||
lockEntityId: LOCK_ENTITY,
|
||||
stateEntityId,
|
||||
pulseDeviceId: pulseDeviceIds[0],
|
||||
pulseDeviceIdsBySpace,
|
||||
decoratedDeviceIds,
|
||||
expectedOverlayKinds: ['device', 'room-label', 'opening-lock'],
|
||||
expectedOpeningKinds: ['door', 'window', 'gate'],
|
||||
materialDefinitionLimit: ISOMETRIC_STAGE3_MATERIAL_DEFINITION_LIMIT,
|
||||
};
|
||||
fixture.counts = {
|
||||
...fixture.counts,
|
||||
denseMarkers: deviceIds.length,
|
||||
decoratedMarkers: decoratedDeviceIds.length,
|
||||
boundOpenings: fixture.config.spaces.length * 3,
|
||||
};
|
||||
return fixture;
|
||||
};
|
||||
Reference in New Issue
Block a user