fix(m4): tape line is a fixed fixture, not draggable (#99)
This commit is contained in:
@@ -29,9 +29,9 @@ Sources reconciled 2026-09-18 from the current branch docs plus the `pre-simplif
|
|||||||
| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/capture/tapeLine.test.ts |
|
| CAPTURE-60 | The owner aligns the length axis to the prevailing slab length and confirms it; the tape-line calibration is independent of the mask's minimum-area bounding rectangle. | M4 | frontend/src/capture/tapeLine.test.ts |
|
||||||
| CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
|
| CAPTURE-65 | On a nearly square slab the owner chooses which axis is length. | shipped | — |
|
||||||
| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts |
|
| CAPTURE-70 | The owner enters length and thickness in 1/8 inch steps. | shipped | frontend/src/capture/measureInputs.test.ts |
|
||||||
| CAPTURE-75 | Scale equals the pixel distance between the two owner-positioned tape-line ticks divided by the owner-declared length; mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts |
|
| CAPTURE-75 | Scale equals the pixel distance between the two ends of the fixed tape line divided by the owner-declared length; mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts |
|
||||||
| CAPTURE-170 | The calibration overlay shows a draggable tape-line with two rifle-scope end ticks; the owner positions the ticks at the tape's actual start and end. | M4 | frontend/src/capture/tapeLine.test.ts |
|
| CAPTURE-170 | The calibration overlay draws one black tape-line fixed in the middle of the 3:4 frame, with a tick at each end. The line is a stage fixture: it is not editable, and its ends are set by the crop-fill setting alone (at 90 percent each end sits 5 percent in from the frame edge). The owner pans, zooms, and rotates the slab until its long axis lies along the line. | M4 | frontend/src/capture/tapeLine.test.ts |
|
||||||
| CAPTURE-175 | The owner-declared length is the distance represented by the tape-line ticks, and scale uses their pixel distance in source-photo coordinates, independent of mask span. | M4 | frontend/src/measure/measure.test.ts |
|
| CAPTURE-175 | The owner-declared length is the tape reading taken where the slab was aligned to the line; scale equals the fixed line's pixel distance between its two ends, in source-photo coordinates, divided by that declared length. Mask span (`maskSpanAlong`) is not checked or used as a length or scale source. | M4 | frontend/src/measure/measure.test.ts |
|
||||||
| CAPTURE-80 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts |
|
| CAPTURE-80 | Widths sample every 6 inches perpendicular to the approved axis, dropping the first and last 6 inches; min, max, and average are reported; if length is 12 inches or less all three are zero. | shipped | frontend/src/measure/measure.test.ts |
|
||||||
| CAPTURE-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts |
|
| CAPTURE-85 | sqft equals mask_pixels divided by scale squared divided by 144, rounded half-up to 2 decimals. | shipped | frontend/src/measure/measure.test.ts |
|
||||||
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
|
| CAPTURE-90 | bdft equals sqft times thickness_in, rounded half-up to 2 decimals, with no divide-by-12; 1 sqft at 1 inch equals 1 bdft. | shipped | frontend/src/measure/measure.test.ts |
|
||||||
|
|||||||
@@ -8,15 +8,12 @@
|
|||||||
IDENTITY_TRANSFORM,
|
IDENTITY_TRANSFORM,
|
||||||
cropEnds,
|
cropEnds,
|
||||||
fitScale,
|
fitScale,
|
||||||
hitRadiusStage,
|
|
||||||
} from './tapeLine';
|
} from './tapeLine';
|
||||||
|
|
||||||
export let state: Extract<Capture, { k: 'axis' }>;
|
export let state: Extract<Capture, { k: 'axis' }>;
|
||||||
export let cropFill = 90;
|
export let cropFill = 90;
|
||||||
export let setTapePose: (pose: {
|
export let setTapePose: (pose: {
|
||||||
transform: PhotoTransform;
|
transform: PhotoTransform;
|
||||||
tickStartY: number;
|
|
||||||
tickEndY: number;
|
|
||||||
sourceW: number;
|
sourceW: number;
|
||||||
sourceH: number;
|
sourceH: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
@@ -33,14 +30,13 @@
|
|||||||
let sourceH = state.source.heightPx;
|
let sourceH = state.source.heightPx;
|
||||||
let renderedW = 320;
|
let renderedW = 320;
|
||||||
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? IDENTITY_TRANSFORM) };
|
let transform: PhotoTransform = { ...(state.axis.photoTransform ?? IDENTITY_TRANSFORM) };
|
||||||
let tickA = state.axis.tickStartY;
|
// The line is a stage fixture. Its two ends are the crop insets, so they are
|
||||||
let tickB = state.axis.tickEndY;
|
// a pure function of crop_fill and are never an owner input (#99).
|
||||||
let focus: 'A' | 'B' = 'A';
|
|
||||||
let fill = cropFill;
|
let fill = cropFill;
|
||||||
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
let lengthIn = state.lengthEighths === 0 ? '' : String(eighthsToIn(state.lengthEighths));
|
||||||
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
let thicknessIn = state.thicknessEighths === 0 ? '' : String(eighthsToIn(state.thicknessEighths));
|
||||||
let calcBusy = false;
|
let calcBusy = false;
|
||||||
let drag: 'tick' | 'tilt' | 'pan' | 'pinch' | null = null;
|
let drag: 'tilt' | 'pan' | 'pinch' | null = null;
|
||||||
let pointers = new Map<number, [number, number]>();
|
let pointers = new Map<number, [number, number]>();
|
||||||
let pinch0 = 0;
|
let pinch0 = 0;
|
||||||
let zoom0 = 1;
|
let zoom0 = 1;
|
||||||
@@ -56,8 +52,6 @@
|
|||||||
function publish() {
|
function publish() {
|
||||||
setTapePose({
|
setTapePose({
|
||||||
transform: { ...transform },
|
transform: { ...transform },
|
||||||
tickStartY: tickA,
|
|
||||||
tickEndY: tickB,
|
|
||||||
sourceW,
|
sourceW,
|
||||||
sourceH,
|
sourceH,
|
||||||
});
|
});
|
||||||
@@ -105,12 +99,12 @@
|
|||||||
ctx.moveTo(x, ends.startY);
|
ctx.moveTo(x, ends.startY);
|
||||||
ctx.lineTo(x, ends.endY);
|
ctx.lineTo(x, ends.endY);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
for (const [id, y] of [['A', tickA], ['B', tickB]] as const) {
|
for (const y of [ends.startY, ends.endY]) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(x - 13, y);
|
ctx.moveTo(x - 13, y);
|
||||||
ctx.lineTo(x + 13, y);
|
ctx.lineTo(x + 13, y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.fillStyle = focus === id ? '#e23' : '#000';
|
ctx.fillStyle = '#000';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
ctx.arc(x, y, 5, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
@@ -121,10 +115,8 @@
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
function reseat(pct: number) {
|
function reseat() {
|
||||||
const ends = cropEnds(pct);
|
// The line's ends follow crop_fill; nothing about them is owner-draggable.
|
||||||
tickA = ends.startY;
|
|
||||||
tickB = ends.endY;
|
|
||||||
publish();
|
publish();
|
||||||
draw();
|
draw();
|
||||||
}
|
}
|
||||||
@@ -167,14 +159,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const x = frame.width / 2;
|
const x = frame.width / 2;
|
||||||
const hit = hitRadiusStage(renderedW);
|
if (Math.hypot(p[0] - x, p[1] - (frame.height - 28)) <= 22) {
|
||||||
if (Math.hypot(p[0] - x, p[1] - tickA) <= hit) {
|
|
||||||
focus = 'A';
|
|
||||||
drag = 'tick';
|
|
||||||
} else if (Math.hypot(p[0] - x, p[1] - tickB) <= hit) {
|
|
||||||
focus = 'B';
|
|
||||||
drag = 'tick';
|
|
||||||
} else if (Math.hypot(p[0] - x, p[1] - (frame.height - 28)) <= 22) {
|
|
||||||
drag = 'tilt';
|
drag = 'tilt';
|
||||||
tilt0 = transform.tiltDeg;
|
tilt0 = transform.tiltDeg;
|
||||||
tiltX0 = p[0];
|
tiltX0 = p[0];
|
||||||
@@ -199,11 +184,7 @@
|
|||||||
zoomAt(mid, clamp(dist / pinch0, 0.8, 1.25));
|
zoomAt(mid, clamp(dist / pinch0, 0.8, 1.25));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (drag === 'tick') {
|
if (drag === 'tilt') {
|
||||||
const y = clamp(p[1], 8, frame.height - 8);
|
|
||||||
if (focus === 'A') tickA = clamp(y, 8, tickB - 24);
|
|
||||||
else tickB = clamp(y, tickA + 24, frame.height - 8);
|
|
||||||
} else if (drag === 'tilt') {
|
|
||||||
transform = { ...transform, tiltDeg: tilt0 + (p[0] - tiltX0) * 0.6 };
|
transform = { ...transform, tiltDeg: tilt0 + (p[0] - tiltX0) * 0.6 };
|
||||||
} else if (drag === 'pan') {
|
} else if (drag === 'pan') {
|
||||||
transform = {
|
transform = {
|
||||||
@@ -254,20 +235,13 @@
|
|||||||
publish();
|
publish();
|
||||||
draw();
|
draw();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
||||||
const step = (event.shiftKey ? 0.1 : 1) * (event.key === 'ArrowUp' ? -1 : 1);
|
|
||||||
if (focus === 'A') tickA = clamp(tickA + step, 8, tickB - 24);
|
|
||||||
else tickB = clamp(tickB + step, tickA + 24, frame.height - 8);
|
|
||||||
publish();
|
|
||||||
draw();
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFill(event: Event) {
|
function onFill(event: Event) {
|
||||||
fill = Number((event.currentTarget as HTMLInputElement).value);
|
fill = Number((event.currentTarget as HTMLInputElement).value);
|
||||||
setCropFill(fill);
|
setCropFill(fill);
|
||||||
reseat(fill);
|
reseat();
|
||||||
}
|
}
|
||||||
|
|
||||||
const positiveInches = (raw: string): number | null => {
|
const positiveInches = (raw: string): number | null => {
|
||||||
@@ -325,7 +299,7 @@
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Length</h1>
|
<h1>Length</h1>
|
||||||
<p class="hint">Move the slab until its long axis sits on the line. Drag the ticks to the tape ends. The line does not move.</p>
|
<p class="hint">Move the slab until its long axis sits on the line, the way it lines up with the tape. The line is fixed in the middle; its ends are set by the crop slider and do not move.</p>
|
||||||
<div class="stage" bind:this={stageEl} data-testid="tape-line">
|
<div class="stage" bind:this={stageEl} data-testid="tape-line">
|
||||||
<canvas
|
<canvas
|
||||||
bind:this={canvas}
|
bind:this={canvas}
|
||||||
|
|||||||
@@ -267,8 +267,6 @@ test('#62: calibration tape tilt is not copied to sibling photos (#99)', async (
|
|||||||
s.back();
|
s.back();
|
||||||
s.setTapePose({
|
s.setTapePose({
|
||||||
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
|
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
|
||||||
tickStartY: 32,
|
|
||||||
tickEndY: 608,
|
|
||||||
sourceW: 1000,
|
sourceW: 1000,
|
||||||
sourceH: 900,
|
sourceH: 900,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -429,8 +429,6 @@ test('calculateFromAxis passes the tape tilt to the calibration PNG and overlay
|
|||||||
s.approveMask();
|
s.approveMask();
|
||||||
s.setTapePose({
|
s.setTapePose({
|
||||||
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
|
transform: { tiltDeg: 20, zoom: 1, panX: 0, panY: 0 },
|
||||||
tickStartY: 32,
|
|
||||||
tickEndY: 608,
|
|
||||||
sourceW: MASK_W,
|
sourceW: MASK_W,
|
||||||
sourceH: MASK_H,
|
sourceH: MASK_H,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ export interface CaptureSession {
|
|||||||
approveMask(): void;
|
approveMask(): void;
|
||||||
setTapePose(pose: {
|
setTapePose(pose: {
|
||||||
transform: PhotoTransform;
|
transform: PhotoTransform;
|
||||||
tickStartY: number;
|
|
||||||
tickEndY: number;
|
|
||||||
sourceW: number;
|
sourceW: number;
|
||||||
sourceH: number;
|
sourceH: number;
|
||||||
}): void;
|
}): void;
|
||||||
@@ -107,16 +105,18 @@ export function createCaptureSession(opts: SessionOpts = {}): CaptureSession {
|
|||||||
let cropFill = 90;
|
let cropFill = 90;
|
||||||
let poseTimer: ReturnType<typeof setTimeout> | null = null;
|
let poseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// The tape line is a stage fixture: the owner only moves the slab. Its ends are
|
||||||
|
// the crop insets, so a pose carries the photo transform and nothing else (#99).
|
||||||
const axisFrom = (
|
const axisFrom = (
|
||||||
bbox: Axis['bbox'],
|
bbox: Axis['bbox'],
|
||||||
sourceW: number,
|
sourceW: number,
|
||||||
sourceH: number,
|
sourceH: number,
|
||||||
pose?: { transform?: PhotoTransform; tickStartY?: number; tickEndY?: number; tapeStart?: [number, number]; tapeEnd?: [number, number] },
|
pose?: { transform?: PhotoTransform; tapeStart?: [number, number]; tapeEnd?: [number, number] },
|
||||||
): Axis => {
|
): Axis => {
|
||||||
const ends = cropEnds(cropFill);
|
const ends = cropEnds(cropFill);
|
||||||
const transform = pose?.transform ?? { ...IDENTITY_TRANSFORM };
|
const transform = pose?.transform ?? { ...IDENTITY_TRANSFORM };
|
||||||
const tickStartY = pose?.tickStartY ?? ends.startY;
|
const tickStartY = ends.startY;
|
||||||
const tickEndY = pose?.tickEndY ?? ends.endY;
|
const tickEndY = ends.endY;
|
||||||
const w = sourceW > 0 ? sourceW : 1;
|
const w = sourceW > 0 ? sourceW : 1;
|
||||||
const h = sourceH > 0 ? sourceH : 1;
|
const h = sourceH > 0 ? sourceH : 1;
|
||||||
const seg = pose?.tapeStart && pose?.tapeEnd
|
const seg = pose?.tapeStart && pose?.tapeEnd
|
||||||
|
|||||||
@@ -52,6 +52,32 @@ test('scale is source-pixel tick distance divided by the declared length', () =>
|
|||||||
assert.equal(calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 0), 0);
|
assert.equal(calibrationScale(32, 608, 480, 640, IDENTITY_TRANSFORM, 0), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tick ends are fixed by the crop slider and are never an owner input (#99)', () => {
|
||||||
|
// The line is a stage fixture: its two ends are the crop insets, set by
|
||||||
|
// crop_fill alone. There is no owner drag that can move them.
|
||||||
|
for (const fill of [1, 25, 50, 75, 90, 100]) {
|
||||||
|
const ends = cropEnds(fill);
|
||||||
|
assert.equal(ends.startY, cropEnds(fill).startY, 'ends are a pure function of fill');
|
||||||
|
assert.equal(ends.endY, cropEnds(fill).endY, 'ends are a pure function of fill');
|
||||||
|
assert.ok(ends.endY > ends.startY, 'line keeps a positive span at any fill');
|
||||||
|
}
|
||||||
|
// Ends stay symmetric about the frame centre, so the line is fixed in the middle.
|
||||||
|
for (const fill of [1, 50, 90, 100]) {
|
||||||
|
const ends = cropEnds(fill);
|
||||||
|
const top = ends.startY;
|
||||||
|
const bottom = CALIBRATION_FRAME.height - ends.endY;
|
||||||
|
assert.equal(top, bottom, `fill ${fill}: top and bottom insets match`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the scale span is the fixed line span, not a mask span', () => {
|
||||||
|
const ends = cropEnds(90);
|
||||||
|
const distance = sourceTickDistance(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM);
|
||||||
|
const scale = calibrationScale(ends.startY, ends.endY, 480, 640, IDENTITY_TRANSFORM, 72);
|
||||||
|
assert.ok(distance > 0, 'line has a real source-pixel span');
|
||||||
|
assert.equal(scale, distance / 72, 'scale is the line span over the tape reading');
|
||||||
|
});
|
||||||
|
|
||||||
test('the tick hit target is at least 44px on a 320px-wide stage', () => {
|
test('the tick hit target is at least 44px on a 320px-wide stage', () => {
|
||||||
const radius = hitRadiusStage(320);
|
const radius = hitRadiusStage(320);
|
||||||
const screen = radius * (320 / CALIBRATION_FRAME.width);
|
const screen = radius * (320 / CALIBRATION_FRAME.width);
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ Fixtures: `tests/fixtures/set1-48x2/` (L=48, thickness=2) and `set2-31x2p5/` (L=
|
|||||||
| `04-axis.png` | Tape calibration page |
|
| `04-axis.png` | Tape calibration page |
|
||||||
| `05-tape-calibration.png` | Length and thickness entered on the tape page |
|
| `05-tape-calibration.png` | Length and thickness entered on the tape page |
|
||||||
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
| `06-photos-ready.png` | Photos ready lineup (clean + overlay + siblings) |
|
||||||
|
| `tape-fixed-line.png` | #99 fixed tape line: no tick-drag copy, ticks follow the crop slider |
|
||||||
|
| `tape-fixed-line-crop50.png` | #99 fixed tape line at 50% crop fill (line ends pulled in) |
|
||||||
|
|
||||||
|
`tape-fixed-line.spec.ts` asserts the #99 contract: the line is a stage fixture,
|
||||||
|
its ends are a pure function of `crop_fill`, a drag starting on a tick behaves
|
||||||
|
exactly like a pan elsewhere, and arrow keys cannot move the ends.
|
||||||
|
|
||||||
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
Large regenerating `*.png` and Playwright `test-results/` / `playwright-report/` are gitignored.
|
||||||
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
|
Committed small evidence JPEGs live in `evidence/` (regenerate with a green `npm run test:e2e`, then re-encode if needed).
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const FACE_A = path.resolve(here, '../fixtures/set1-48x2/face-a.jpeg');
|
||||||
|
const ARTIFACTS = path.resolve(here, 'artifacts');
|
||||||
|
|
||||||
|
const shot = async (page: Page, name: string) => {
|
||||||
|
fs.mkdirSync(ARTIFACTS, { recursive: true });
|
||||||
|
await page.screenshot({ path: path.join(ARTIFACTS, name), fullPage: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureFreshBatch = async (page: Page) => {
|
||||||
|
await page.goto('/');
|
||||||
|
const resumeHeading = page.getByRole('heading', { name: /resume/i });
|
||||||
|
const batchHeading = page.getByRole('heading', { name: 'Batch photos' });
|
||||||
|
await expect(resumeHeading.or(batchHeading)).toBeVisible({ timeout: 15_000 });
|
||||||
|
if (await resumeHeading.isVisible().catch(() => false)) {
|
||||||
|
await page.getByRole('button', { name: /start fresh/i }).click();
|
||||||
|
}
|
||||||
|
await expect(batchHeading).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareMaskForApprove = async (page: Page) => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Mask' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Black', exact: true }).click();
|
||||||
|
const approve = page.getByRole('button', { name: 'Approve mask' });
|
||||||
|
try {
|
||||||
|
await expect(approve).toBeEnabled({ timeout: 45_000 });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* sensitivity ladder */
|
||||||
|
}
|
||||||
|
for (const target of [60, 70, 40, 30, 80]) {
|
||||||
|
await page.locator('input[type="range"]').fill(String(target));
|
||||||
|
await page.waitForTimeout(2800);
|
||||||
|
if (await approve.isEnabled()) return;
|
||||||
|
}
|
||||||
|
await expect(approve).toBeEnabled({ timeout: 15_000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toLengthPage = async (page: Page) => {
|
||||||
|
await ensureFreshBatch(page);
|
||||||
|
await page.locator('input[type="file"][multiple]').setInputFiles([FACE_A]);
|
||||||
|
await expect(page.getByAltText('Batch photo 1')).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Use photo 1 as calibration' }).click();
|
||||||
|
await prepareMaskForApprove(page);
|
||||||
|
await page.getByRole('button', { name: 'Approve mask' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Length' })).toBeVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('#99 fixed tape line', () => {
|
||||||
|
test('the line is not editable: no tick-drag copy and no tick drag', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
expect(fs.existsSync(FACE_A), 'fixture face-a').toBeTruthy();
|
||||||
|
await toLengthPage(page);
|
||||||
|
|
||||||
|
// The hint must never tell the owner to drag the ticks.
|
||||||
|
await expect(page.getByText('Drag the ticks')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('The line is fixed in the middle')).toBeVisible();
|
||||||
|
|
||||||
|
const stage = page.getByTestId('tape-line');
|
||||||
|
await expect(stage).toBeVisible();
|
||||||
|
const box = (await stage.boundingBox())!;
|
||||||
|
const canvas = stage.locator('canvas').first();
|
||||||
|
|
||||||
|
// A pan delta produces the same result wherever you grab the stage, because
|
||||||
|
// pan is measured as a delta from the grab point. So: drag from a tick
|
||||||
|
// position, then repeat the same delta from a plain part of the stage. If
|
||||||
|
// the tick drag were still live it would move the line too, and the two
|
||||||
|
// results would differ. Identical output ⇒ the line is fixed.
|
||||||
|
const centerX = box.x + box.width / 2;
|
||||||
|
const tickY = box.y + box.height * 0.05;
|
||||||
|
const plainY = box.y + box.height * 0.62;
|
||||||
|
const delta = box.height * 0.18;
|
||||||
|
|
||||||
|
const dragBy = async (from: number) => {
|
||||||
|
await page.mouse.move(centerX, from);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(centerX, from + delta, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
};
|
||||||
|
|
||||||
|
await dragBy(tickY);
|
||||||
|
const fromTick = await canvas.screenshot();
|
||||||
|
|
||||||
|
// Reset the transform, then repeat the identical delta from a non-tick point.
|
||||||
|
await toLengthPage(page);
|
||||||
|
await dragBy(plainY);
|
||||||
|
const fromPlain = await canvas.screenshot();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Buffer.compare(fromTick, fromPlain),
|
||||||
|
'a tick drag must behave exactly like a pan elsewhere (line stays fixed)',
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
// Length and thickness live on this same screen; Calculate stays usable.
|
||||||
|
await page.getByLabel('Length L').fill('48');
|
||||||
|
await page.getByLabel('Thickness').fill('2');
|
||||||
|
await expect(page.getByRole('button', { name: 'Calculate' })).toBeEnabled();
|
||||||
|
await shot(page, 'tape-fixed-line.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the crop slider sets the line ends and nothing else does', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
await toLengthPage(page);
|
||||||
|
|
||||||
|
const canvas = page.getByTestId('tape-line').locator('canvas').first();
|
||||||
|
const before = await canvas.screenshot();
|
||||||
|
|
||||||
|
// Move the crop-fill slider to a different value.
|
||||||
|
const fill = page.getByLabel('Crop fill percent');
|
||||||
|
await fill.fill('50');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const after = await canvas.screenshot();
|
||||||
|
expect(Buffer.compare(before, after), 'crop fill must move the line ends').not.toBe(0);
|
||||||
|
|
||||||
|
// Arrow-key nudge on the line must not change the geometry either.
|
||||||
|
// Blur the slider first: arrow keys on a focused range input legitimately
|
||||||
|
// change that input, which would move the line ends for the wrong reason.
|
||||||
|
await page.locator('h1').click();
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
await page.keyboard.press('ArrowUp');
|
||||||
|
await page.keyboard.press('ArrowDown');
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const afterKeys = await canvas.screenshot();
|
||||||
|
expect(
|
||||||
|
Buffer.compare(after, afterKeys),
|
||||||
|
'arrow keys must not move the fixed line ends',
|
||||||
|
).toBe(0);
|
||||||
|
await shot(page, 'tape-fixed-line-crop50.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user