feat(07-03): layout.spec.ts — UI-SPEC Rules 1-4 + harness self-validation
- Rule 1: boundingBox assertions for BottomTabBar Calendar/Lists tabs (≥44px), PhoneNav settings button (≥44px), New Event FAB (≥56px) on both profiles - Rule 2: scrollWidth ≤ clientWidth on /calendar and /lists - Rule 3: BottomTabBar in-viewport (bottom edge ≤ viewport height), PhoneNav visible - Rule 4: navigation landmark locatable by role+name (getByRole with accessible name) - Self-validation: addStyleTag injection proves Rule 1 tracks geometry (20px height <44), proves Rule 2 detects overflow (2000px body width); both recover after removal - Runs on iphone (WebKit/390px) and pixel (Chromium/412px) profiles; 30 tests pass
This commit is contained in:
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* layout.spec.ts — TEST-01
|
||||||
|
*
|
||||||
|
* Cross-route structural quality-bar assertions (UI-SPEC Rules 1-4):
|
||||||
|
* Rule 1: Touch-target minimum ≥44×44px (FAB ≥56×56px)
|
||||||
|
* Rule 2: No horizontal overflow (scrollWidth ≤ clientWidth)
|
||||||
|
* Rule 3: Critical elements visible and in-viewport on initial load
|
||||||
|
* Rule 4: Accessible names on all interactive elements (role+name locators)
|
||||||
|
*
|
||||||
|
* Runs on both device profiles automatically (playwright.config.ts matrix):
|
||||||
|
* iphone: iPhone 14 / WebKit / 390×844
|
||||||
|
* pixel: Pixel 7 / Chromium / 412×915
|
||||||
|
*
|
||||||
|
* STRICT-MODE NOTE:
|
||||||
|
* On mobile viewports (≤767px), AppNav renders PhoneNav as a <header> element
|
||||||
|
* (NOT a nav landmark) — it does NOT expose a navigation landmark. BottomTabBar
|
||||||
|
* renders the sole <nav aria-label="Main navigation"> on mobile. There is no
|
||||||
|
* strict-mode collision on these profiles.
|
||||||
|
* The DesktopNav <nav aria-label="Main navigation"> is only rendered at ≥768px
|
||||||
|
* and is not visible on either test profile.
|
||||||
|
*
|
||||||
|
* Run:
|
||||||
|
* pnpm --filter @familysync/pwa test:e2e
|
||||||
|
* pnpm --filter @familysync/pwa exec playwright test --project=pixel layout.spec.ts
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
// ── Rule 1 + Rule 3 + Rule 4: BottomTabBar tap targets, visibility, accessible names ──
|
||||||
|
|
||||||
|
test.describe('Rule 1/3/4 — BottomTabBar tap targets and in-viewport position', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/calendar')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BottomTabBar navigation landmark is visible (Rule 4 — accessible name)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// On mobile profiles the sole navigation landmark is the BottomTabBar nav.
|
||||||
|
// getByRole succeeds ↔ accessible name exists — doubles as Rule 4 gate.
|
||||||
|
await expect(
|
||||||
|
page.getByRole('navigation', { name: 'Main navigation' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Calendar tab meets 44×44px touch-target minimum (Rule 1)', async ({ page }) => {
|
||||||
|
// Scope to the navigation landmark to stay robust if desktop nav ever appears.
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||||
|
const calTab = nav.getByRole('link', { name: 'Calendar' })
|
||||||
|
const box = await calTab.boundingBox()
|
||||||
|
expect(box, 'Calendar tab bounding box must not be null').not.toBeNull()
|
||||||
|
expect(box!.width, 'Calendar tab width ≥ 44px').toBeGreaterThanOrEqual(44)
|
||||||
|
expect(box!.height, 'Calendar tab height ≥ 44px').toBeGreaterThanOrEqual(44)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Lists tab meets 44×44px touch-target minimum (Rule 1)', async ({ page }) => {
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||||
|
const listsTab = nav.getByRole('link', { name: 'Lists' })
|
||||||
|
const box = await listsTab.boundingBox()
|
||||||
|
expect(box, 'Lists tab bounding box must not be null').not.toBeNull()
|
||||||
|
expect(box!.width, 'Lists tab width ≥ 44px').toBeGreaterThanOrEqual(44)
|
||||||
|
expect(box!.height, 'Lists tab height ≥ 44px').toBeGreaterThanOrEqual(44)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BottomTabBar is fully in-viewport (Rule 3 — safe-area-inset)', async ({ page }) => {
|
||||||
|
// The bar uses env(safe-area-inset-bottom, 0px). In emulation there is no
|
||||||
|
// safe-area-inset, so the bar's bottom edge must be ≤ viewport height.
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||||
|
await expect(nav).toBeVisible()
|
||||||
|
const box = await nav.boundingBox()
|
||||||
|
expect(box, 'BottomTabBar bounding box must not be null').not.toBeNull()
|
||||||
|
const viewportHeight = page.viewportSize()!.height
|
||||||
|
expect(
|
||||||
|
box!.y + box!.height,
|
||||||
|
`BottomTabBar bottom edge (${box!.y + box!.height}) must be ≤ viewport height (${viewportHeight})`,
|
||||||
|
).toBeLessThanOrEqual(viewportHeight)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PhoneNav header is visible (Rule 3)', async ({ page }) => {
|
||||||
|
// PhoneNav renders a <header> with exact text "FamilySync" (not a nav landmark).
|
||||||
|
// Use exact:true to avoid matching the "Install FamilySync" install-prompt text.
|
||||||
|
await expect(page.getByText('FamilySync', { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PhoneNav settings button meets 44×44px touch-target minimum (Rule 1)', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// aria-label: "${displayName} — open settings" (AppNav.tsx PhoneNav)
|
||||||
|
const settingsBtn = page.getByRole('button', { name: /open settings/i })
|
||||||
|
const box = await settingsBtn.boundingBox()
|
||||||
|
expect(box, 'Settings button bounding box must not be null').not.toBeNull()
|
||||||
|
expect(box!.width, 'Settings button width ≥ 44px').toBeGreaterThanOrEqual(44)
|
||||||
|
expect(box!.height, 'Settings button height ≥ 44px').toBeGreaterThanOrEqual(44)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('New Event FAB meets 56×56px touch-target minimum (Rule 1)', async ({ page }) => {
|
||||||
|
// Phone-only FAB — aria-label="New Event", fixed 56×56px (CalendarShell.tsx)
|
||||||
|
const fab = page.getByRole('button', { name: 'New Event' })
|
||||||
|
const box = await fab.boundingBox()
|
||||||
|
expect(box, 'New Event FAB bounding box must not be null').not.toBeNull()
|
||||||
|
expect(box!.width, 'New Event FAB width ≥ 56px').toBeGreaterThanOrEqual(56)
|
||||||
|
expect(box!.height, 'New Event FAB height ≥ 56px').toBeGreaterThanOrEqual(56)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Rule 1/3/4 repeated on /lists ──
|
||||||
|
|
||||||
|
test.describe('Rule 1/3/4 — BottomTabBar on /lists', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/lists')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BottomTabBar navigation landmark is visible on /lists (Rule 4)', async ({ page }) => {
|
||||||
|
await expect(
|
||||||
|
page.getByRole('navigation', { name: 'Main navigation' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Calendar tab meets 44×44px on /lists (Rule 1)', async ({ page }) => {
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||||
|
const calTab = nav.getByRole('link', { name: 'Calendar' })
|
||||||
|
const box = await calTab.boundingBox()
|
||||||
|
expect(box).not.toBeNull()
|
||||||
|
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||||
|
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Lists tab meets 44×44px on /lists (Rule 1)', async ({ page }) => {
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||||
|
const listsTab = nav.getByRole('link', { name: 'Lists' })
|
||||||
|
const box = await listsTab.boundingBox()
|
||||||
|
expect(box).not.toBeNull()
|
||||||
|
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||||
|
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('BottomTabBar is fully in-viewport on /lists (Rule 3)', async ({ page }) => {
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||||
|
await expect(nav).toBeVisible()
|
||||||
|
const box = await nav.boundingBox()
|
||||||
|
expect(box).not.toBeNull()
|
||||||
|
const viewportHeight = page.viewportSize()!.height
|
||||||
|
expect(box!.y + box!.height).toBeLessThanOrEqual(viewportHeight)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Rule 2: No horizontal overflow ──
|
||||||
|
|
||||||
|
test.describe('Rule 2 — No horizontal overflow', () => {
|
||||||
|
test('no overflow on /calendar', async ({ page }) => {
|
||||||
|
await page.goto('/calendar')
|
||||||
|
const overflow = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}))
|
||||||
|
expect(
|
||||||
|
overflow.scrollWidth,
|
||||||
|
`scrollWidth (${overflow.scrollWidth}) must be ≤ clientWidth (${overflow.clientWidth}) on /calendar`,
|
||||||
|
).toBeLessThanOrEqual(overflow.clientWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('no overflow on /lists', async ({ page }) => {
|
||||||
|
await page.goto('/lists')
|
||||||
|
const overflow = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}))
|
||||||
|
expect(
|
||||||
|
overflow.scrollWidth,
|
||||||
|
`scrollWidth (${overflow.scrollWidth}) must be ≤ clientWidth (${overflow.clientWidth}) on /lists`,
|
||||||
|
).toBeLessThanOrEqual(overflow.clientWidth)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Harness self-validation — injected-defect proofs (TEST-01 acceptance bar) ──
|
||||||
|
//
|
||||||
|
// Each test is a PASSING test that proves the assertion would have failed under a
|
||||||
|
// deliberately injected defect and recovers once the injection is removed.
|
||||||
|
// The suite stays green; the proofs demonstrate the harness measures rendered
|
||||||
|
// geometry rather than CSS source values.
|
||||||
|
|
||||||
|
test.describe('harness self-validation — injected defects', () => {
|
||||||
|
test('Rule 1 proof: tap-target assertion fails under 20px injection, passes after removal', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/calendar')
|
||||||
|
|
||||||
|
// Confirm the nav is visible before injection
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||||
|
await expect(nav).toBeVisible()
|
||||||
|
|
||||||
|
// INJECT: force BottomTabBar links to 20px height — simulates a broken tap target
|
||||||
|
const styleHandle = await page.addStyleTag({
|
||||||
|
content:
|
||||||
|
'nav[aria-label="Main navigation"] a { min-height: 20px !important; height: 20px !important; max-height: 20px !important; }',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Measure WHILE injected — must be < 44px to prove the assertion tracks geometry
|
||||||
|
const calTab = nav.getByRole('link', { name: 'Calendar' })
|
||||||
|
const boxWithDefect = await calTab.boundingBox()
|
||||||
|
expect(
|
||||||
|
boxWithDefect,
|
||||||
|
'Calendar tab bounding box must not be null even with defect injected',
|
||||||
|
).not.toBeNull()
|
||||||
|
expect(
|
||||||
|
boxWithDefect!.height,
|
||||||
|
'Height must be < 44px with 20px injection (proving measurement tracks rendered geometry)',
|
||||||
|
).toBeLessThan(44)
|
||||||
|
|
||||||
|
// REMOVE the injected style by navigating (page.reload drops inline style tags)
|
||||||
|
// then re-measure — must be ≥ 44px again
|
||||||
|
await styleHandle.evaluate((el) => el.remove())
|
||||||
|
const boxAfterRemoval = await calTab.boundingBox()
|
||||||
|
expect(
|
||||||
|
boxAfterRemoval,
|
||||||
|
'Calendar tab bounding box must not be null after defect removal',
|
||||||
|
).not.toBeNull()
|
||||||
|
expect(
|
||||||
|
boxAfterRemoval!.height,
|
||||||
|
'Height must be ≥ 44px after defect style is removed',
|
||||||
|
).toBeGreaterThanOrEqual(44)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Rule 2 proof: overflow assertion fails under 2000px injection, passes after removal', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/calendar')
|
||||||
|
|
||||||
|
// Confirm baseline — no overflow before injection
|
||||||
|
const baseOverflow = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}))
|
||||||
|
expect(baseOverflow.scrollWidth).toBeLessThanOrEqual(baseOverflow.clientWidth)
|
||||||
|
|
||||||
|
// INJECT: force body width to 2000px — simulates Schedule-X overflow defect
|
||||||
|
const styleHandle = await page.addStyleTag({
|
||||||
|
content: 'body { width: 2000px !important; }',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Measure WHILE injected — scrollWidth must exceed clientWidth
|
||||||
|
const overflowWithDefect = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}))
|
||||||
|
expect(
|
||||||
|
overflowWithDefect.scrollWidth,
|
||||||
|
`scrollWidth (${overflowWithDefect.scrollWidth}) must be > clientWidth (${overflowWithDefect.clientWidth}) with 2000px injection (proving overflow detection works)`,
|
||||||
|
).toBeGreaterThan(overflowWithDefect.clientWidth)
|
||||||
|
|
||||||
|
// REMOVE the injected style — overflow must clear
|
||||||
|
await styleHandle.evaluate((el) => el.remove())
|
||||||
|
const overflowAfterRemoval = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
}))
|
||||||
|
expect(
|
||||||
|
overflowAfterRemoval.scrollWidth,
|
||||||
|
'scrollWidth must be ≤ clientWidth after 2000px injection is removed',
|
||||||
|
).toBeLessThanOrEqual(overflowAfterRemoval.clientWidth)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user