From 938c161a2622f0f0fc19c2e4b46e347ae247a300 Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:21:23 -0500 Subject: [PATCH] feat(scanner): add desktop workstation layout (#165) Give /scanner a md+ camera, live match inspector, and history strip (with device picker, batch scan, and tips) without regressing the mobile immersive checkout. Co-authored-by: Cursor --- .convoys/.metrics.jsonl | 15 + .convoys/scanner-desktop-layout.md | 889 ++++++++++++++++++ .../audits/a11y-20260815.md | 147 +++ .../audits/design-system-20260815.md | 153 +++ .../audits/reviewer-20260815.md | 21 + .../audits/security-20260815.md | 30 + .../brief-1-camera-device-picker.md | 90 ++ .../brief-2-scanner-tips.md | 79 ++ .../brief-3-result-inspector.md | 78 ++ .../brief-4-history-strip.md | 105 +++ .../brief-5-batch-identify-helper.md | 92 ++ .../brief-6-page-workstation-wiring.md | 171 ++++ components/scanner/ScannerCamera.js | 177 +++- components/scanner/ScannerHistoryStrip.js | 343 +++++++ components/scanner/ScannerResultPanel.js | 323 +++++++ components/scanner/ScannerTips.js | 64 ++ lib/scanner-batch-identify.js | 46 + lib/use-camera-scanner.js | 130 ++- lib/use-scanner-identification.js | 13 +- lib/use-scanner-queue.js | 8 +- pages/scanner.js | 510 +++++++++- test/components/ScannerCamera.test.js | 94 ++ test/components/ScannerHistoryStrip.test.js | 206 ++++ test/components/ScannerResultPanel.test.js | 131 +++ test/components/ScannerTips.test.js | 60 ++ test/lib/scanner-batch-identify.test.js | 122 +++ test/lib/use-camera-scanner.test.js | 264 ++++++ test/pages/scanner.test.js | 184 ++++ 28 files changed, 4437 insertions(+), 108 deletions(-) create mode 100644 .convoys/scanner-desktop-layout.md create mode 100644 .convoys/scanner-desktop-layout/audits/a11y-20260815.md create mode 100644 .convoys/scanner-desktop-layout/audits/design-system-20260815.md create mode 100644 .convoys/scanner-desktop-layout/audits/reviewer-20260815.md create mode 100644 .convoys/scanner-desktop-layout/audits/security-20260815.md create mode 100644 .convoys/scanner-desktop-layout/brief-1-camera-device-picker.md create mode 100644 .convoys/scanner-desktop-layout/brief-2-scanner-tips.md create mode 100644 .convoys/scanner-desktop-layout/brief-3-result-inspector.md create mode 100644 .convoys/scanner-desktop-layout/brief-4-history-strip.md create mode 100644 .convoys/scanner-desktop-layout/brief-5-batch-identify-helper.md create mode 100644 .convoys/scanner-desktop-layout/brief-6-page-workstation-wiring.md create mode 100644 components/scanner/ScannerHistoryStrip.js create mode 100644 components/scanner/ScannerResultPanel.js create mode 100644 components/scanner/ScannerTips.js create mode 100644 lib/scanner-batch-identify.js create mode 100644 test/components/ScannerCamera.test.js create mode 100644 test/components/ScannerHistoryStrip.test.js create mode 100644 test/components/ScannerResultPanel.test.js create mode 100644 test/components/ScannerTips.test.js create mode 100644 test/lib/scanner-batch-identify.test.js create mode 100644 test/lib/use-camera-scanner.test.js create mode 100644 test/pages/scanner.test.js diff --git a/.convoys/.metrics.jsonl b/.convoys/.metrics.jsonl index ca6d32e..1eacda8 100644 --- a/.convoys/.metrics.jsonl +++ b/.convoys/.metrics.jsonl @@ -116,3 +116,18 @@ {"ts": "2026-08-15T21:42:20Z", "role": "role-ux-reviewer", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 90, "model": "composer-2.5-fast", "model_tier": "fast"} {"ts": "2026-08-15T21:42:20Z", "role": "role-architect", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 180, "model": "composer-2.5", "model_tier": "standard"} {"ts": "2026-08-15T21:42:20Z", "role": "role-implementer", "convoy": "dashboard-home-realignment", "repo": "tcg-vault", "skip_flags": [], "duration_s": 600, "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:44:54Z", "role": "role-conductor", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "classification": "feature", "duration_s": 900, "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:48:07Z", "role": "role-ia-architect", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 120, "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:49:34Z", "role": "role-ui-designer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 300, "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:50:19Z", "role": "role-ux-reviewer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 180, "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:51:58Z", "role": "role-architect", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 720, "model": "composer-2.5", "model_tier": "standard"} +{"ts": "2026-08-15T21:53:14Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 5, "duration_s": 120, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:53:18Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 2, "duration_s": 120, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:53:18Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 1, "duration_s": 420, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:53:19Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 3, "duration_s": 420, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:53:28Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 4, "duration_s": 420, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:55:19Z", "role": "role-implementer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "brief": 6, "duration_s": 900, "outcome": "complete", "model": "composer-2.5-fast", "model_tier": "fast"} +{"ts": "2026-08-15T21:56:43Z", "role": "role-reviewer", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 58, "multitask_group": "audit-scanner-desktop-layout-local", "model": "cursor-grok-4.5-high", "model_tier": "fast"} +{"ts": "2026-08-15T21:56:53Z", "role": "role-security-auditor", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 300, "multitask_group": "audit-scanner-desktop-layout-local", "model": "gpt-5.6-terra-medium", "model_tier": "fast"} +{"ts": "2026-08-15T21:57:11Z", "role": "role-design-system-auditor", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 165, "multitask_group": "audit-scanner-desktop-layout-local", "model": "cursor-grok-4.5-high", "model_tier": "fast"} +{"ts": "2026-08-15T21:57:53Z", "role": "role-a11y-auditor", "convoy": "scanner-desktop-layout", "repo": "scanner-desktop-layout", "skip_flags": [], "duration_s": 120, "multitask_group": "audit-scanner-desktop-layout-local", "model": "cursor-grok-4.5-high", "model_tier": "fast"} diff --git a/.convoys/scanner-desktop-layout.md b/.convoys/scanner-desktop-layout.md new file mode 100644 index 0000000..6735dac --- /dev/null +++ b/.convoys/scanner-desktop-layout.md @@ -0,0 +1,889 @@ +--- +name: scanner-desktop-layout +classification: feature +success_metric: | + On md+ viewports, /scanner keeps the desktop app chrome (sidebar + + top bar), shows a framed camera workstation with a real webcam + device picker, Upload Image, Batch Scan, Auto-detect, Scanner + Tips, a live match inspector, and a bottom strip with Recent + Scans / Scan Queue / Duplicates — without regressing the mobile + immersive checkout. +skip: [] +status: open +created: 2026-08-15 +depends_on: + - scanner-mobile-checkout + - scanner-rebuild +model_policy: + default_session: auto + roles: + role-conductor: composer-2.5-fast + role-architect: composer-2.5 + role-ia-architect: composer-2.5-fast + role-ux-reviewer: composer-2.5-fast + role-ui-designer: composer-2.5-fast + role-implementer: composer-2.5-fast + role-reviewer: cursor-grok-4.5-high + role-security-auditor: gpt-5.6-terra-medium + role-design-system-auditor: cursor-grok-4.5-high + role-a11y-auditor: cursor-grok-4.5-high + role-doc-writer: auto + escalate_to: claude-sonnet-5-thinking-medium + escalate_to_premium: claude-4.6-opus-high-thinking + never_premium: + - role-reviewer + - role-security-auditor + - role-design-system-auditor + - role-a11y-auditor + - role-ui-designer + - role-doc-writer +design_direction: + source: role-ui-designer + skill: ui-ux-pro-max + skill_version: "2.5.0" + version: 1 + locked_at: 2026-08-15 + product_type: desktop trading card scanner workstation + pattern: Feature-Rich Showcase (workstation variant) + style: Liquid Glass / glassmorphism + stack: nextjs + layout_reference: image-1eeebe23-9983-4a96-a3e7-4e3cdfdceb5b.png +--- + +# Convoy: scanner-desktop-layout + +Give `/scanner` a dedicated desktop workstation layout from the +attached dark/light mock (camera + live result + history strip), +while leaving the shipped mobile immersive checkout alone. + +Worktree: `tcg-vault-worktrees/scanner-desktop-layout` on +`convoy/scanner-desktop-layout` (branched from `origin/main` @ +`0d52858`). Layout reference: +`image-1eeebe23-9983-4a96-a3e7-4e3cdfdceb5b.png`. + +Do **not** land this on `dashboard-home-realignment` — that convoy +owns sidebar IA + top-bar chrome. Do **not** land this on +`scanner-identify-upgrade` — that epic owns detect/identify accuracy. + +## Why + +`scanner-mobile-checkout` shipped the phone job: full-bleed camera, +local cart, checkout sheet. Desktop (`md+`) got the leftover +composition — the same camera chrome plus a 360px cart side panel +(`ScannerReview` `variant="side-panel"`). That is not a desk +workstation. + +On a laptop the user wants to see the webcam, inspect the current +match (set, rarity, number, condition, foil, confidence), decide +what to do with it, and keep a history/queue in view — without +losing the app sidebar or search bar. The mock is that layout. +Today they get a phone overlay stretched into a column. + +## Scope + +### In scope + +- **Desktop-only composition (`md+`).** Keep Layout sidebar + + TopSearchBar. Stop treating desktop as an immersive camera page + with a bolted-on cart. Mobile (`max-md`) stays + `chrome="immersive"` + checkout sheet. +- **Framed camera viewport.** Large live feed with ember corner + brackets (already in `ScannerCamera`), Auto-detect status, and + desk controls under the frame. Camera is a panel in the page, + not a full-bleed overlay. +- **Real webcam device picker.** `enumerateDevices` + `deviceId` + in `useCameraScanner` (not facing-mode swap relabeled). Persist + the last-used device for the tab if cheap. Empty-list / denied- + permission fallback. Mobile keeps the existing facing-mode + toggle — do not replace the phone chrome with a device ``, Batch + Scan progress, and the three-tab strip. Do **not** skip + `ui-design`. +3. `role-ux-reviewer` — inspect-then-add vs scan-all-then-checkout + on desktop; Auto-detect off; Rescan; empty inspector; leave + with an uncommitted queue; sequential batch cancel; duplicate + tab actions (increment qty vs skip vs still add). +4. `role-architect` — briefs. Likely: (1) page composition + + Layout chrome split, (2) camera panel + `deviceId` picker, + (3) result inspector, (4) history/queue/duplicates strip, + (5) batch multi-file identify + Tips. `slice_dependencies` + must mark what can run in parallel. +5. `role-implementer` — per brief. +6. Audit fan-out: reviewer + security-auditor + design-system-auditor + + a11y-auditor. + +## Todos + +- [x] IA: desktop screen inventory + inspector-add vs cart-commit; + Duplicates membership; Batch Scan + Tips content +- [x] UI Designer: lock md+ workstation (tokens, not hex) from + the attached mock; light + dark; Tips, device picker, + batch progress, three-tab strip +- [x] UX: Auto-detect off, Rescan, empty state, leave-with-queue, + keyboard on desk controls, batch cancel, duplicate actions +- [x] Architect: briefs + `slice_dependencies`; confirm Layout + is `chrome="default"` on md+ only +- [ ] Desktop composition in `pages/scanner.js` (do not hide + sidebar / top bar at md+) +- [ ] Camera as a framed panel; `deviceId` picker + Upload + + Batch Scan + Auto-detect; hide mobile overlay chrome at md+ +- [ ] Live match inspector wired to the latest unprocessed + identify (condition / foil already on the cart entry) +- [ ] Bottom strip: Recent Scans + Scan Queue + Duplicates over + `useScannerQueue` / `ownershipMap` / `scanner-session` +- [ ] Scanner Tips popover/modal with convoy-authored copy +- [ ] Sequential multi-file Batch Scan through + `identifyFromGalleryFile` (cancellable, queue progress) +- [ ] Tests for desktop composition (inspector + queue + + duplicates + batch enqueue, no mobile sheet) and + no-regression on checkout sheet at `max-width: 767px` +- [ ] Visual-diff: desktop `/scanner` surface; refresh Linux + baselines if the page is in the visual suite + +## What exists today (conductor survey) + +`/scanner` is one route, two compositions, one engine. + +| Layer | Files | Today | +| --- | --- | --- | +| Page | `pages/scanner.js` | Auth gate; `Layout chrome="immersive"` on **all** viewports; camera column + `md:` 360px `ScannerReview` cart; mobile-only `ScannerCheckoutSheet` | +| Layout | `components/Layout.js` | Immersive hides **mobile** nav + top bar (`max-md` only). Desktop sidebar + TopSearchBar already stay visible. | +| Camera chrome | `components/scanner/ScannerCamera.js` | Full-bleed video, overlay top bar (back / title / gallery), bottom bar (flash / facing / status / Review N), scan peek, disambiguation. Same chrome on desktop. | +| Cart | `lib/use-scanner-queue.js`, `lib/scanner-session.js` | Identify enqueues locally (`processed: false`). Commit via `commitSelectedToOwned` / `commitSelectedToCollection`. `sessionStorage` persist. `addSingleCardToOwned` already exists. | +| Identify | `lib/use-scanner-identification.js`, `lib/use-camera-scanner.js` | Facing-mode swap only — **no** `deviceId` / `enumerateDevices`. Auto-detect is always on unless `verificationPausedRef` (checkout / list picker / disambiguation). | +| Review | `components/scanner/ScannerReview.js` | Thin wrapper: desktop side panel titled "Cart" that mounts `ScannerCheckoutContent`. | + +Mobile checkout decisions that still apply unless IA overturns them +for desktop only: stay on camera after commit (D1), skip Setup +(D3), My Collection + List only (D4), gallery in-scope (D5), +cart in `sessionStorage` (D7). + +## Conductor notes (build shape) + +Likely file ownership for Architect to refine: + +| Area | Files | +| --- | --- | +| Viewport split | `pages/scanner.js` — `chrome` default on md+, immersive on mobile; desktop grid vs mobile overlay | +| Camera panel | `components/scanner/ScannerCamera.js` (desktop variant or `variant="workstation"`), `lib/use-camera-scanner.js` (`enumerateDevices` + `deviceId`) | +| Inspector | new `components/scanner/ScannerResultPanel.js` — latest cart entry + condition/foil + add/rescan | +| History strip | new `components/scanner/ScannerHistoryStrip.js` — Recent / Queue / Duplicates over `queue.scannedCards` + `ownershipMap` | +| Batch Scan | `lib/use-scanner-identification.js` (`identifyFromGalleryFile` loop), queue progress UI | +| Tips | new `components/scanner/ScannerTips.js` — `` or popover, convoy copy | +| Cart reuse | `lib/use-scanner-queue.js`, `lib/scanner-session.js`, `ScannerCheckoutSheet.js` (mobile only) | +| Copy | `lib/collection-vocabulary.js` | + +Do not rewrite identification. Prefer a desktop layout shell that +**hides** mobile overlay chrome at `md+` rather than forking the +camera hook. + +## Decisions (post-conductor) + +Locked 2026-08-15 from the parent session. IA / UX / Architect +treat these as settled. + +| # | Decision | +| --- | --- | +| C1 | **Batch Scan is in.** Multi-file sequential identify via the existing gallery path. No new batch API, no parallel Gemini, no new pile detector. | +| C2 | **Duplicates tab is in.** Strip tab with a badge. Membership = already-owned (`ownershipMap`) and/or same-session name+set repeats. IA picks the exact rule and tab actions. | +| C3 | **Scanner Tips is in.** Header control → glass popover/modal. Copy in this convoy. | +| C4 | **Real webcam device picker is in.** `enumerateDevices` + `deviceId` on desktop. Mobile keeps facing-mode swap. | +| C5 | **Inspector can commit this card now** *and* the queue strip remains for multi-add (same cart, two commit surfaces). Overturn only if IA finds a conflict. | + +## Open questions (IA / product) + +1. **Duplicates membership + actions.** Owned-in-collection only, + session repeats only, or both? From the tab, can the user still + add (increment qty), skip, or jump the inspector to that row? +2. **Wishlist.** Out as a feature. Confirm "Save to Wishlist" → + Add to List on the inspector, or omit the third action. +3. **Auto-detect toggle.** User-facing pause of identification + (extend `verificationPausedRef`), or just a status badge? +4. **Batch Scan cancel / errors.** Mid-batch cancel: keep already- + identified rows? Per-file failure: continue the rest and flag + the row, or stop? +5. **Tips content.** Four or five short tips (lighting, frame the + card, foil glare, hold still, auto-detect). IA drafts; UI + Designer locks the surface. + +## Multitask dispatch + +Planning is serial: IA → UI Designer → UX → Architect. + +After architect: implementer fan-out only if briefs have +`depends_on: []` and disjoint `files:`. Device picker +(`use-camera-scanner.js`) and Tips (`ScannerTips.js`) are the +best candidates to parallelize with the inspector if they do +not both own `pages/scanner.js`. Page composition likely +blocks the strip and Batch Scan wiring. + +After PR draft: `/multitask` audit fan-out +`role-reviewer + role-security-auditor + role-design-system-auditor + role-a11y-auditor` +(group id: `audit-scanner-desktop-layout-`). + +## IA + +### Affected routes + +- `/scanner` — **[modified]** Single route, two viewport compositions. Desktop (`md+`) switches to `Layout chrome="default"` (sidebar + TopSearchBar visible), framed camera workstation, live match inspector (right rail), and bottom history strip. Mobile (`max-md`) stays `chrome="immersive"` with checkout sheet — no regression. +- `/login` — **[impacted]** Existing `returnUrl=/scanner` auth gate unchanged; desktop users land on the workstation after sign-in. +- `/collections`, `/my-cards` — **[impacted]** Post-commit navigation targets only (Add to List picker, success flows). No route or nav IA changes in this convoy. + +No new routes. No API route changes. + +### User flow + +```mermaid +flowchart LR + A["/scanner (auth)"] --> B{"md+?"} + B -->|Yes| C["Workstation"] + B -->|No| D["Immersive mobile"] + C --> E["Scan / Upload / Batch"] + E --> F["Match inspector"] + F --> G["Add or queue"] + C --> H["Strip tabs"] + H --> F +``` + +Desktop path: user opens `/scanner` with app chrome → scans via webcam, single Upload Image, or Batch Scan (sequential gallery identify) → latest match appears in the right-rail inspector → commits one card via inspector **or** batches via Scan Queue strip → Duplicates tab surfaces owned + session-repeat rows for review/increment. Mobile path unchanged: full-bleed camera → checkout sheet. + +### Screen inventory + +| Screen | Path | New/modified | Notes | +| --- | --- | --- | --- | +| Scanner Desktop Workstation | `/scanner` | modified | `md+` grid: framed camera panel (device picker, Upload, Batch Scan, Auto-detect toggle, Tips), right-rail inspector, bottom strip. Replaces 360px cart side panel as primary right-hand surface. | +| Scanner Mobile Immersive | `/scanner` | impacted (no regression) | `max-md`: `chrome="immersive"`, overlay camera chrome, `ScannerCheckoutSheet`. D1/D3/D4/D5/D7 decisions preserved. | +| Live Match Inspector | `/scanner` | new (sub-surface) | Right rail on desktop. Shows latest unprocessed identify: thumbnail, name, set, rarity, collector #, condition, foil, confidence. Actions: `VOCAB.ADD_TO_MY_COLLECTION`, `VOCAB.ADD_TO_LIST`, Rescan. Single-card commit without opening checkout sheet. | +| History / Queue Strip | `/scanner` | new (sub-surface) | Bottom strip on desktop. Tabs: **Recent Scans** (session history), **Scan Queue** (uncommitted cart, badge = unprocessed count), **Duplicates** (badge = duplicate row count). Row click focuses card in inspector. | +| Scanner Tips | `/scanner` | new (sub-surface) | Header control → glass `` or popover. Five convoy-authored tips (see Content deltas). No route change. | +| List Picker | `/scanner` | impacted | Existing "Choose a List" ``. Opened from inspector `VOCAB.ADD_TO_LIST` on desktop (and unchanged on mobile). | +| Leave Scanner | `/scanner` | impacted | Existing leave-with-uncommitted-queue modal. Applies to both viewports when navigating away with queue items. | + +### Content / data model deltas + +**Copy (ship from `lib/collection-vocabulary.js`):** + +- Primary add: `VOCAB.ADD_TO_MY_COLLECTION` ("Add to My Collection"). +- Secondary add: `VOCAB.ADD_TO_LIST` ("Add to List") — **not** "Save to Wishlist" (feature omitted). +- Strip tab labels: "Recent Scans", "Scan Queue", "Duplicates". +- Camera controls: "Upload Image", "Batch Scan", "Auto-detect" (toggle + status on/off), "Scanner Tips", "Rescan". +- Device picker: "Camera" or "Webcam" `` or styled native picker, label **Camera** | `enumerateDevices` video inputs; desktop only | +| Upload Image | ` + + + {confidencePct != null && ( +
+
+ + Confidence + + + {confidencePct}% + +
+
+
+
+

+ {confidenceCaption(confidencePct)} +

+
+ )} +
+ + +
+ {commitError && ( +
+ {commitError} +
+ )} + + + +
+ + +
+
+ + ); +} diff --git a/components/scanner/ScannerTips.js b/components/scanner/ScannerTips.js new file mode 100644 index 0000000..78b011c --- /dev/null +++ b/components/scanner/ScannerTips.js @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import Button from '../ui/Button'; +import Modal from '../ui/Modal'; + +const SCANNER_TIPS = [ + { lead: 'Good lighting', body: 'avoid glare on foil cards.' }, + { lead: 'Fill the frame', body: 'with one card; keep corners visible.' }, + { lead: 'Hold still', body: 'until Auto-detect locks the match.' }, + { lead: 'Use Batch Scan', body: 'for a pile of photos from your gallery.' }, + { lead: 'Switch camera', body: 'if the image is dark or mirrored.' }, +]; + +function LightbulbIcon() { + return ( + + ); +} + +export default function ScannerTips({ className = '' }) { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} + title="Scanner Tips" + size="md" + > +
    + {SCANNER_TIPS.map((tip) => ( +
  1. + {tip.lead} + {' — '} + {tip.body} +
  2. + ))} +
