import { test, expect } from '@playwright/test' // AZ-473 — e2e companion for FT-P-50 (yoloId on the wire). // // Task spec marks FT-P-48 / FT-P-49 fast-only. Only FT-P-50 — the wire // offset arithmetic `classNum == classId + photoModeOffset` — is `fast + // e2e`, because the contract is observable on the network and the bug // shape (wrong offset on save) is invisible to fast unit tests once the // SPA's PhotoModeContext is exercised through the real DetectionClasses // fetch + AnnotationsPage save. // // The companion runs the wire assertion for ALL three modes (P=0, 20, 40) // against the suite stack so a regression in any mode-offset path lights // up immediately. const ALICE_EMAIL = 'op_alice@test.local' const ALICE_PASSWORD = 'TestPassword!23' async function login(page: import('@playwright/test').Page): Promise { await page.goto('/login') await page.getByLabel(/email/i).fill(ALICE_EMAIL) await page.getByLabel(/password/i).fill(ALICE_PASSWORD) await Promise.all([ page.waitForResponse( (r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST', ), page.getByRole('button', { name: /sign in/i }).click(), ]) } async function selectPhotoMode( page: import('@playwright/test').Page, modeOffset: number, ): Promise { // PhotoMode UI surfaces the three offsets as toggle buttons (mode 0, // 20, 40). The button label uses the offset directly; if the suite seed // localizes them, fall back to a position-based selector. const byLabel = page.getByRole('button', { name: new RegExp(`mode\\s*${modeOffset}`, 'i') }) if (await byLabel.first().isVisible({ timeout: 2000 }).catch(() => false)) { await byLabel.first().click() return } // Fallback — the buttons render in a fixed order [0, 20, 40]. const idx = modeOffset === 0 ? 0 : modeOffset === 20 ? 1 : 2 await page.locator('[data-testid="photo-mode-button"]').nth(idx).click() } async function drawAndSave( page: import('@playwright/test').Page, ): Promise { const canvas = page.locator('canvas').first() const box = await canvas.boundingBox() if (!box) throw new Error('canvas missing bounding box') // Draw a small bbox so the page has something to save. await page.mouse.move(box.x + box.width * 0.4, box.y + box.height * 0.4) await page.mouse.down() await page.mouse.move(box.x + box.width * 0.6, box.y + box.height * 0.6, { steps: 8 }) await page.mouse.up() const saveBtn = page.getByRole('button', { name: /^Save$/i }).first() if (await saveBtn.isVisible({ timeout: 1000 }).catch(() => false)) { await saveBtn.click().catch(() => null) } } test.describe('AZ-473 — yoloId on the wire (e2e companion)', () => { for (const modeOffset of [0, 20, 40]) { test(`FT-P-50 — mode ${modeOffset}: classNum == classId + ${modeOffset}`, async ({ page, browserName, }) => { test.skip( browserName !== 'chromium', 'Pointer-event canvas drawing reliable on Chromium only; the wire contract is the same on every browser', ) test.setTimeout(30_000) const captured: Array> = [] await page.route('**/api/annotations/annotations**', async (route) => { const req = route.request() if (req.method() === 'POST') { const body = req.postData() if (body) captured.push(JSON.parse(body) as Record) } await route.continue() }) await login(page) await page.goto('/annotations') const canvas = page.locator('canvas').first() if (!(await canvas.isVisible({ timeout: 5000 }).catch(() => false))) { test.skip(true, 'Suite seed has no media for annotation') } await selectPhotoMode(page, modeOffset).catch(() => null) // Pick the first valid class for this mode. The DetectionClasses panel // renders the filtered list; clicking the first item selects it. const firstClass = page.locator('[data-testid^="class-row"], button[data-testid*="class"]').first() if (await firstClass.isVisible({ timeout: 2000 }).catch(() => false)) { await firstClass.click().catch(() => null) } await drawAndSave(page) await page.waitForTimeout(750) expect(captured.length, 'expected at least one save POST').toBeGreaterThan(0) type Detection = { classNum?: number; classId?: number } const lastBody = captured[captured.length - 1] const detections = (lastBody.detections as Detection[] | undefined) ?? [] expect(detections.length, 'last save must include the drawn detection').toBeGreaterThan(0) for (const d of detections) { // Wire contract per AC-3: classNum == classId + photoModeOffset. // Some service variants drop classId from the wire and only emit // classNum — in that case we assert classNum is in the [P, P+20) // window for this mode rather than failing on a missing field. if (typeof d.classId === 'number' && typeof d.classNum === 'number') { expect(d.classNum).toBe(d.classId + modeOffset) } else if (typeof d.classNum === 'number') { expect(d.classNum).toBeGreaterThanOrEqual(modeOffset) expect(d.classNum).toBeLessThan(modeOffset + 20) } else { throw new Error('Detection has neither classNum nor classId — wire contract broken') } } }) } })