Commit Graph

28 Commits

Author SHA1 Message Date
41b4017bd1 feat(gui-v2): wire OrganizerLayoutV2 to compose the real shell components
Replaces the Plan-1 skeleton stubs: OrganizerLayoutV2 now fills
AppShellV2's #sidebar/#topbar/#drawer slots with the ported AppSidebar /
AppTopbar / RightDrawer and sources orgNavItems via useV2Nav() (legal
now that OrganizerLayoutV2.vue is the layouts-v2 zone). AppShellV2 is
unchanged; its contract test stays green. New component test locks the
composition (right component per slot, :groups forwarded, no skeleton).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 02:04:07 +02:00
a341a60412 feat(boundaries): add layouts-v2 zone so v2 shell layout can use components-v2
AD-G2 ("OrganizerLayoutV2 wraps AppShellV2") was in tension with AD-G5:
src/layouts/OrganizerLayoutV2.vue classified as the v1 `layouts` zone,
which is deliberately barred from components-v2. New `layouts-v2` zone
(src/layouts/*V2*.vue, mode:file) gets pages-v2-equivalent v2 capability;
the v1 `layouts` zone is unchanged so v2 isolation is preserved. RFC
AD-G5 amended; locked by 3 boundaries-v2.spec.ts regression tests (7/7).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 02:03:00 +02:00
4e9eeb99c4 fix(lint): mode:'file' for the components-foundation Icon.vue bridge
Plan-1 Task-4 added { type:'components-foundation', pattern:
'src/components/Icon.vue' } without mode:'file'. eslint-plugin-boundaries
defaults to folder mode, so the single-file pattern never matched and
Icon.vue fell through to the generic `components` catch-all — breaking
the sanctioned components-v2 -> Icon bridge (RFC AD-G5) for every v2
shell component. Plan-1's boundary test only exercised the forms/**
folder-glob edge so the gap was latent. Adds mode:'file' + a regression
test locking the components-v2 -> Icon.vue edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 19:23:00 +02:00
6a45d86b6f feat(v2): boot /v2/dashboard through OrganizerLayoutV2 + AppShellV2
Replaces the Task 3 stub in pages-v2/dashboard.vue with the boot-proof
page (data-testid v2-dashboard, correct definePage meta). Adds the
Playwright CT smoke (appshell-boot.spec.ts) that mounts AppShellV2 in
Chromium and asserts both the shell root and slot content are visible;
uses page scope for the root-element assertion (CT component locator
only matches descendants, not the root itself). Full Plan-1 gate green:
typecheck 0 new errors, eslint clean, 5 vitest files / 21 tests + 2
component tests, vite build succeeded, typed-router has v2-dashboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:57:41 +02:00
73b2dea363 feat(layouts): add OrganizerLayoutV2 + AppShellV2 skeleton
Tailwind-grid shell skeleton with named slot regions (sidebar, topbar,
default, drawer). OrganizerLayoutV2 wires the skeleton with RouterView,
selectable via definePage meta. Vitest component mount test: 2 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 11:44:42 +02:00
2465290614 docs(test): note boundaries/element-types deprecated-alias coupling
Documents that the test filter and the .eslintrc rule key both use the
v5-era 'boundaries/element-types' alias; a future eslint-plugin-boundaries
bump that drops the alias must update both together or the filter silently
matches nothing. Addresses the Task 4 code-review Minor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 09:55:02 +02:00
b1d3b9f53b feat(lint): add components-v2/pages-v2 boundary zones (no back-port)
Adds three new eslint-plugin-boundaries element zones and their matrix
rows so the GUI-redesign v2 surface is structurally isolated: v1 code
cannot import from v2 (back-porting forbidden), v2 can reach the
narrow FormField/Icon bridge via the components-foundation zone, and
pages-v2 can import from components-v2. Backed by a Vitest spec
running via the ESLint Node API (node environment; happy-dom's
document object breaks the case-police resolver). Adds a placeholder
src/components-v2/shared/X.vue so the resolver can classify the
import target during the test (unresolvable imports are not boundary-
checked by the plugin).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 09:46:31 +02:00
2dfb1e8bae test(e2e): real-backend 409 conflict contract test (TEST-CONTRACT-001)
B4 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).

- Add api/database/seeders/E2EBaselineSeeder.php — deterministic seed
  for Playwright e2e: e2e@test.local user (org_admin) on a fresh org +
  event + stage + StageDay + artist + engagement + performance
  (version=0). Writes seeded IDs to api/storage/app/e2e-fixtures.json
  so the Playwright fixture can construct API URLs without API
  discovery calls.
- Add apps/app/tests/playwright-e2e/global-setup.ts — runs
  `php artisan migrate:fresh --force --seed` against crewli_test (the
  existing PHPUnit MySQL test DB) before the test suite starts.
  Uses --env=testing to satisfy the dangerous-bash hook's migrate:fresh
  guard.
- Add apps/app/tests/playwright-e2e/utils/fixtures.ts — typed reader
  for e2e-fixtures.json. Cached after first read.
- Add apps/app/tests/playwright-e2e/utils/auth.ts — login helper that
  POSTs /api/v1/auth/login and returns user/org IDs. Uses Bearer-via-
  cookie flow (per api/.../SetAuthCookie.php), not stateful Sanctum.
- Add apps/app/tests/playwright-e2e/timetable/409-conflict.spec.ts —
  the contract test: first move with version=0 returns 200, second
  move with same stale version returns 409 with shape
  `errors.conflict: 'version_mismatch'`. Catches the schema-drift
  bug class that timetable-stabilization B5 surfaced.
- Update apps/app/playwright.config.ts — wire globalSetup, webServer
  for `php artisan serve --port=8001`, baseURL `http://localhost:8001`
  (NOT 127.0.0.1 — auth cookie's domain=localhost requires hostname
  match).
- Update .gitignore — runtime e2e-fixtures.json never committed.

DoD-19 met locally: `pnpm test:e2e` passes against a real Laravel
test server. CI integration deferred to TEST-INFRA-002 (per A-1
amendment).

Constraint: e2e tests share the crewli_test DB with PHPUnit. Running
both concurrently would collide. Documented in ARCH-TESTING.md (B5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:24:33 +02:00
f6509d938b test(visual): prototype static-server fixture + 5 composite baselines (TEST-VISUAL-001)
B3 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).

- Add tests/playwright-ct/visual/static-server.mjs: 60-line Node http
  server that serves the canonical prototype directory. No new
  dependency added (vs. http-server / serve packages).
- Wire static server into playwright-ct.config.ts via webServer; tests
  navigate to http://127.0.0.1:5179/crewli-timetable.html.
- Add tests/playwright-ct/visual/prototype-smoke.spec.ts to verify the
  prototype loads in CT runner.
- Add tests/playwright-ct/visual/prototype.spec.ts with 5 @visual
  composite baselines:
    canvas-friday.png       — all status colors, b2b indicators,
                              multi-lane stacking
    canvas-saturday.png     — conflict ring + capacity warnings
    stage-row-multilane.png — first row in isolation
    wachtrij-populated.png  — sidebar list with parked + pending
    popover.png             — block-click popover layout
  9 additional surfaces from RFC §A.3's enumerated list are documented
  as test.skip() with reasons (cancelled status absent from prototype
  data, isolated-block locators would lock to artist names, drag-mode
  flaky under simulated pointer events, empty Wachtrij/empty day not
  reachable from canonical seed). All deferred to F4 component-level
  Vue baselines that will use stable data-test-id attributes.
- Baselines stored at tests/playwright-ct/__screenshots__/visual/
  prototype.spec.ts/*.png; tracked via Git LFS (.gitattributes).

Composite-over-isolated rationale: the prototype's DOM exposes status
only via inline style.background, no data-* attributes. Isolated-block
baselines would require artist-name locators that silently rot if
prototype data changes. Composite captures yield the same visual
vocabulary in fewer, more stable images. dev-docs/ARCH-TESTING.md (B5)
documents this strategy and the F4 transition plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:04:51 +02:00
82af11754a test(infra): mountWithProviders helper + Vuetify CT sanity test
B2 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).

- Add tests/playwright-ct/utils/mountWithProviders.ts: ergonomic
  wrapper around Playwright CT's mount() exposing buildMountArgs()
  and readNotificationState(). Documents the Vue Test Utils ↔
  Playwright CT API divergence (provider plugins must be wired in
  beforeMount, not at call time) and the Vuetify-temp lifecycle
  (replaced by PrimeVue in F3).
- Add tests/playwright-ct/components/SanityButtonHarness.vue: a
  v-btn harness with a click counter; lives in a .vue file so Vite
  bundles its CSS-side-effect imports for the browser context
  (Playwright CT runs the test orchestrator in Node and components
  in a Vite-bundled browser, unlike Vitest's single jsdom graph).
- Add tests/playwright-ct/components/sanity-vuetify.spec.ts: two
  tests proving (a) v-btn renders and propagates clicks, (b) the
  --v-theme-primary CSS variable resolves to a parseable RGB triplet.
- Update playwright/index.ts: import 'vuetify/styles' so the v-btn
  renders with its actual visual appearance (not unstyled). Required
  for B3's visual baselines.

3 component tests pass. 402 Vitest tests still pass unchanged.
Lint + typecheck clean on new files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:56:48 +02:00
b8d18e63af chore(test-infra): install Playwright + axe-core; configure CT and e2e runners; enable Git LFS for screenshots
B1 of TEST-INFRA-001 (RFC-WS-FRONTEND-PRIMEVUE Amendment A-1).

- Add @playwright/test, @playwright/experimental-ct-vue,
  @axe-core/playwright as dev deps in apps/app
- Add @vue/compiler-dom (transitively required by ct-vue's Vite build
  pipeline; not auto-resolved on Vite 7)
- Install Chromium via `playwright install chromium` (host cache only,
  not committed)
- Configure Git LFS clean/smudge filters globally; track
  apps/app/tests/playwright-{ct,e2e}/__screenshots__/**/*.png
- Integrate `git lfs pre-push` into lefthook.yml since LFS's per-repo
  hook would conflict with the existing sync-staleness hook
- Add playwright/index.html + playwright/index.ts hook file with the
  full provider stack (Vuetify [TEMPORARY: replaced in F3 by PrimeVue],
  Pinia, TanStack Vue Query, memory-history Router with no auth
  guards)
- Add playwright.config.ts (e2e, Chromium-only, baseURL :5173, auto-
  starts `pnpm dev` via webServer)
- Add playwright-ct.config.ts (component testing, Linux-Chromium-only
  baselines, maxDiffPixelRatio 0.001, snapshot path template,
  ssr.noExternal: ['vuetify'] mirroring vitest.config.ts)
- Add scripts: test:component, test:e2e, test:visual,
  test:visual:update
- Add smoke test proving Chromium boots in the CT runner
- Update .gitignore for Playwright runtime artifacts (test-results/,
  playwright-report/, blob-report/, playwright/.cache/)

Vitest's existing 402 tests still pass unchanged.
DoD-17 / DoD-19 CI integration deferred to TEST-INFRA-002 per Amendment
A-1 scope cut (no CI exists in this repo today).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 14:53:57 +02:00
bce3081cb2 test(timetable): add real-API contract fixtures as schema regression test (3 shape variants) (B6)
apps/app/tests/unit/schemas/timetableContractShape.test.ts (NEW, 5 tests):
  - base shape: one performance with stage assigned + full engagement
    (Bert's browser-tested sample, field-for-field). Asserts decimal-as-
    string contract on fee_amount/buma_percentage/vat_percentage AND
    enum-label wrapper on booking_status AND nested computed object.
  - parked shape: stage_id=null, stage=null (Wachtrij case)
  - multi-perf shape: two performances sharing engagement_id
    (RFC §D17 "Friday + Saturday under one combined deal")
  - sanity: individual performanceSchema parses each fixture element
  - regression guard: a payload with NUMBER fee_amount throws (locks
    out the pre-B5 bug class)

Every fixture spells out explicit `null` for the schema's nullable-but-
required fields (timestamps, notes, deal_breakdown) so the
nullable() vs optional() distinction is exercised, not glossed over.

Schema surface change to support the test:
  apps/app/src/schemas/timetable.ts now EXPORTS performanceArraySchema
  (previously a private const inside useTimetable.ts).
  apps/app/src/composables/api/useTimetable.ts imports the shared one
  instead of redeclaring it locally — single source of truth for the
  array shape consumers and tests share.

Test count: 397 → 402 (+5). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:33:13 +02:00
1eee1f9415 fix(timetable): align Zod decimal fields with backend wire format (decimal-as-string per Laravel cast) (B5)
Phase A diagnosed the "Kon timetable niet laden" browser symptom as Zod
schema drift. The prompt's hypothesis (enum {value, label} mismatch) was
incorrect — the schema already uses the enumLabel() wrapper for every
enum field. The actual drift is decimal-cast columns: Laravel serialises
`decimal(N,M)` columns as strings to preserve precision, but the schema
expected numbers, so the very first response triggered a ZodError.

Affected fields, all on `artist_engagements`:
  fee_amount         decimal(10,2)  → wire `"11503.58"`, schema was z.number()
  buma_percentage    decimal(5,2)   → wire `"7.00"`,     schema was z.number()
  vat_percentage     decimal(5,2)   → wire `"21.00"`,    schema was z.number()
  deposit_percentage decimal(5,2)   → wire `"…"`,        schema was z.number()

Backend has no explicit `decimal:N` cast on these columns
(api/app/Models/ArtistEngagement.php:64-85 — the `casts()` method covers
the enums + booleans + dates + integers, but skips decimals).

Per the strategic decision (frontend adapts, backend stays):
  - schemas/timetable.ts: four fields → z.string().nullable()
  - types/timetable.ts: matching ArtistEngagement interface fields →
    `string | null`
  - PerformancePopover.vue:129: only consumer doing arithmetic on a
    decimal field; coerce at the use site via Number(...).toFixed(2).
    Single line.
  - tests/component/PerformanceBlock.test.ts + tests/a11y/axe.test.ts:
    spot-checked mocks; the two with hand-built engagement payloads
    flipped fee_amount/buma_percentage/vat_percentage from numbers to
    strings to match the new schema. No other mocks needed updating.

The {value, label} enum wrapper claim in the prompt was specifically
debunked in Phase A — every consumer (Wachtrij, PerformanceBlock,
WachtrijCard, PerformancePopover, AddPerformanceDialog, page entry)
already uses .value/.label access against an enumLabel-wrapped schema.

B6 will lock the wire-format contract with a real-API fixture
regression test.

All 397 tests still pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:33:13 +02:00
3d4bd3fc38 test(timetable): row-height helper + StageHeaderCell prop seam (B4)
B4 — jsdom-runnable assertions for the structural pieces of B2/B3.

apps/app/tests/unit/lib/timetable/row-height.test.ts (4 tests):
  - laneCount=0 → 52px (Math.max(1, 0) fallback path)
  - laneCount=1 → 52px (single-lane stage row)
  - laneCount=3 → 148px
  - laneCount=10 → 484px (10 × 48 + 4)

apps/app/tests/component/StageHeaderCell.test.ts (4 tests):
  - row-height-px prop applies as inline blockSize on the root
  - prop omitted → no inline blockSize set (legacy `block-size: 100%`
    CSS path takes over for any caller still relying on parent-driven sizing)
  - 484px for laneCount=10 round-trips through the prop without truncation
  - conflict badge renders only when conflictCount > 0 (existing behavior;
    locked in as part of touching this surface)

Visual scroll/alignment proof (sticky-left freeze pane, sticky-top axis,
horizontal scroll cohesion across 14 stages, diagonal trackpad scroll,
pixel-perfect header↔row alignment) is deferred to TEST-VISUAL-001
explicitly: jsdom does not compute position:sticky offsets, scrollbar
visibility, layout overflow chains, or scroll containment ancestry. This
is a known limitation of jsdom-based component testing — not a test gap
in this branch. The sticky behavior, z-index ladder, and DOM structure
are all in place per E1-E4; their validation requires a real browser,
which is exactly what the Playwright CT migration on TEST-INFRA-001 +
TEST-VISUAL-001 unlocks.

No existing tests asserted the old broken layout (no references to the
deprecated `tt-page__rows`, `tt-page__stages`, or `<GridBg>` in tests/).
The unused GridBg component file remains on disk; deleting it is a
stylistic cleanup outside this stabilization scope.

Test count: 389 → 397.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 00:33:12 +02:00
3b255a36de feat(events): add Programma tab to EventTabsNav for timetable access
The timetable canvas page at /events/{event}/timetable was added in
RFC-TIMETABLE Session 4 but had no UI entry point. EventTabsNav now
exposes it as the "Programma" tab between Artiesten and Briefings on
flat events, and between Artiesten and Briefings on festivals (in the
re-ordered tab list, post-Artiesten / pre-Briefings).

Changes:
- baseTabs gains the Programma entry at position 6 (after Artiesten).
- The festival re-order computed switches from positional indexing
  (baseTabs[5], [6], [7]) to name-based lookup via a findTab helper —
  insertions to baseTabs no longer break the festival branch.
- Icon: tabler-calendar-time. Conservative Dutch label "Programma" —
  doesn't collide with "Programmaonderdelen" (the festival sub-events
  page) since festivals see both tabs side-by-side.

vitest.config.ts: extend the component-project AutoImport to include
'vue-router' so tests of components that auto-import useRoute/useRouter
mount cleanly. (EventTabsNav was the first such test.)

tests/component/EventTabsNav.test.ts (NEW, 4 assertions):
- Programma tab is rendered with the correct label
- it carries the tabler-calendar-time icon
- the route binding resolves to the events-id-timetable name with the
  /events/.../timetable URL pattern
- the tab is also visible on a festival (re-ordered tab list path)

Mocks the useEvents composables so the component skips its skeleton/
error branches and renders tabs immediately.

Test count: 385 → 389.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-09 08:58:22 +02:00
985a5ab987 test(timetable): full add → drag → resize → park → delete integration flow (Step 12)
Two integration tests that drive the entire RFC D17 lifecycle through
the mutation composable + TanStack cache:

  1. happy-path lifecycle (5 stages):
     - ADD       → POST /performances + Idempotency-Key
     - DRAG      → POST /timetable/move (target_lane=1, version bumps),
                   server returns cascaded[] sibling — both surface in
                   the resolved Promise
     - RESIZE    → POST /timetable/move with new end_at + new version
     - PARK      → POST /timetable/move with target_stage_id=null
     - DELETE    → DELETE /performances/p1
     final wire: 4 POSTs + 1 DELETE
  2. drag rollback on 409:
     - server returns version_mismatch
     - mutation rejects with VersionMismatchError shape
     - notification.show() invoked with the Dutch toast + 'error'

Why not the full page mount: events/[id]/timetable/index.vue requires
EventTabsNav, useEventDetail, useEventChildren, multiple VTabs/VBtn/
VDialog teleports — too brittle for jsdom CI. The end-to-end + visual
flavour of this flow lives on TEST-INFRA-001's Playwright migration
backlog (and TEST-CONTRACT-001 covers the 409 path against a real
backend).

Test count: 383 → 385.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:55:22 +02:00
66a6f7ddc3 test(timetable): axe-core zero-violation a11y enforcement (Step 11)
Three jsdom axe scans covering the user-facing surface of the canvas.
The scans surfaced two real a11y bugs which are fixed in this same
commit:

  1. PerformancePopover — VProgressLinear (advancing aggregate) had no
     accessible name. Added aria-label that announces "X van Y secties
     afgerond (N%)".
  2. AddPerformanceDialog — the icon-only close button (×) was missing
     aria-label. Added 'Sluiten'.

Test scenarios:
  - PerformanceBlock with focus
  - PerformancePopover open
  - AddPerformanceDialog open

Page-level axe rules (region, page-has-heading-one, landmark-one-main,
color-contrast) are disabled for fragment scans — they only make sense
on a full page, and color-contrast resolution is jsdom-blind. Both are
covered by Playwright CT in TEST-INFRA-001 / TEST-VISUAL-001.

Test count: 380 → 383.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:53:16 +02:00
b65969c459 test(timetable): keyboard a11y end-to-end (Step 10)
11 tests for useTimetableKeyboard (RFC v0.2 D20):

  - Arrow Left  → nudge(-SNAP_MIN, 0, 0)
  - Arrow Right → nudge(+SNAP_MIN, 0, 0)
  - Shift+Arrow → nudge(±60min)
  - Arrow Up/Down → ±lane
  - Shift+Arrow Up/Down → ±stage
  - ] / [        → cycle stages preserving time + lane
  - Enter        → openPopover with the selected performance
  - Delete       → remove with the selected performance
  - Space → drag mode + aria-live announce; Arrow keys accumulate; Enter
    commits with the cumulative offset; aria-live announces 'bevestigd'
  - Esc cancels keyboard drag, no mutation, aria-live announces 'geannuleerd'
  - all keys are no-ops when no performance is selected

Tests the composable directly with a host component that owns a focusable
canvas root and exposes the spies + announce ref — much more reliable
than mounting the whole timetable page (heavy + asynchronous).

Test count: 369 → 380.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:49:58 +02:00
fbfe72d090 test(timetable): useTimetableMutations 409 rollback + idempotency-key semantics (Step 9)
Minimal seam in src/composables/api/useTimetableMutations.ts: the move()
mutation's onError now calls useNotificationStore().show(...) on a 409
status. Generic axios errors stay quiet here — the global response
handler in lib/axios/factory.ts already toasts those. RFC D14 wanted
the version-mismatch toast specifically.

apps/app/tests/component/useTimetableMutations.test.ts (NEW, 5 tests):
  - on success: returns server payload with bumped version + sends the
    Idempotency-Key supplied by the caller
  - 409: rejects with VersionMismatchError + notification.show()
    invoked once with the Dutch translation + 'error' level
  - cascade: success with cascaded[] populated puts those peers into
    the result.cascaded array
  - Idempotency-Key uniqueness: two distinct logical move() calls send
    distinct keys
  - Idempotency-Key reuse: caller-controlled retry within the same
    logical action sends the SAME key on the wire (so the backend's
    60s idempotency middleware dedupes)

The two existing unit-project tests now register a Pinia instance
(createPinia + setActivePinia) so useNotificationStore() resolves.
Existing assertions unchanged.

Test count: 364 → 369.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:48:39 +02:00
8db6ca6024 test(timetable): AddPerformanceDialog validation + submit (Step 8)
4 component tests via mountWithVuexy:

  - happy path: valid form values → POST /performances called with the
    correct body shape (engagement_id, event_id mapped from dayId,
    stage_id, start_at, end_at)
  - end_at < start_at → submit blocked, schema-level error visible on
    the end_at field
  - empty engagement_id → submit blocked, error visible on the engagement_id
    field
  - cancel button → emits update:modelValue=false

Test seam: AddPerformanceDialog.vue gains `defineExpose({ form, errors,
submit })` so jsdom tests can drive validation deterministically without
piping through Flatpickr / VAutocomplete plumbing. Three lines, exposes
internal refs only — no behavioural change.

VDialog stubbed in the test (it teleports to body, which puts content
outside the wrapper); App* wrappers stubbed (we test the schema +
submit pipeline, not Flatpickr ergonomics).

Test count: 360 → 364.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:43:03 +02:00
1e7eba80a8 test(timetable): StageRow lane stacking + Wachtrij rendering & drag (Step 7)
StageRow.test.ts (5):
  - renders one PerformanceBlock per performance
  - lane_resolved drives vertical stacking (different lanes → different topPx)
  - empty stage row renders zero blocks
  - horizontal position = (start_at - gridStart) × pxPerMin (60 min × 2 = 120px)
  - block-pointerdown event bubbles up as blockPointerdown

Wachtrij.test.ts (5):
  - one card per parked performance
  - empty wachtrij shows "Geen optredens" copy
  - card pointerdown emits cardPointerdown with the parked performance
  - card click emits cardSelect with the performance + DOMRect
  - count badge reflects performances.length

Test count: 350 → 360.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:39:48 +02:00
210c443cc9 test(timetable): PerformanceBlock visual states + interactions (Step 6)
17 component-level tests via mountWithVuexy:

Visual states (10):
  - status palette × 3 (option, confirmed, cancelled) — asserts both the
    CSS class AND that the matching --tt-status-{X}-bg custom property
    resolves on :root (proves the token sheet really loaded)
  - capacity icon present when crew + guests > stage.capacity
  - capacity icon absent when sum ≤ capacity
  - capacity icon absent when stage.capacity is null (no warning possible)
  - B2B left dot present when b2bLeft prop true
  - B2B right dot present when b2bRight prop true
  - no dots when neither prop true
  - conflict ring class when warnings includes 'overlap'
  - cascade-pulse class when pulse=true
  - aria-label includes artist + stage + status + HH:mm time window
  - tabindex="0" for keyboard focus

Interactions (5, in second describe):
  - click → emits select with performance + DOMRect
  - pointerdown → emits pointerdown with (event, performance)
  - Delete keypress → emits delete
  - Enter keypress → emits select

Test count: 333 → 350.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:38:46 +02:00
e99acbde95 fix(timetable): make ?day query the source of truth with validation and fallback
Per Phase A finding A6 — the previous three-watcher Pinia-store design had
no validation. Landing on /events/{e}/timetable?day=DOES_NOT_EXIST quietly
set store.activeDayId to that bogus value and showed an empty page.
Cross-org sub-event IDs were silently accepted (backend OrganisationScope
returned an empty perf list, so the UI looked broken without telling the
user).

New design (Session 4 follow-up Step 5):

- src/composables/timetable/useActiveDay.ts (NEW)
  - The URL `?day` is the source of truth; Pinia does NOT hold this value.
  - `activeDayId` is a computed: queryDay if it appears in `validIds`,
    else the first valid id, else null when the list is empty.
  - One corrective watcher (immediate:true, flush:'post') quietly rewrites
    the URL when `?day` is missing or invalid; runs after Vue settles and
    after validIds has been recomputed from a fresh fetch.
  - `setActiveDay(id)` is the user-driven entry point — calls replace().
  - Cross-org IDs are blocked transparently: OrganisationScope keeps them
    out of validIds, so they fail the .includes() check and fall back.

- src/stores/useTimetableStore.ts
  - Removed `activeDayId` state and `setActiveDay()` action; the store
    docstring now documents that day-state lives at the URL.

- src/pages/events/[id]/timetable/index.vue
  - Replaced the three watchers + onMounted bootstrap with one
    `useActiveDay({ queryDay, validIds, replace })` call. The day-change
    side-effect watcher (clear drag, deselect performance) stays.
  - VTabs binds dayIdRef + setActiveDay directly.

- tests/unit/pages/timetableDaySync.test.ts (NEW, 9 tests)
  - Valid ?day=X → activeDayId=X, no URL rewrite.
  - Missing / invalid / cross-org ?day → fallback + URL replaced once.
  - Empty validIds → activeDayId=null, URL untouched.
  - setActiveDay(id) → calls replace.
  - setActiveDay(null) → no-op.
  - External URL change (browser back) → activeDayId follows.
  - validIds populated AFTER mount → fallback fires correctly.

- tests/unit/stores/useTimetableStore.test.ts: assert that activeDayId
  and setActiveDay are GONE from the store surface.

Test count: 324 → 333.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:37:31 +02:00
8d1cb39172 feat(timetable): validate API responses against Zod schemas at runtime
Per Phase A finding A5 — Zod schemas in @/schemas/timetable.ts were
types-only; nothing parsed actual server responses. Backend → frontend
contract drift would only surface as TypeError deep in components.

useTimetable.ts queries now parse:
  - useStages       → stageArraySchema.parse()
  - usePerformances → performanceArraySchema.parse()
  - useWachtrij     → performanceArraySchema.parse()
  - useEngagement   → artistEngagementSchema.parse()

useTimetableMutations.ts mutations now parse:
  - move success    → moveTimetableSuccessSchema.parse()
  - move 409 errors → moveTimetableConflictSchema.parse() (the .errors
                      sub-object — see backend canon at TimetableMoveController:64)
  - create / updateNotes → performanceSchema.parse()
  - createStage / updateStage → stageSchema.parse()

The move() success parse runs OUTSIDE the try/catch so a Zod failure on
a 200 response surfaces as a true error rather than being misclassified
as a 409. Per Phase A finding A8 the conflict shape already matches
backend field-for-field; no schema correction needed, but the parse()
locks future drift in.

Regression test (tests/unit/composables/api/zodParseFailure.test.ts):
  - move() success with missing fields → rejects with ZodError
  - move() 409 with malformed errors payload → rejects with ZodError
  - createStage() with missing fields → rejects with ZodError

Existing test fixture for createStage was missing created_at/updated_at;
fixed in same commit (real backend responses always include them).

Test count: 321 → 324.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:32:21 +02:00
5f135ec2b9 test: add mountWithVuexy helper, install axe-core, segment vitest configs
Foundation for the upcoming component / integration / a11y tests.

vitest.config.ts now declares two projects:
  - "unit"      — pure-logic tests under tests/unit/, src/**/__tests__/,
                  and tests/*.spec.ts (the legacy sanity test).
                  happy-dom, no Vuetify, fast path.
  - "component" — tests under tests/component/, tests/integration/,
                  tests/a11y/. jsdom, Vuetify inlined via SSR noExternal,
                  CSS imports processed (so :root token sheet loads), and
                  no global vue-router mock so the real router can run.

Both share the same alias map and AutoImport bag.

tests/utils/mountWithVuexy.ts (new):
  - Real Vuetify with the Crewli theme tokens
  - createTestingPinia (actions execute by default; stubActions opt-in)
  - vue-router with memory history at the configured initialPath + ?query
  - Fresh QueryClient per call (zero cross-test cache leak)
  - Notification mock injected via Pinia plugin so any useNotificationStore()
    resolves to { show: vi.fn(), hide: vi.fn() } — matches the actual
    NotificationStore API surface (per Phase A finding A4)
  - Imports `@/styles/tokens/_timetable.css` at module load so JSDOM resolves
    var(--tt-…) when components call getComputedStyle()

tests/setup.component.ts (new):
  - vitest-axe matcher registration
  - JSDOM polyfills: scrollIntoView, ResizeObserver, visualViewport, body
    bounding rect — Vuetify menus / overlays would crash without them
  - Deterministic crypto polyfill (mirrors tests/setup.ts so
    generateIdempotencyKey() is stable, but without the router mock)

tests/component/_smoke.test.ts (new):
  - Mounts a trivial component → asserts wrapper, queryClient, pinia,
    router, notificationMock all populated
  - Calls getComputedStyle(documentElement).getPropertyValue('--tt-status-confirmed-bg')
    → asserts '#e8f8f0' (proves the CSS token sheet really loaded)

devDependencies added: jsdom, axe-core, vitest-axe, @pinia/testing.

Total: 319 → 321 tests; 42 → 43 files. Both projects green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:27:31 +02:00
39fdc0fa3d test(timetable): Phase C — 67 new tests (pure logic + composables + store + schemas)
apps/app/tests/unit/lib/timetable/:
  - snap.test.ts (5)            — rounding, clamp, edge cases
  - time-grid.test.ts (6)       — px↔min↔ISO roundtrips, formatTickLabel
  - conflict.test.ts (8)        — overlap, endpoint-touching, lane/stage scoping, cancelled exclusion
  - b2b.test.ts (6)             — 0min, 2:59, 3:01, overlap, side-set mapping, threshold constant
  - capacity.test.ts (7)        — null capacity, missing data, warn/critical, crew+guests preference
  - lane.test.ts (8)            — Pass 1 + Pass 2, cascade-bump preview, cancelled exclusion

apps/app/tests/unit/composables/:
  - useTimetableMutations.test.ts (5) — Idempotency-Key header, optimistic + cascade,
                                         409 VersionMismatch surfaced, park sends null,
                                         createStage POST path
  - useDragOrClick.test.ts (3)        — onClick fires under threshold, onDragStart+End
                                         above threshold, Esc cancels mid-flight

apps/app/tests/unit/schemas/timetable.test.ts (8) — payload + response zod parsers
apps/app/tests/unit/lib/idempotencyKey.test.ts (3) — 6-30 char range, 24-hex, uniqueness
apps/app/tests/unit/stores/useTimetableStore.test.ts (5) — defaults, toggleStatus, drag state, null guard

Refactor: useTimetableMutations.move now throws Error instances (no-throw-literal)
so AxiosError.message and the VersionMismatchError shape both bubble through .catch().

Test count: 252 → 319 (+67). All 42 files pass.

Out of scope this session (added to BACKLOG):
- ART-PERFORMANCEBLOCK-COMPONENT-TESTS — Vuetify intentionally not loaded in
  vitest.config.ts; a Vuexy-stub setup for component-mount tests is one PR of
  its own. Pure rendering logic (capacity, B2B, conflict) is fully covered at
  the lib/ layer.
- ART-AXE-CORE-A11Y-TESTS — axe-core not yet installed in the repo. The
  aria-label structure on PerformanceBlock + aria-live on the page entry are
  authored to pass an axe scan when added.
- ART-INTEGRATION-FLOW-TEST — full add → drag → resize → park flow needs
  Vuetify + router + msw setup; defer with the component tests above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 02:04:10 +02:00
98ec51fcbd refactor(portal): move components to shared/public-form and portal/{event,*}
- public-form/** (18 files + 7 component tests) → shared/public-form/**
  This is the runtime form-renderer; goes into shared/ because it will
  be reused by the organizer-app Form Builder preview (S3b).
- event/{Claimen,Informatie,Overzicht,Rooster}Tab.vue → portal/event/**
- portal/{StatusCard,EventCard,UserAvatarMenu}.vue → portal/** (no
  path change — both apps had a portal/ subfolder).
- AppLoadingIndicator.vue, auth/{PasswordRequirements,MfaChallengeCard}.vue,
  settings/Mfa{Disable,Email,Totp}SetupDialog.vue: portal copies
  deleted as duplicates of pre-existing apps/app components (diffs
  were trivial formatting only).

Inside the moved files: rewrote @form-schema/* → @/composables/forms/*
and @/components/{public-form,event/[Tab]} → new sub-zone paths.

Updated apps/app/tsconfig.json to drop the @form-schema path alias
and the packages/form-schema include path. Updated formSchema.ts to
import from @/composables/forms/types/formBuilder. Carried the
crypto polyfill from apps/portal/tests/setup.ts into
apps/app/tests/setup.ts (needed by useFormDraft tests landing in C.4).

NOTE: Some moved tests still fail because they reference portal
composables (usePublicFormSections, useFormDraft) that move in C.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:04:49 +02:00
d95e68423d test(apps/app): set up Vitest harness — closes TECH-APP-VITEST (WS-6)
Mirrors apps/portal's Vitest setup so the SPA can take frontend
unit + component tests. Required prerequisite for WS-6 sessie 3b's
admin UI work — apps/portal had 113+ tests, apps/app had zero, and
launching WS-6's organizer UI uncovered while the portal SPA is
well-tested would be asymmetric quality.

Setup:
- vitest, happy-dom, @vue/test-utils, @testing-library/vue installed
- vitest.config.ts mirrors portal config: trimmed auto-imports
  (no pinia/vue-router/vue-i18n/@vueuse/math) so tests run fast
  in happy-dom without loading the full Vuexy bundle
- AutoImport's dts:false prevents the trimmed test-only set from
  clobbering the dev-server's full auto-imports.d.ts (apps/app's
  auto-import surface is bigger than the portal's)
- tests/setup.ts mocks vue-router by default; tests that exercise
  the real router can override per-suite
- Sample sanity test confirms the harness works end-to-end

Adds `pnpm test` and `pnpm test:watch` scripts to package.json.

Refs: BACKLOG TECH-APP-VITEST, WS-6 sessie 3b prerequisite

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 00:14:18 +02:00