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>
This commit is contained in:
2026-05-09 21:55:58 +02:00
parent 82ca920b81
commit 3d4bd3fc38
2 changed files with 106 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { mountWithVuexy } from '../utils/mountWithVuexy'
import StageHeaderCell from '@/components/timetable/StageHeaderCell.vue'
import type { Stage } from '@/types/timetable'
/**
* jsdom-runnable assertions for the B2 + B3 prop seam:
*
* - the explicit row-height-px prop applies as inline blockSize
* - the prop's absence falls back to the legacy CSS path (no inline style)
* - the conflict count badge appears only when > 0
*
* Visual proof of the freeze panes — sticky-left alignment, pixel-perfect
* stacking against StageRow — is jsdom-blind and lives on TEST-VISUAL-001.
*/
const stage: Stage = {
id: 's1',
event_id: 'ev1',
name: 'Hardstyle District',
color: '#e85d75',
capacity: 1000,
sort_order: 0,
created_at: null,
updated_at: null,
}
describe('StageHeaderCell — explicit row-height-px prop (B3 freeze-pane alignment)', () => {
it('applies blockSize inline when rowHeightPx prop is provided', () => {
const { wrapper } = mountWithVuexy(StageHeaderCell, {
props: { stage, rowHeightPx: 148 },
})
const root = wrapper.find('[data-stage-id="s1"]')
expect(root.attributes('style')).toMatch(/block-size:\s*148px/)
})
it('does not set blockSize inline when rowHeightPx is omitted (legacy CSS path)', () => {
const { wrapper } = mountWithVuexy(StageHeaderCell, {
props: { stage },
})
const root = wrapper.find('[data-stage-id="s1"]')
const style = root.attributes('style') ?? ''
expect(style).not.toMatch(/block-size/)
})
it('passes through laneCount=10 row height (484px) without truncation', () => {
const { wrapper } = mountWithVuexy(StageHeaderCell, {
props: { stage, rowHeightPx: 484 },
})
const root = wrapper.find('[data-stage-id="s1"]')
expect(root.attributes('style')).toMatch(/block-size:\s*484px/)
})
it('shows the conflict badge only when conflictCount > 0', () => {
const { wrapper: withZero } = mountWithVuexy(StageHeaderCell, {
props: { stage, conflictCount: 0 },
})
expect(withZero.find('.tt-stage-header__conflict').exists()).toBe(false)
const { wrapper: withTwo } = mountWithVuexy(StageHeaderCell, {
props: { stage, conflictCount: 2 },
})
expect(withTwo.find('.tt-stage-header__conflict').exists()).toBe(true)
expect(withTwo.find('.tt-stage-header__conflict').text()).toContain('2')
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { computeStageRowHeight } from '@/lib/timetable/row-height'
/**
* One-line helper, four assertion cases — keep this surface tight; this
* is the canonical row-height math for both StageRow content and the
* sticky-left StageHeaderCell. If either side drifts, the freeze panes
* misalign in the browser. The math:
*
* max(1, laneCount) × (laneHeight + lanePad) + lanePad
*
* with the canonical constants laneHeight=44, lanePad=4 → 48 per lane + 4.
*/
const LANE_HEIGHT = 44
const LANE_PAD = 4
describe('computeStageRowHeight', () => {
it('falls back to single-lane height when laneCount is 0', () => {
expect(computeStageRowHeight(0, LANE_HEIGHT, LANE_PAD)).toBe(52)
})
it('returns 52px for laneCount=1', () => {
expect(computeStageRowHeight(1, LANE_HEIGHT, LANE_PAD)).toBe(52)
})
it('returns 148px for laneCount=3', () => {
expect(computeStageRowHeight(3, LANE_HEIGHT, LANE_PAD)).toBe(148)
})
it('returns 484px for laneCount=10 (10 × 48 + 4)', () => {
expect(computeStageRowHeight(10, LANE_HEIGHT, LANE_PAD)).toBe(484)
})
})