+
+ + ); +} diff --git a/lib/scanner-batch-identify.js b/lib/scanner-batch-identify.js new file mode 100644 index 0000000..c1fe6d4 --- /dev/null +++ b/lib/scanner-batch-identify.js @@ -0,0 +1,46 @@ +/** + * @typedef {{ current: number, total: number, file: File }} BatchProgress + */ + +/** + * Run gallery identify sequentially over multiple files with progress, cancel, and per-file failure continuation. + * + * @param {File[] | FileList | Iterable} files + * @param {(file: File) => Promise} identifyFn + * @param {{ + * onProgress?: (progress: BatchProgress) => void, + * onFileSuccess?: (payload: { file: File, index: number }) => void, + * onFileError?: (payload: { file: File, index: number, error: unknown }) => void, + * cancelRef?: { current: boolean }, + * }} [options] + * @returns {Promise<{ results: Array<{ file: File, ok: true } | { file: File, ok: false, error: unknown }>, cancelled: boolean }>} + */ +export async function runSequentialGalleryIdentify(files, identifyFn, options = {}) { + const { + onProgress, + onFileSuccess, + onFileError, + cancelRef = { current: false }, + } = options; + + const list = Array.from(files || []); + const total = list.length; + const results = []; + + for (let i = 0; i < list.length; i++) { + if (cancelRef.current) break; + const file = list[i]; + onProgress?.({ current: i + 1, total, file }); + + try { + await identifyFn(file); + results.push({ file, ok: true }); + onFileSuccess?.({ file, index: i }); + } catch (error) { + results.push({ file, ok: false, error }); + onFileError?.({ file, index: i, error }); + } + } + + return { results, cancelled: cancelRef.current }; +} diff --git a/lib/use-camera-scanner.js b/lib/use-camera-scanner.js index 496ec7f..726cbbd 100644 --- a/lib/use-camera-scanner.js +++ b/lib/use-camera-scanner.js @@ -8,6 +8,16 @@ import { VERIFICATION_INTERVAL_MS, } from './scanner-card-detection.js'; +const SESSION_DEVICE_KEY = 'scanner:last-camera-device-id'; + +const DEVICE_PICKER_MESSAGES = { + loading: 'Detecting cameras…', + ready: '', + empty: 'No camera found. Connect a webcam or use Upload Image.', + denied: 'Camera access blocked. Allow camera permission or use Upload Image.', + error: "Couldn't list cameras. Try again or use Upload Image.", +}; + /** * Camera stream + card shape detection loop for the card scanner. * Identification callbacks stay in the parent component. @@ -23,6 +33,18 @@ export function useCameraScanner({ const [trackedCards, setTrackedCards] = useState([]); const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 }); const [activeFacingMode, setActiveFacingMode] = useState(facingMode); + const [videoDevices, setVideoDevices] = useState([]); + const [selectedDeviceId, setSelectedDeviceIdState] = useState(() => { + try { + return sessionStorage.getItem(SESSION_DEVICE_KEY) || ''; + } catch { + return ''; + } + }); + const [devicePickerStatus, setDevicePickerStatus] = useState('loading'); + const [devicePickerMessage, setDevicePickerMessage] = useState( + DEVICE_PICKER_MESSAGES.loading + ); const videoRef = useRef(null); const canvasRef = useRef(null); @@ -35,6 +57,8 @@ export function useCameraScanner({ const isStreamingRef = useRef(false); const activeFacingModeRef = useRef(activeFacingMode); const facingModeInitializedRef = useRef(false); + const selectedDeviceIdRef = useRef(selectedDeviceId); + const selectedDeviceIdInitializedRef = useRef(false); useEffect(() => { isStreamingRef.current = isStreaming; @@ -44,6 +68,63 @@ export function useCameraScanner({ activeFacingModeRef.current = activeFacingMode; }, [activeFacingMode]); + useEffect(() => { + selectedDeviceIdRef.current = selectedDeviceId; + }, [selectedDeviceId]); + + const persistSelectedDeviceId = useCallback((deviceId) => { + try { + if (deviceId) { + sessionStorage.setItem(SESSION_DEVICE_KEY, deviceId); + } else { + sessionStorage.removeItem(SESSION_DEVICE_KEY); + } + } catch { + // sessionStorage may be unavailable in private mode or SSR + } + }, []); + + const setSelectedDeviceId = useCallback((deviceId) => { + setSelectedDeviceIdState(deviceId); + persistSelectedDeviceId(deviceId); + }, [persistSelectedDeviceId]); + + const refreshVideoDevices = useCallback(async () => { + if (!navigator.mediaDevices?.enumerateDevices) { + setDevicePickerStatus('error'); + setDevicePickerMessage(DEVICE_PICKER_MESSAGES.error); + return; + } + + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const videoInputs = devices.filter((device) => device.kind === 'videoinput'); + setVideoDevices(videoInputs); + + if (videoInputs.length === 0) { + setDevicePickerStatus('empty'); + setDevicePickerMessage(DEVICE_PICKER_MESSAGES.empty); + return; + } + + const availableIds = videoInputs.map((device) => device.deviceId); + const currentId = selectedDeviceIdRef.current; + if (currentId && !availableIds.includes(currentId)) { + const fallbackId = videoInputs[0]?.deviceId || ''; + selectedDeviceIdRef.current = fallbackId; + setSelectedDeviceIdState(fallbackId); + persistSelectedDeviceId(fallbackId); + } + + setDevicePickerStatus('ready'); + setDevicePickerMessage(DEVICE_PICKER_MESSAGES.ready); + } catch (err) { + console.error('enumerateDevices failed:', err); + setDevicePickerStatus('error'); + setDevicePickerMessage(DEVICE_PICKER_MESSAGES.error); + } + }, [persistSelectedDeviceId]); + useEffect(() => { if (canvasRef.current) { canvasRef.current.getContext('2d', { willReadFrequently: true }); @@ -124,16 +205,29 @@ export function useCameraScanner({ } }; + const buildVideoConstraints = () => { + const deviceId = selectedDeviceIdRef.current; + if (deviceId) { + return { + deviceId: { exact: deviceId }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }; + } + + return { + facingMode: activeFacingModeRef.current, + width: { ideal: 1280 }, + height: { ideal: 720 }, + aspectRatio: { ideal: 16 / 9 }, + }; + }; + const startCamera = async () => { try { console.log('🎥 Starting camera...'); const stream = await navigator.mediaDevices.getUserMedia({ - video: { - facingMode: activeFacingModeRef.current, - width: { ideal: 1280 }, - height: { ideal: 720 }, - aspectRatio: { ideal: 16 / 9 }, - }, + video: buildVideoConstraints(), }); console.log('📹 Camera stream obtained:', stream); @@ -153,6 +247,7 @@ export function useCameraScanner({ console.log('▶️ Video playback started successfully'); setIsStreaming(true); isStreamingRef.current = true; + refreshVideoDevices(); }).catch((err) => { console.error('❌ Video playback failed:', err); onError?.(`Video playback failed: ${err.message}`); @@ -171,11 +266,16 @@ export function useCameraScanner({ console.log('▶️ Fallback video playback started'); setIsStreaming(true); isStreamingRef.current = true; + refreshVideoDevices(); }).catch(console.error); } }, 2000); } catch (err) { console.error('❌ Camera access error:', err); + if (err?.name === 'NotAllowedError') { + setDevicePickerStatus('denied'); + setDevicePickerMessage(DEVICE_PICKER_MESSAGES.denied); + } onError?.(`Unable to access camera: ${err.message}`); } }; @@ -236,6 +336,19 @@ export function useCameraScanner({ // eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when facing mode toggles }, [activeFacingMode]); + useEffect(() => { + if (!selectedDeviceIdInitializedRef.current) { + selectedDeviceIdInitializedRef.current = true; + return; + } + + if (!isStreamingRef.current) return; + + stopCamera(); + startCamera(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- restart stream when selected device changes + }, [selectedDeviceId]); + return { videoRef, canvasRef, @@ -249,5 +362,10 @@ export function useCameraScanner({ streamRef, facingMode: activeFacingMode, switchFacingMode, + videoDevices, + selectedDeviceId, + setSelectedDeviceId, + devicePickerStatus, + devicePickerMessage, }; } diff --git a/lib/use-scanner-identification.js b/lib/use-scanner-identification.js index 5fd3f2f..f641c8f 100644 --- a/lib/use-scanner-identification.js +++ b/lib/use-scanner-identification.js @@ -51,6 +51,7 @@ export function useScannerIdentification({ verificationPausedRef, }) { const [disambiguation, setDisambiguation] = useState(null); + const [isIdentifying, setIsIdentifying] = useState(false); const [scanNotice, setScanNotice] = useState(null); const [submittingReview, setSubmittingReview] = useState(false); @@ -59,12 +60,6 @@ export function useScannerIdentification({ const lastErrorAtRef = useRef(0); const disambiguationRefineRef = useRef(null); - useEffect(() => { - if (verificationPausedRef) { - verificationPausedRef.current = Boolean(disambiguation); - } - }, [disambiguation, verificationPausedRef]); - const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => { cardTracker.status = 'scanned'; @@ -280,6 +275,7 @@ export function useScannerIdentification({ activeVerificationRef.current += 1; cardTracker.status = 'verifying'; + setIsIdentifying(true); try { cardTracker.scanAttempts++; @@ -314,6 +310,7 @@ export function useScannerIdentification({ reportScannerError(error.message || 'Scan failed'); } finally { activeVerificationRef.current = Math.max(0, activeVerificationRef.current - 1); + setIsIdentifying(activeVerificationRef.current > 0); } }; @@ -326,6 +323,7 @@ export function useScannerIdentification({ const identifyFromGalleryFile = async (file) => { if (!file || verificationPausedRef?.current) return; + setIsIdentifying(true); try { const imageData = await readFileToImageData(file); const authHeaders = getScanAuthHeaders(); @@ -368,11 +366,14 @@ export function useScannerIdentification({ reportScannerError('Could not identify card from gallery image'); } catch (error) { reportScannerError(error.message || 'Gallery identify failed'); + } finally { + setIsIdentifying(activeVerificationRef.current > 0); } }; return { disambiguation, + isIdentifying, scanNotice, submittingReview, handleDisambiguationPick, diff --git a/lib/use-scanner-queue.js b/lib/use-scanner-queue.js index 31efa61..120298d 100644 --- a/lib/use-scanner-queue.js +++ b/lib/use-scanner-queue.js @@ -203,24 +203,28 @@ export function useScannerQueue({ user, sessionDestination, scanDefaults, deckMo }; const addSingleCardToOwned = async (card) => { - if (!tryBeginAdding(card.id)) return; + if (!tryBeginAdding(card.id)) return false; try { await addScannedCardToOwned(card); markCardAsProcessed(card.id, 'owned'); + return true; } catch (error) { console.error('Error adding card to owned:', error); + return false; } finally { endAdding(card.id); } }; const addSingleCardToCollection = async (card, collectionId) => { - if (!tryBeginAdding(card.id)) return; + if (!tryBeginAdding(card.id)) return false; try { await addScannedCardToCollection(card, collectionId); markCardAsProcessed(card.id, 'collection'); + return true; } catch (error) { console.error('Error adding card to collection:', error); + return false; } finally { endAdding(card.id); } diff --git a/pages/scanner.js b/pages/scanner.js index 4651c2a..455de1f 100644 --- a/pages/scanner.js +++ b/pages/scanner.js @@ -1,26 +1,53 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/router'; import Layout from '../components/Layout'; import ScannerCamera from '../components/scanner/ScannerCamera'; -import ScannerReview from '../components/scanner/ScannerReview'; import ScannerCheckoutSheet from '../components/scanner/ScannerCheckoutSheet'; +import ScannerResultPanel from '../components/scanner/ScannerResultPanel'; +import ScannerHistoryStrip, { + TAB_QUEUE, + TAB_RECENT, +} from '../components/scanner/ScannerHistoryStrip'; +import ScannerTips from '../components/scanner/ScannerTips'; +import ScannerToast from '../components/scanner/ScannerToast'; import { Modal, Button } from '../components/ui'; +import GlassSurface from '../components/ui/GlassSurface.js'; import { useAuth } from '../lib/use-auth'; import { useScannerSession } from '../lib/use-scanner-session.js'; import { useScannerQueue } from '../lib/use-scanner-queue.js'; import { useCameraScanner } from '../lib/use-camera-scanner.js'; import { useScannerIdentification } from '../lib/use-scanner-identification.js'; +import { runSequentialGalleryIdentify } from '../lib/scanner-batch-identify.js'; import { clearScannerCartStorage } from '../lib/scanner-session.js'; -import { collectionDisplayName } from '../lib/collection-vocabulary.js'; +import { VOCAB, collectionDisplayName } from '../lib/collection-vocabulary.js'; + +const TOAST_DURATION_MS = 2500; export default function Scanner() { const { user, loading: authLoading } = useAuth(); const router = useRouter(); + const [isDesktop, setIsDesktop] = useState(false); const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); const [isListPickerOpen, setIsListPickerOpen] = useState(false); const [showLeaveModal, setShowLeaveModal] = useState(false); const [listCommitError, setListCommitError] = useState(null); + const [inspectorCommitError, setInspectorCommitError] = useState(null); + const [focusedCardId, setFocusedCardId] = useState(null); + const [stripActiveTab, setStripActiveTab] = useState(TAB_RECENT); + const [isAutoDetectPaused, setIsAutoDetectPaused] = useState(false); + const [pageToast, setPageToast] = useState({ message: '', visible: false, type: 'success' }); + const [galleryBusy, setGalleryBusy] = useState(false); + const [batchBusy, setBatchBusy] = useState(false); + const [batchProgress, setBatchProgress] = useState(null); + const verificationPausedRef = useRef(false); + const prevCardCountRef = useRef(0); + const scannedCardsRef = useRef([]); + const toastTimerRef = useRef(null); + const batchCancelRef = useRef(false); + const deskGalleryInputRef = useRef(null); + const batchInputRef = useRef(null); + const disambiguationActiveRef = useRef(false); const { scanDefaults } = useScannerSession(); const queue = useScannerQueue({ user, scanDefaults, deckMode: null }); @@ -43,6 +70,20 @@ export default function Scanner() { }); const unprocessedCount = queue.unprocessedCount; + const focusedCard = + queue.scannedCards.find((card) => card.id === focusedCardId) ?? null; + + useEffect(() => { + scannedCardsRef.current = queue.scannedCards; + }, [queue.scannedCards]); + + useEffect(() => { + const mq = window.matchMedia('(min-width: 768px)'); + const sync = () => setIsDesktop(mq.matches); + sync(); + mq.addEventListener('change', sync); + return () => mq.removeEventListener('change', sync); + }, []); useEffect(() => { if (!authLoading && !user) { @@ -50,6 +91,10 @@ export default function Scanner() { } }, [authLoading, user, router]); + useEffect(() => { + disambiguationActiveRef.current = Boolean(identification.disambiguation); + }, [identification.disambiguation]); + useEffect(() => { const mobileCheckoutPauses = typeof window !== 'undefined' && @@ -59,8 +104,38 @@ export default function Scanner() { verificationPausedRef.current = mobileCheckoutPauses || isListPickerOpen || + isAutoDetectPaused || Boolean(identification.disambiguation); - }, [isCheckoutOpen, isListPickerOpen, identification.disambiguation]); + }, [ + isCheckoutOpen, + isListPickerOpen, + isAutoDetectPaused, + identification.disambiguation, + ]); + + useEffect(() => { + if (queue.scannedCards.length > prevCardCountRef.current) { + const latestUnprocessed = queue.scannedCards.find((card) => !card.processed); + if (latestUnprocessed) { + // Auto-focus the newest unprocessed scan when the queue grows. + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional focus sync on enqueue + setFocusedCardId(latestUnprocessed.id); + } + } + prevCardCountRef.current = queue.scannedCards.length; + }, [queue.scannedCards]); + + useEffect(() => { + return () => clearTimeout(toastTimerRef.current); + }, []); + + const showPageToast = useCallback((message, type = 'success') => { + clearTimeout(toastTimerRef.current); + setPageToast({ message, visible: true, type }); + toastTimerRef.current = setTimeout(() => { + setPageToast((current) => ({ ...current, visible: false })); + }, TOAST_DURATION_MS); + }, []); const handleLeaveConfirm = () => { clearScannerCartStorage(); @@ -71,12 +146,31 @@ export default function Scanner() { const handleListPick = async (collectionId) => { setListCommitError(null); - const selectedIds = queue.scannedCards - .filter((card) => queue.selectedCards.has(card.id)) - .map((card) => card.id); + const card = isDesktop ? focusedCard : null; + const selectedIds = isDesktop + ? card + ? [card.id] + : [] + : queue.scannedCards + .filter((entry) => queue.selectedCards.has(entry.id)) + .map((entry) => entry.id); if (selectedIds.length === 0) return; + if (isDesktop && card) { + const success = await queue.addSingleCardToCollection(card, collectionId); + if (!success) { + setListCommitError('Could not add cards to the list. Try again.'); + return; + } + setIsListPickerOpen(false); + const remaining = queue.scannedCards.filter( + (entry) => !entry.processed && entry.id !== card.id + ); + setFocusedCardId(remaining[0]?.id ?? null); + return; + } + const successfulIds = await queue.commitSelectedToCollection(collectionId); if (!successfulIds?.length) { setListCommitError('Could not add cards to the list. Try again.'); @@ -86,6 +180,142 @@ export default function Scanner() { setIsListPickerOpen(false); }; + const handleInspectorAddToOwned = async (card) => { + setInspectorCommitError(null); + const success = await queue.addSingleCardToOwned(card); + if (!success) { + setInspectorCommitError('Could not add card. Try again.'); + return; + } + + showPageToast(`Added to ${VOCAB.MY_COLLECTION}`); + const remaining = queue.scannedCards.filter( + (entry) => !entry.processed && entry.id !== card.id + ); + setFocusedCardId(remaining[0]?.id ?? null); + }; + + const handleInspectorAddToList = () => { + if (!focusedCard) return; + setListCommitError(null); + setIsListPickerOpen(true); + }; + + const handleRescan = async (card) => { + queue.updateCardMetadata(card.id, { + processed: false, + identifyFailed: false, + identifyFailureReason: undefined, + confidence: undefined, + }); + + if (card.scanImageUrl) { + try { + const response = await fetch(card.scanImageUrl); + const blob = await response.blob(); + const file = new File([blob], 'rescan.jpg', { type: blob.type || 'image/jpeg' }); + await identification.identifyFromGalleryFile(file); + } catch (error) { + showPageToast(error.message || 'Rescan failed', 'error'); + } + return; + } + + showPageToast('Rescan from camera', 'info'); + }; + + const enqueueFailedIdentify = useCallback( + (file, error) => { + const baseName = file?.name?.replace(/\.[^.]+$/, '') || 'Unknown image'; + queue.handleCardScanned({ + name: baseName, + set: 'Batch scan', + identifyFailed: true, + identifyFailureReason: + error?.message || 'Could not identify card from gallery image', + }); + }, + [queue] + ); + + const identifyGalleryFileOrThrow = useCallback( + async (file) => { + const countBefore = scannedCardsRef.current.length; + + await identification.identifyFromGalleryFile(file); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (scannedCardsRef.current.length > countBefore) return; + if (disambiguationActiveRef.current) return; + + throw new Error('Could not identify card from gallery image'); + }, + [identification] + ); + + const handleDeskGalleryChange = async (event) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + setGalleryBusy(true); + try { + await identification.identifyFromGalleryFile(file); + } finally { + setGalleryBusy(false); + } + }; + + const handleBatchChange = async (event) => { + const files = event.target.files; + event.target.value = ''; + if (!files?.length) return; + + batchCancelRef.current = false; + setStripActiveTab(TAB_QUEUE); + setBatchBusy(true); + setBatchProgress({ active: true, current: 0, total: files.length }); + + try { + await runSequentialGalleryIdentify(files, identifyGalleryFileOrThrow, { + cancelRef: batchCancelRef, + onProgress: ({ current, total }) => { + setBatchProgress({ + active: true, + current, + total, + onCancel: () => { + batchCancelRef.current = true; + }, + }); + }, + onFileError: ({ file, error }) => { + enqueueFailedIdentify(file, error); + }, + }); + } finally { + setBatchBusy(false); + setBatchProgress(null); + batchCancelRef.current = false; + } + }; + + const handleClearAll = () => { + if (batchProgress?.active) return; + clearScannerCartStorage(); + queue.clearScannedCards(); + setFocusedCardId(null); + }; + + const handleBack = () => { + if (unprocessedCount > 0) { + setShowLeaveModal(true); + return; + } + router.back(); + }; + if (authLoading) { return ( @@ -103,52 +333,236 @@ export default function Scanner() { return null; } - return ( - -
-
- - unprocessedCount > 0 ? setShowLeaveModal(true) : router.back() - } - onOpenCheckout={() => setIsCheckoutOpen(true)} - onGalleryIdentify={(file) => identification.identifyFromGalleryFile(file)} - latestPeekCard={queue.scannedCards[0] ?? null} - cartCount={unprocessedCount} - isCheckoutOpen={isCheckoutOpen} - verificationPausedRef={verificationPausedRef} - /> -
+ const listPickerDescription = isDesktop + ? `Add ${focusedCard?.name ?? 'this card'} to one of your lists.` + : 'Add the selected cards to one of your lists.'; -
- {isCheckoutOpen && ( - +
+ {isDesktop && ( +
+
+

+ Card Scanner +

+

+ Identify cards with your webcam and add them to {VOCAB.MY_COLLECTION}. +

+
+ +
+ )} + +
+
+ setIsCheckoutOpen(false)} - onOpenListPicker={() => setIsListPickerOpen(true)} - trapActive={!isListPickerOpen} + camera={camera} + identification={identification} + variant={isDesktop ? 'workstation' : 'default'} + autoDetectOn={!isAutoDetectPaused} + onBack={handleBack} + onOpenCheckout={() => setIsCheckoutOpen(true)} + onGalleryIdentify={(file) => identification.identifyFromGalleryFile(file)} + latestPeekCard={queue.scannedCards[0] ?? null} + cartCount={unprocessedCount} + isCheckoutOpen={isCheckoutOpen} + verificationPausedRef={verificationPausedRef} /> + + {isDesktop && ( + +
+ + + {camera.devicePickerMessage && ( +

+ {camera.devicePickerMessage} +

+ )} +
+ + + + + +
+ + Auto-detect + + +
+ + + +
+ )} +
+ +
+ {isCheckoutOpen && ( + setIsCheckoutOpen(false)} + onOpenListPicker={() => setIsListPickerOpen(true)} + trapActive={!isListPickerOpen} + /> + )} +
+ + {isDesktop && ( +
+ +
)}
-
- setIsListPickerOpen(true)} - /> -
+ {isDesktop && ( +
+ queue.commitSelectedToOwned()} + onOpenListPicker={() => setIsListPickerOpen(true)} + isProcessing={queue.isProcessing} + /> +
+ )} + + {listCommitError && (
({ + useScannerSound: () => ({ playSuccess: vi.fn() }), +})); + +vi.mock('../../lib/use-scanner-flash.js', () => ({ + useScannerFlash: () => ({ + flashSupported: false, + flashOn: false, + toggleFlash: vi.fn(), + }), +})); + +vi.mock('../../components/scanner/ScannerScanPeek.js', () => ({ + default: () =>
, +})); + +vi.mock('../../components/scanner/ScannerCountPill.js', () => ({ + default: () =>
, +})); + +vi.mock('../../components/scanner/ScannerDisambiguation.js', () => ({ + default: () => null, +})); + +import ScannerCamera from '../../components/scanner/ScannerCamera.js'; + +function createCameraFixture() { + return { + videoRef: { current: document.createElement('video') }, + canvasRef: { current: document.createElement('canvas') }, + detectionCanvasRef: { current: document.createElement('canvas') }, + isStreaming: true, + trackedCards: [], + videoMetrics: { width: 1280, height: 720 }, + startCamera: vi.fn(), + streamRef: { current: null }, + facingMode: 'environment', + switchFacingMode: vi.fn(), + }; +} + +function renderCamera(overrides = {}) { + const props = { + queue: { scannedCards: [], addingCardIds: new Set() }, + camera: createCameraFixture(), + identification: { disambiguation: null }, + onBack: vi.fn(), + onOpenCheckout: vi.fn(), + latestPeekCard: null, + cartCount: 0, + ...overrides, + }; + + return render(); +} + +describe('ScannerCamera workstation variant', () => { + afterEach(() => cleanup()); + + it('keeps mobile overlay chrome visible in the default variant', () => { + const { container } = renderCamera({ variant: 'default' }); + + expect(screen.getByRole('button', { name: 'Leave scanner' })).toBeTruthy(); + expect(screen.getByTestId('scanner-scan-peek')).toBeTruthy(); + expect(screen.getByTestId('scanner-count-pill')).toBeTruthy(); + expect(screen.queryByText('Auto-detect ON')).toBeNull(); + expect(container.querySelector('.md\\:hidden')).toBeNull(); + }); + + it('marks overlay chrome for md+ hiding and shows the Auto-detect badge in workstation mode', () => { + const { container } = renderCamera({ + variant: 'workstation', + autoDetectOn: true, + }); + + expect(container.querySelector('.md\\:hidden')).toBeTruthy(); + expect(screen.getByText('Auto-detect ON')).toBeTruthy(); + expect(screen.getByRole('status').textContent).toContain('Auto-detect ON'); + expect(screen.getByText('LIVE')).toBeTruthy(); + }); + + it('shows Auto-detect OFF when autoDetectOn is false', () => { + renderCamera({ + variant: 'workstation', + autoDetectOn: false, + }); + + expect(screen.getByText('Auto-detect OFF')).toBeTruthy(); + }); +}); diff --git a/test/components/ScannerHistoryStrip.test.js b/test/components/ScannerHistoryStrip.test.js new file mode 100644 index 0000000..cc2f184 --- /dev/null +++ b/test/components/ScannerHistoryStrip.test.js @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; + +import ScannerHistoryStrip, { + TAB_DUPLICATES, + TAB_QUEUE, + TAB_RECENT, + getDuplicateCards, +} from '../../components/scanner/ScannerHistoryStrip.js'; + +const CARD_A = { + id: 'card-a', + name: 'Lightning Bolt', + set: 'Alpha', + databaseId: 101, + quantity: 1, + processed: false, +}; + +const CARD_B = { + id: 'card-b', + name: 'Lightning Bolt', + set: 'Alpha', + databaseId: 102, + quantity: 1, + processed: false, +}; + +const CARD_C = { + id: 'card-c', + name: 'Counterspell', + set: 'Beta', + databaseId: 201, + quantity: 2, + processed: true, +}; + +function renderStrip(overrides = {}) { + const props = { + scannedCards: [], + ownershipMap: {}, + focusedCardId: null, + onFocusCard: vi.fn(), + activeTab: TAB_RECENT, + onTabChange: vi.fn(), + batchProgress: null, + onClearAll: vi.fn(), + onCommitSelectedToOwned: vi.fn(), + onOpenListPicker: vi.fn(), + isProcessing: false, + ...overrides, + }; + + return { + ...render(), + props, + }; +} + +describe('getDuplicateCards', () => { + it('flags session name/set repeats', () => { + const result = getDuplicateCards([CARD_A, CARD_B], {}); + expect(result.map((card) => card.id)).toEqual(['card-b']); + }); + + it('flags quantity greater than one', () => { + const solo = { ...CARD_C, quantity: 2 }; + const result = getDuplicateCards([solo], {}); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('card-c'); + }); + + it('flags cards already in ownershipMap by databaseId', () => { + const owned = { ...CARD_A, databaseId: 999 }; + const result = getDuplicateCards([owned], { 999: { quantity: 1 } }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('card-a'); + }); +}); + +describe('ScannerHistoryStrip', () => { + afterEach(() => cleanup()); + + it('renders three tabs with badge counts in aria-label', () => { + renderStrip({ + scannedCards: [CARD_A, CARD_B, CARD_C], + activeTab: TAB_RECENT, + }); + + expect(screen.getByRole('tab', { name: 'Recent Scans' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Scan Queue, 2 unprocessed cards' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Duplicates, 2 duplicate cards' })).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'Scan Queue, 2 unprocessed cards' }).innerHTML).not.toContain('#ffffff'); + }); + + it('calls onTabChange when a tab is clicked', () => { + const { props } = renderStrip({ scannedCards: [CARD_A] }); + + fireEvent.click(screen.getByRole('tab', { name: /scan queue/i })); + expect(props.onTabChange).toHaveBeenCalledWith(TAB_QUEUE); + }); + + it('shows empty states for each tab', () => { + const { rerender, props } = renderStrip({ activeTab: TAB_RECENT }); + expect(screen.getByText('No scans yet this session.')).toBeTruthy(); + + rerender(); + expect( + screen.getByText('Scan queue is empty — matches appear here before you add them.') + ).toBeTruthy(); + + rerender(); + expect(screen.getByText('No duplicates detected.')).toBeTruthy(); + }); + + it('row click invokes onFocusCard without commit handlers', () => { + const onFocusCard = vi.fn(); + const onCommitSelectedToOwned = vi.fn(); + const onOpenListPicker = vi.fn(); + + renderStrip({ + scannedCards: [CARD_A], + activeTab: TAB_RECENT, + onFocusCard, + onCommitSelectedToOwned, + onOpenListPicker, + }); + + fireEvent.click(screen.getByRole('button', { name: /lightning bolt/i })); + expect(onFocusCard).toHaveBeenCalledWith('card-a'); + expect(onCommitSelectedToOwned).not.toHaveBeenCalled(); + expect(onOpenListPicker).not.toHaveBeenCalled(); + }); + + it('applies focused styling when focusedCardId matches a chip', () => { + renderStrip({ + scannedCards: [CARD_A], + activeTab: TAB_RECENT, + focusedCardId: 'card-a', + }); + + const chip = screen.getByRole('button', { name: /lightning bolt/i }); + expect(chip.getAttribute('aria-current')).toBe('true'); + }); + + it('shows batch progress banner with cancel in Scan Queue tab', () => { + const onCancel = vi.fn(); + renderStrip({ + scannedCards: [CARD_A], + activeTab: TAB_QUEUE, + batchProgress: { active: true, current: 2, total: 5, onCancel }, + }); + + expect(screen.getByText('Scanning 2 of 5…')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('disables Clear All while batch progress is active', () => { + renderStrip({ + scannedCards: [CARD_A], + batchProgress: { active: true, current: 1, total: 3, onCancel: vi.fn() }, + }); + + expect(screen.getByRole('button', { name: /clear all/i }).disabled).toBe(true); + }); + + it('shows identify failed label on failed queue rows', () => { + const failedCard = { + ...CARD_A, + identifyFailed: true, + identifyFailureReason: 'No match found', + }; + + renderStrip({ + scannedCards: [failedCard], + activeTab: TAB_QUEUE, + }); + + expect(screen.getByText('Identify failed')).toBeTruthy(); + expect(screen.getByText('No match found')).toBeTruthy(); + }); + + it('lists recent scans newest first', () => { + const older = { ...CARD_A, id: 'older', name: 'Older Card' }; + const newer = { ...CARD_B, id: 'newer', name: 'Newer Card', set: 'New Set' }; + + renderStrip({ + scannedCards: [older, newer], + activeTab: TAB_RECENT, + }); + + const panel = screen.getByRole('tabpanel', { name: /recent scans/i }); + const buttons = within(panel).getAllByRole('button'); + expect(buttons[0].textContent).toContain('Newer Card'); + expect(buttons[1].textContent).toContain('Older Card'); + }); + + it('exposes tablist semantics', () => { + renderStrip({ scannedCards: [CARD_A] }); + expect(screen.getByRole('tablist', { name: 'Scan history' })).toBeTruthy(); + expect(screen.getAllByRole('tab')).toHaveLength(3); + expect(screen.getAllByRole('tabpanel', { hidden: true })).toHaveLength(3); + }); +}); diff --git a/test/components/ScannerResultPanel.test.js b/test/components/ScannerResultPanel.test.js new file mode 100644 index 0000000..b5a2dd8 --- /dev/null +++ b/test/components/ScannerResultPanel.test.js @@ -0,0 +1,131 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +import ScannerResultPanel from '../../components/scanner/ScannerResultPanel.js'; +import { VOCAB } from '../../lib/collection-vocabulary.js'; + +const FOCUSED_CARD = { + id: 'card-1', + name: 'Lightning Bolt', + set: 'Alpha', + rarity: 'Common', + cardNumber: '161', + image_url: 'https://example.com/bolt.jpg', + condition: 'NM', + isFoil: false, + confidence: 0.92, +}; + +function createQueueFixture({ addingCardIds = new Set() } = {}) { + return { + addingCardIds, + updateCardMetadata: vi.fn(), + }; +} + +function renderPanel(overrides = {}) { + const props = { + focusedCard: null, + queue: createQueueFixture(), + isIdentifying: false, + commitError: null, + onAddToOwned: vi.fn(), + onAddToList: vi.fn(), + onRescan: vi.fn(), + ...overrides, + }; + + return { + ...render(), + props, + }; +} + +describe('ScannerResultPanel', () => { + afterEach(() => cleanup()); + + it('renders empty state illustration and instructional copy', () => { + renderPanel(); + + expect( + screen.getByText('Point your camera at a card or upload an image to see a match.') + ).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Scan Result' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: VOCAB.ADD_TO_MY_COLLECTION })).toBeNull(); + expect(screen.queryByRole('button', { name: /upload/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /batch/i })).toBeNull(); + }); + + it('shows Identifying… placeholder when identifying without a focused card', () => { + renderPanel({ isIdentifying: true }); + + expect(screen.getByText('Identifying…')).toBeTruthy(); + expect( + screen.queryByText('Point your camera at a card or upload an image to see a match.') + ).toBeNull(); + }); + + it('renders populated card metadata, controls, and vocab action labels', () => { + const queue = createQueueFixture(); + renderPanel({ focusedCard: FOCUSED_CARD, queue }); + + expect(screen.getByRole('heading', { name: FOCUSED_CARD.name, level: 3 })).toBeTruthy(); + expect(screen.getByText('Alpha · Common · 161')).toBeTruthy(); + expect(screen.getByRole('combobox', { name: `Condition for ${FOCUSED_CARD.name}` })).toBeTruthy(); + expect(screen.getByRole('switch', { name: 'Foil' })).toBeTruthy(); + expect(screen.getByText('92%')).toBeTruthy(); + expect(screen.getByText('Excellent match.')).toBeTruthy(); + expect(screen.getByRole('button', { name: VOCAB.ADD_TO_MY_COLLECTION })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Rescan' })).toBeTruthy(); + expect(screen.getByRole('button', { name: VOCAB.ADD_TO_LIST })).toBeTruthy(); + expect(screen.queryByText(/wishlist/i)).toBeNull(); + }); + + it('disables the primary add button while the card is adding', () => { + const queue = createQueueFixture({ addingCardIds: new Set(['card-1']) }); + renderPanel({ focusedCard: FOCUSED_CARD, queue }); + + const primaryButton = screen.getByRole('button', { name: VOCAB.ADD_TO_MY_COLLECTION }); + expect(primaryButton.disabled).toBe(true); + expect(primaryButton.getAttribute('aria-busy')).toBe('true'); + }); + + it('calls queue.updateCardMetadata when condition changes', () => { + const queue = createQueueFixture(); + renderPanel({ focusedCard: FOCUSED_CARD, queue }); + + fireEvent.change(screen.getByRole('combobox', { name: `Condition for ${FOCUSED_CARD.name}` }), { + target: { value: 'LP' }, + }); + + expect(queue.updateCardMetadata).toHaveBeenCalledWith('card-1', { condition: 'LP' }); + }); + + it('calls queue.updateCardMetadata when foil switch is toggled', () => { + const queue = createQueueFixture(); + renderPanel({ focusedCard: FOCUSED_CARD, queue }); + + fireEvent.click(screen.getByRole('switch', { name: 'Foil' })); + + expect(queue.updateCardMetadata).toHaveBeenCalledWith('card-1', { isFoil: true }); + }); + + it('shows commit errors with role=alert', () => { + renderPanel({ + focusedCard: FOCUSED_CARD, + commitError: 'Could not add card. Try again.', + }); + + const alert = screen.getByRole('alert'); + expect(alert.textContent).toContain('Could not add card. Try again.'); + }); + + it('includes reduced-motion CSS for the confidence bar', () => { + const { container } = renderPanel({ focusedCard: FOCUSED_CARD }); + + expect(container.innerHTML).toContain('scanner-result-confidence-bar-fill'); + expect(container.innerHTML).toContain('prefers-reduced-motion: reduce'); + expect(container.innerHTML).toContain('transition: none'); + }); +}); diff --git a/test/components/ScannerTips.test.js b/test/components/ScannerTips.test.js new file mode 100644 index 0000000..cf0fc00 --- /dev/null +++ b/test/components/ScannerTips.test.js @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import ScannerTips from '../../components/scanner/ScannerTips'; + +const TIP_LEADS = [ + 'Good lighting', + 'Fill the frame', + 'Hold still', + 'Use Batch Scan', + 'Switch camera', +]; + +const TIP_BODIES = [ + 'avoid glare on foil cards.', + 'with one card; keep corners visible.', + 'until Auto-detect locks the match.', + 'for a pile of photos from your gallery.', + 'if the image is dark or mirrored.', +]; + +describe('ScannerTips', () => { + afterEach(() => cleanup()); + + it('opens the modal when the trigger is clicked', () => { + render(); + + expect(screen.queryByRole('dialog')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Scanner Tips' })); + + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(screen.getByRole('heading', { name: 'Scanner Tips' })).toBeTruthy(); + }); + + it('lists all five locked tips when open', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Scanner Tips' })); + + for (const lead of TIP_LEADS) { + expect(screen.getByText(lead, { selector: 'strong' })).toBeTruthy(); + } + for (const body of TIP_BODIES) { + expect(screen.getByText(body, { exact: false })).toBeTruthy(); + } + expect(screen.getAllByRole('listitem')).toHaveLength(5); + }); + + it('dismisses the modal when the close control is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Scanner Tips' })); + expect(screen.getByRole('dialog')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/test/lib/scanner-batch-identify.test.js b/test/lib/scanner-batch-identify.test.js new file mode 100644 index 0000000..3971d73 --- /dev/null +++ b/test/lib/scanner-batch-identify.test.js @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from 'vitest'; +import { runSequentialGalleryIdentify } from '../../lib/scanner-batch-identify.js'; + +function makeFile(name) { + return new File(['x'], name, { type: 'image/jpeg' }); +} + +describe('runSequentialGalleryIdentify', () => { + it('processes files one-at-a-time in order on success', async () => { + const files = [makeFile('a.jpg'), makeFile('b.jpg'), makeFile('c.jpg')]; + const callOrder = []; + const progressEvents = []; + const successEvents = []; + + const identifyFn = vi.fn(async (file) => { + callOrder.push(file.name); + }); + + const { results, cancelled } = await runSequentialGalleryIdentify(files, identifyFn, { + onProgress: (p) => progressEvents.push({ current: p.current, total: p.total, name: p.file.name }), + onFileSuccess: ({ file, index }) => successEvents.push({ name: file.name, index }), + }); + + expect(cancelled).toBe(false); + expect(callOrder).toEqual(['a.jpg', 'b.jpg', 'c.jpg']); + expect(identifyFn).toHaveBeenCalledTimes(3); + expect(progressEvents).toEqual([ + { current: 1, total: 3, name: 'a.jpg' }, + { current: 2, total: 3, name: 'b.jpg' }, + { current: 3, total: 3, name: 'c.jpg' }, + ]); + expect(successEvents).toEqual([ + { name: 'a.jpg', index: 0 }, + { name: 'b.jpg', index: 1 }, + { name: 'c.jpg', index: 2 }, + ]); + expect(results).toHaveLength(3); + expect(results.every((r) => r.ok === true)).toBe(true); + expect(results.map((r) => r.file.name)).toEqual(['a.jpg', 'b.jpg', 'c.jpg']); + }); + + it('stops remaining files when cancelRef is set mid-batch', async () => { + const files = [makeFile('a.jpg'), makeFile('b.jpg'), makeFile('c.jpg')]; + const cancelRef = { current: false }; + const identifyFn = vi.fn(async (file) => { + if (file.name === 'a.jpg') { + cancelRef.current = true; + } + }); + + const { results, cancelled } = await runSequentialGalleryIdentify(files, identifyFn, { + cancelRef, + }); + + expect(cancelled).toBe(true); + expect(identifyFn).toHaveBeenCalledTimes(1); + expect(results).toEqual([{ file: files[0], ok: true }]); + }); + + it('invokes onFileError and continues after a per-file failure', async () => { + const files = [makeFile('a.jpg'), makeFile('b.jpg'), makeFile('c.jpg')]; + const err = new Error('identify failed'); + const errorEvents = []; + const callOrder = []; + + const identifyFn = vi.fn(async (file) => { + callOrder.push(file.name); + if (file.name === 'b.jpg') { + throw err; + } + }); + + const { results, cancelled } = await runSequentialGalleryIdentify(files, identifyFn, { + onFileError: ({ file, index, error }) => { + errorEvents.push({ name: file.name, index, error }); + }, + }); + + expect(cancelled).toBe(false); + expect(callOrder).toEqual(['a.jpg', 'b.jpg', 'c.jpg']); + expect(identifyFn).toHaveBeenCalledTimes(3); + expect(errorEvents).toEqual([{ name: 'b.jpg', index: 1, error: err }]); + expect(results).toHaveLength(3); + expect(results[0]).toMatchObject({ file: files[0], ok: true }); + expect(results[1]).toMatchObject({ file: files[1], ok: false, error: err }); + expect(results[2]).toMatchObject({ file: files[2], ok: true }); + }); + + it('accepts FileList-like iterables via Array.from', async () => { + const files = [makeFile('solo.jpg')]; + const fileList = { + length: files.length, + 0: files[0], + [Symbol.iterator]() { + let i = 0; + return { + next: () => { + if (i < files.length) { + return { value: files[i++], done: false }; + } + return { done: true }; + }, + }; + }, + }; + + const identifyFn = vi.fn(async () => {}); + const { results } = await runSequentialGalleryIdentify(fileList, identifyFn); + + expect(identifyFn).toHaveBeenCalledTimes(1); + expect(results).toEqual([{ file: files[0], ok: true }]); + }); + + it('returns empty results for null/undefined files', async () => { + const identifyFn = vi.fn(async () => {}); + const { results, cancelled } = await runSequentialGalleryIdentify(null, identifyFn); + + expect(cancelled).toBe(false); + expect(results).toEqual([]); + expect(identifyFn).not.toHaveBeenCalled(); + }); +}); diff --git a/test/lib/use-camera-scanner.test.js b/test/lib/use-camera-scanner.test.js new file mode 100644 index 0000000..be7d2e6 --- /dev/null +++ b/test/lib/use-camera-scanner.test.js @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('../../lib/scanner-card-detection.js', () => ({ + detectCardShapesFromFrame: vi.fn(() => []), + DETECTION_START_DELAY_MS: 10_000, + mergeDetectedShapesIntoTrackedCards: vi.fn(() => ({ cards: [], nextCardId: 1 })), + selectCardsReadyForVerification: vi.fn(() => []), + SHAPE_DETECTION_INTERVAL_MS: 10_000, + VERIFICATION_INTERVAL_MS: 10_000, +})); + +import { useCameraScanner } from '../../lib/use-camera-scanner.js'; + +const SESSION_DEVICE_KEY = 'scanner:last-camera-device-id'; + +function createMockStream() { + const track = { stop: vi.fn() }; + return { + getTracks: () => [track], + }; +} + +function attachMockVideo(hookResult) { + const video = document.createElement('video'); + video.play = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(video, 'readyState', { value: 2, configurable: true }); + hookResult.videoRef.current = video; + return video; +} + +async function startCameraWithMetadata(hookResult) { + const video = attachMockVideo(hookResult); + await act(async () => { + await hookResult.startCamera(); + }); + await act(async () => { + video.onloadedmetadata?.(); + await Promise.resolve(); + }); +} + +describe('useCameraScanner — device picker', () => { + let getUserMedia; + let enumerateDevices; + + beforeEach(() => { + sessionStorage.clear(); + getUserMedia = vi.fn().mockResolvedValue(createMockStream()); + enumerateDevices = vi.fn().mockResolvedValue([ + { + deviceId: 'cam-a', + kind: 'videoinput', + label: 'Desk Webcam', + }, + { + deviceId: 'cam-b', + kind: 'videoinput', + label: 'USB Camera', + }, + { + deviceId: 'mic-1', + kind: 'audioinput', + label: 'Built-in Mic', + }, + ]); + + Object.defineProperty(global.navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia, + enumerateDevices, + }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + }); + + it('starts with loading picker status and message', () => { + const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); + + expect(result.current.devicePickerStatus).toBe('loading'); + expect(result.current.devicePickerMessage).toBe('Detecting cameras…'); + }); + + it('enumerates video inputs after stream start', async () => { + const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); + + await startCameraWithMetadata(result.current); + + expect(enumerateDevices).toHaveBeenCalled(); + expect(result.current.videoDevices).toEqual([ + { + deviceId: 'cam-a', + kind: 'videoinput', + label: 'Desk Webcam', + }, + { + deviceId: 'cam-b', + kind: 'videoinput', + label: 'USB Camera', + }, + ]); + expect(result.current.devicePickerStatus).toBe('ready'); + expect(result.current.devicePickerMessage).toBe(''); + }); + + it('uses deviceId exact constraints when selectedDeviceId is set', async () => { + sessionStorage.setItem(SESSION_DEVICE_KEY, 'cam-b'); + + const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); + + expect(result.current.selectedDeviceId).toBe('cam-b'); + + await startCameraWithMetadata(result.current); + + expect(getUserMedia).toHaveBeenCalledWith({ + video: { + deviceId: { exact: 'cam-b' }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + }); + + it('uses facingMode constraints when no selectedDeviceId is set', async () => { + const { result } = renderHook(() => + useCameraScanner({ onError: vi.fn(), facingMode: 'user' }) + ); + + await startCameraWithMetadata(result.current); + + expect(getUserMedia).toHaveBeenCalledWith({ + video: { + facingMode: 'user', + width: { ideal: 1280 }, + height: { ideal: 720 }, + aspectRatio: { ideal: 16 / 9 }, + }, + }); + }); + + it('persists selectedDeviceId to sessionStorage', async () => { + const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); + + await act(async () => { + result.current.setSelectedDeviceId('cam-a'); + }); + + expect(result.current.selectedDeviceId).toBe('cam-a'); + expect(sessionStorage.getItem(SESSION_DEVICE_KEY)).toBe('cam-a'); + }); + + it('restarts the stream when selectedDeviceId changes while streaming', async () => { + const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); + + await startCameraWithMetadata(result.current); + getUserMedia.mockClear(); + + await act(async () => { + result.current.setSelectedDeviceId('cam-b'); + }); + + expect(getUserMedia).toHaveBeenCalledWith({ + video: { + deviceId: { exact: 'cam-b' }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }); + }); + + it('switchFacingMode still toggles facingMode when no deviceId is forced', async () => { + const { result } = renderHook(() => + useCameraScanner({ onError: vi.fn(), facingMode: 'environment' }) + ); + + await startCameraWithMetadata(result.current); + getUserMedia.mockClear(); + + await act(async () => { + result.current.switchFacingMode(); + }); + + expect(result.current.facingMode).toBe('user'); + expect(getUserMedia).toHaveBeenCalledWith({ + video: { + facingMode: 'user', + width: { ideal: 1280 }, + height: { ideal: 720 }, + aspectRatio: { ideal: 16 / 9 }, + }, + }); + }); + + it('reports empty picker status when no video inputs are found', async () => { + enumerateDevices.mockResolvedValue([ + { deviceId: 'mic-1', kind: 'audioinput', label: 'Mic' }, + ]); + + const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); + + await startCameraWithMetadata(result.current); + + expect(result.current.devicePickerStatus).toBe('empty'); + expect(result.current.devicePickerMessage).toBe( + 'No camera found. Connect a webcam or use Upload Image.' + ); + expect(result.current.videoDevices).toEqual([]); + }); + + it('reports denied picker status when getUserMedia throws NotAllowedError', async () => { + const deniedError = new Error('Permission denied'); + deniedError.name = 'NotAllowedError'; + getUserMedia.mockRejectedValueOnce(deniedError); + + const onError = vi.fn(); + const { result } = renderHook(() => useCameraScanner({ onError })); + + await act(async () => { + attachMockVideo(result.current); + await result.current.startCamera(); + }); + + expect(result.current.devicePickerStatus).toBe('denied'); + expect(result.current.devicePickerMessage).toBe( + 'Camera access blocked. Allow camera permission or use Upload Image.' + ); + expect(onError).toHaveBeenCalled(); + }); + + it('reports error picker status when enumerateDevices is unavailable', async () => { + Object.defineProperty(global.navigator, 'mediaDevices', { + configurable: true, + value: { + getUserMedia, + }, + }); + + const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); + + await startCameraWithMetadata(result.current); + + expect(result.current.devicePickerStatus).toBe('error'); + expect(result.current.devicePickerMessage).toBe( + "Couldn't list cameras. Try again or use Upload Image." + ); + }); + + it('falls back to the first device when persisted id is missing', async () => { + sessionStorage.setItem(SESSION_DEVICE_KEY, 'missing-device'); + + const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); + + await startCameraWithMetadata(result.current); + + expect(result.current.selectedDeviceId).toBe('cam-a'); + expect(sessionStorage.getItem(SESSION_DEVICE_KEY)).toBe('cam-a'); + }); +}); diff --git a/test/pages/scanner.test.js b/test/pages/scanner.test.js new file mode 100644 index 0000000..9a819d3 --- /dev/null +++ b/test/pages/scanner.test.js @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; + +const mockRouterPush = vi.fn(); +const mockRouterBack = vi.fn(); + +vi.mock('next/router', () => ({ + useRouter: () => ({ + push: mockRouterPush, + back: mockRouterBack, + pathname: '/scanner', + asPath: '/scanner', + query: {}, + prefetch: vi.fn().mockResolvedValue(undefined), + events: { on: vi.fn(), off: vi.fn(), emit: vi.fn() }, + }), +})); + +vi.mock('next/link', () => ({ + __esModule: true, + default: ({ href, children, ...rest }) => ( + + {children} + + ), +})); + +vi.mock('../../lib/theme-context', () => ({ + useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }), +})); + +const layoutPropsRef = { current: null }; + +vi.mock('../../components/Layout', () => ({ + default: ({ children, chrome, user }) => { + layoutPropsRef.current = { chrome, user }; + return ( +
+ {children} +
+ ); + }, +})); + +vi.mock('../../components/scanner/ScannerCamera', () => ({ + default: ({ variant }) => ( +
+ ), +})); + +vi.mock('../../components/scanner/ScannerCheckoutSheet', () => ({ + default: () =>
, +})); + +vi.mock('../../components/scanner/ScannerResultPanel', () => ({ + default: () =>
, +})); + +vi.mock('../../components/scanner/ScannerHistoryStrip', () => ({ + default: () =>
, + TAB_RECENT: 'recent', + TAB_QUEUE: 'queue', +})); + +vi.mock('../../components/scanner/ScannerTips', () => ({ + default: () => , +})); + +vi.mock('../../components/scanner/ScannerToast', () => ({ + default: () => null, +})); + +const queueFixture = { + scannedCards: [], + collections: [], + selectedCards: new Set(), + isProcessing: false, + addingCardIds: new Set(), + ownershipMap: {}, + unprocessedCount: 0, + handleCardScanned: vi.fn(), + updateCardMetadata: vi.fn(), + addSingleCardToOwned: vi.fn(async () => true), + addSingleCardToCollection: vi.fn(async () => true), + commitSelectedToCollection: vi.fn(async () => []), + commitSelectedToOwned: vi.fn(async () => []), + clearScannedCards: vi.fn(), +}; + +const cameraFixture = { + videoRef: { current: null }, + canvasRef: { current: null }, + videoDevices: [{ deviceId: 'cam-1', label: 'FaceTime HD Camera' }], + selectedDeviceId: 'cam-1', + setSelectedDeviceId: vi.fn(), + devicePickerStatus: 'ready', + devicePickerMessage: '', +}; + +const identificationFixture = { + disambiguation: null, + isIdentifying: false, + identifyFromGalleryFile: vi.fn(async () => {}), +}; + +vi.mock('../../lib/use-auth', () => ({ + useAuth: () => ({ + user: { email: 'scanner@test.com', role: 'user' }, + loading: false, + }), +})); + +vi.mock('../../lib/use-scanner-session.js', () => ({ + useScannerSession: () => ({ + scanDefaults: { condition: 'NM', isFoil: false }, + }), +})); + +vi.mock('../../lib/use-scanner-queue.js', () => ({ + useScannerQueue: () => queueFixture, +})); + +vi.mock('../../lib/use-camera-scanner.js', () => ({ + useCameraScanner: () => cameraFixture, +})); + +vi.mock('../../lib/use-scanner-identification.js', () => ({ + useScannerIdentification: () => identificationFixture, +})); + +vi.mock('../../lib/scanner-session.js', () => ({ + clearScannerCartStorage: vi.fn(), +})); + +import Scanner from '../../pages/scanner.js'; + +function setMatchMedia(matches) { + window.matchMedia = vi.fn((query) => ({ + matches, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })); +} + +describe('Scanner page viewport composition', () => { + beforeEach(() => { + layoutPropsRef.current = null; + vi.clearAllMocks(); + }); + + afterEach(() => cleanup()); + + it('uses immersive Layout chrome and hides workstation surfaces on mobile', () => { + setMatchMedia(false); + + render(); + + expect(layoutPropsRef.current?.chrome).toBe('immersive'); + expect(screen.getByTestId('scanner-camera').getAttribute('data-variant')).toBe('default'); + expect(screen.queryByTestId('scanner-result-panel')).toBeNull(); + expect(screen.queryByTestId('scanner-history-strip')).toBeNull(); + expect(screen.queryByTestId('scanner-checkout-sheet')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Scanner Tips' })).toBeNull(); + }); + + it('uses default Layout chrome and mounts workstation surfaces at md+', () => { + setMatchMedia(true); + + render(); + + expect(layoutPropsRef.current?.chrome).toBe('default'); + expect(screen.getByTestId('scanner-camera').getAttribute('data-variant')).toBe('workstation'); + expect(screen.getByTestId('scanner-result-panel')).toBeTruthy(); + expect(screen.getByTestId('scanner-history-strip')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Scanner Tips' })).toBeTruthy(); + expect(screen.getByLabelText('Camera')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Upload Image' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Batch Scan' })).toBeTruthy(); + expect(screen.getByRole('switch', { name: 'Auto-detect' })).toBeTruthy(); + expect(screen.queryByTestId('scanner-checkout-sheet')).toBeNull(); + }); +});