fix: make the handle paint order a named decision and mutate it (#400)

CODE-REVIEW-400-r1 Medium: the registered mutant edited a comment, not
the order — it could not reproduce the regression AC1 exists to catch.
That is the same defect class this issue is fixing elsewhere, in my own
guard.

The order is now HANDLE_PAINT_ORDER, a named constant, because it IS the
hit priority rather than an accident of where the blocks sit in the
template. The mutant flips that constant, so it reproduces exactly the
behaviour the audit found.

Also: smoke_furniture picked the SE corner as handles[3], an index that
silently depended on the old paint order — CI shard 3 went red on four
checks. It now selects by role (corner handles, third of four), which is
what the test actually means.

User-Visible: no
Issue: #400
This commit is contained in:
Codex
2026-08-31 08:14:55 +03:00
parent f07e3c54ff
commit d153b20a1d
3 changed files with 41 additions and 24 deletions
+10 -3
View File
@@ -357,7 +357,13 @@ const res = await page.evaluate(async () => {
const before = { w: sofaNow().w, h: sofaNow().h };
const b = c._dtBox || { x: 0, y: 0, w: 0, h: 0 };
const handles = [...(frame()?.querySelectorAll('.dthandle') || [])];
const se = handles[3] || stageEl(); // SE — четвёртый угол
// #400: угловая ручка ищется по РОЛИ, а не по индексу в DOM. Индекс держался
// на порядке отрисовки, а порядок — это решение о приоритете хита (углы
// рисуются последними, чтобы выигрывать нажатие у осевых на мелкой мебели),
// и оно имеет право меняться. Юго-восточный угол — второй `nwse`.
const cornerHandles = handles.filter((el) => !el.classList.contains('dtrot')
&& !el.classList.contains('dtedge'));
const se = cornerHandles[2] || stageEl(); // SE — [1, 1, 'nwse']
ev('pointerdown', se, b.x + b.w, b.y + b.h);
out.cornerDragStarted = c._dtDrag?.kind === 'scale' && !!c._dtDrag?.orig;
ev('pointermove', stageEl(), b.x + b.w + 100, b.y + b.h + 20, { shiftKey: true });
@@ -405,8 +411,9 @@ const res = await page.evaluate(async () => {
// must restore the exact pre-gesture object and create no persisted flip.
const crossBefore = JSON.parse(JSON.stringify(sofaNow()));
const cb = c._dtBox;
const crossHandles = [...frame().querySelectorAll('.dthandle')];
const crossSe = crossHandles[3];
const crossHandles = [...frame().querySelectorAll('.dthandle')]
.filter((el) => !el.classList.contains('dtrot') && !el.classList.contains('dtedge'));
const crossSe = crossHandles[2]; // #400: по роли, не по позиции в DOM
ev('pointerdown', crossSe, cb.x + cb.w, cb.y + cb.h);
ev('pointermove', stageEl(), cb.x - 25, cb.y + cb.h + 10, { shiftKey: true });
await c.updateComplete;
+5 -2
View File
@@ -754,8 +754,11 @@ const MUTANT_DEFINITIONS = [
+ 'the one that cannot be reached any other way (#400 AC1)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' ${/* #400: corners LAST.',
replace: ' ${/* mutant: corners no longer last.',
// Порядок — именованное решение, поэтому мутант меняет ЕГО, а не текст
// рядом с ним: прежний патч правил комментарий и регрессию не
// воспроизводил (находка CODE-REVIEW-400-r1).
find: "const HANDLE_PAINT_ORDER = ['edges', 'corners'] as const;",
replace: "const HANDLE_PAINT_ORDER = ['corners', 'edges'] as const;",
}],
},
{
+26 -19
View File
@@ -811,6 +811,19 @@ interface DeviceInboxDialogState {
type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown };
/**
* #400: the paint order of the selection handles IS the hit priority, so it is
* a named decision rather than a consequence of where the blocks happen to sit
* in the template.
*
* Corner and edge handles carry the same hit radius (1.8 % of the view). On
* furniture narrower than 4·hr — a 40 cm cabinet — their circles overlap, and
* whichever is painted last takes the tap. The corner must be last: an edge
* handle scales one axis, a corner scales both, and the object is small
* exactly when proportional resize matters most.
*/
const HANDLE_PAINT_ORDER = ['edges', 'corners'] as const;
interface DeviceDragState {
id: string;
spaceId: string;
@@ -8464,25 +8477,19 @@ export class HouseplanCard extends LitElement {
<circle class="dthandle dtrot" cx="${b.x + b.w / 2}" cy="${b.y - arm}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'rotate')}></circle>
<circle class="dtknob" cx="${b.x + b.w / 2}" cy="${b.y - arm}" r="${kr.toFixed(2)}"></circle>
${sides.map(([sx, sy, cur]) => {
const x = sx < 0 ? b.x : sx > 0 ? b.x + b.w : b.x + b.w / 2;
const y = sy < 0 ? b.y : sy > 0 ? b.y + b.h : b.y + b.h / 2;
return svg`<circle class="dthandle dtedge dt-${cur}" cx="${x}" cy="${y}"
r="${hr.toFixed(1)}" @pointerdown=${(e: PointerEvent) =>
this._dtStart(e, 'scale', [sx, sy])}></circle>
<circle class="dtknob dtedgeknob" cx="${x}" cy="${y}" r="${kr.toFixed(2)}"></circle>`;
})}
${/* #400: corners LAST. Both handles carry the same hit radius (1.8 % of
the view), so on furniture narrower than 4·hr — a 40 cm cabinet — the
two circles overlap and whichever is painted later takes the hit.
The corner is the one that cannot be reached any other way: an edge
handle scales one axis, a corner scales both, and the object is small
precisely when proportional resize matters most. The bead order is
unchanged visually — the knobs are drawn with their own handles. */
corners.map(([sx, sy, cur]) => svg`<circle class="dthandle dt-${cur}"
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'scale', [sx, sy])}></circle><circle class="dtknob"
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${kr.toFixed(2)}"></circle>`)}
${HANDLE_PAINT_ORDER.map((role) => (role === 'edges'
? sides.map(([sx, sy, cur]) => {
const x = sx < 0 ? b.x : sx > 0 ? b.x + b.w : b.x + b.w / 2;
const y = sy < 0 ? b.y : sy > 0 ? b.y + b.h : b.y + b.h / 2;
return svg`<circle class="dthandle dtedge dt-${cur}" cx="${x}" cy="${y}"
r="${hr.toFixed(1)}" @pointerdown=${(e: PointerEvent) =>
this._dtStart(e, 'scale', [sx, sy])}></circle>
<circle class="dtknob dtedgeknob" cx="${x}" cy="${y}" r="${kr.toFixed(2)}"></circle>`;
})
: corners.map(([sx, sy, cur]) => svg`<circle class="dthandle dt-${cur}"
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${hr.toFixed(1)}"
@pointerdown=${(e: PointerEvent) => this._dtStart(e, 'scale', [sx, sy])}></circle><circle class="dtknob"
cx="${sx < 0 ? b.x : b.x + b.w}" cy="${sy < 0 ? b.y : b.y + b.h}" r="${kr.toFixed(2)}"></circle>`)))}
</g>` as unknown as TemplateResult;
}