2026-08-14 15:02:00 +02:00
import { expect , test , type Page } from '@playwright/test'
2026-08-14 18:26:20 +02:00
async function dragBy ( page : Page , locator : ReturnType < Page [ 'locator' ] >, deltaX : number , deltaY : number ) {
const box = await locator . boundingBox ()
if ( ! box ) throw new Error ( 'Drag target is not visible' )
const start = { x : box.x + box . width / 2 , y : box.y + Math . min ( 18 , box . height / 4 ) }
await page . mouse . move ( start . x , start . y )
await page . mouse . down ()
await page . mouse . move ( start . x + deltaX , start . y + deltaY , { steps : 8 })
await page . mouse . up ()
}
2026-08-14 15:02:00 +02:00
async function waitForSave ( page : Page , action : () => Promise < void >) {
const response = page . waitForResponse ( candidate => candidate . request (). method () === 'PUT' && candidate . url (). includes ( '/api/levels/' ) && candidate . ok ())
await action ()
await response
}
async function classify ( page : Page , name : string , kind : 'PERSON' | 'ORGANIZATION' , summary : string ) {
2026-08-14 16:18:13 +02:00
if ( ! await page . locator ( '.brief-panel' ). isVisible ()) await page . getByRole ( 'button' , { name : 'Case brief' , exact : true }). click ()
2026-08-14 15:02:00 +02:00
const concept = page . locator ( '.brief-concepts section' ). filter ({ hasText : name })
2026-08-14 16:18:13 +02:00
await waitForSave ( page , () => concept . getByRole ( 'button' , { name : kind , exact : true }). click ())
await expect ( page . locator ( '.brief-panel' )). toBeVisible ()
await expect ( concept ). toHaveClass ( /\bjust-resolved\b/ )
await concept . getByRole ( 'button' , { name : 'EDIT DOSSIER' , exact : true }). click ()
2026-08-14 15:02:00 +02:00
await page . getByLabel ( 'Party summary' ). fill ( summary )
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'SAVE DOSSIER' , exact : true }). click ())
}
async function createEvent ( page : Page , title : string , narrative : string , occurredAt : string , sources : string []) {
await page . getByRole ( 'button' , { name : 'NEW EVENT' , exact : true }). click ()
await page . getByLabel ( 'Event title' ). fill ( title )
await page . getByLabel ( 'Event narrative' ). fill ( narrative )
await page . getByLabel ( 'Occurred at' ). fill ( occurredAt )
for ( const source of sources ) {
await page . locator ( '.event-support-list .folder-member' ). filter ({ hasText : source }). locator ( 'input[type="checkbox"]' ). check ()
}
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'SAVE EVENT' , exact : true }). click ())
}
test ( 'a cloned Glass Harbor level can be solved without modifying its template' , async ({ page , request }) => {
2026-08-14 17:57:00 +02:00
test . setTimeout ( 90 _000 )
2026-08-14 18:11:51 +02:00
await expect . poll ( async () => {
const seededLevels = await ( await request . get ( '/api/levels' )). json () as { id : string }[]
return seededLevels . some ( level => level . id . startsWith ( 'glass-harbor-case-' ))
}). toBe ( true )
2026-08-14 15:02:00 +02:00
const levels = await ( await request . get ( '/api/levels' )). json () as { id : string }[]
const playable = levels . find ( level => level . id . startsWith ( 'glass-harbor-case-' ))
if ( ! playable ) throw new Error ( 'Glass Harbor playable clone was not seeded' )
await page . goto ( `/?level= ${ playable . id } ` )
await expect ( page . getByRole ( 'heading' , { name : 'The Glass Harbor Diversion' })). toBeVisible ()
2026-08-14 17:57:00 +02:00
await expect ( page . locator ( '.documents-panel' )). toHaveClass ( /\bclosed\b/ )
await page . locator ( '.open-files' ). click ()
await page . getByRole ( 'searchbox' , { name : 'Search inside documents' , exact : true }). fill ( 'Elias' )
await expect ( page . locator ( '.doc-row' )). toHaveCount ( 1 )
await expect ( page . locator ( '.doc-row' )). toContainText ( 'Carrier Dispatch Manifest · H&F 14' )
await page . getByRole ( 'button' , { name : 'Clear document search' , exact : true }). click ()
await expect ( page . locator ( '.doc-row' )). toHaveCount ( 8 )
await page . getByRole ( 'button' , { name : 'Close documents' , exact : true }). click ()
2026-08-14 15:02:00 +02:00
await expect ( page . locator ( '.evidence-card.folder' )). toHaveCount ( 3 )
await expect ( page . locator ( '.timeline .marker' )). toHaveCount ( 8 )
2026-08-14 15:40:54 +02:00
await expect ( page . locator ( '.timeline-label button' )). toHaveText ( '1987-10-01 — 1987-10-31' )
const dispatchMarker = await page . getByRole ( 'button' , { name : '1987-10-16 — Carrier Dispatch Manifest · H&F 14' , exact : true }). boundingBox ()
const auctionMarker = await page . getByRole ( 'button' , { name : '1987-10-24 — Meridian Maritime Auction · Lot 117' , exact : true }). boundingBox ()
expect ( dispatchMarker ). not . toBeNull ()
expect ( auctionMarker ). not . toBeNull ()
expect ( auctionMarker ! . x - dispatchMarker ! . x ). toBeGreaterThan ( 100 )
await page . getByRole ( 'button' , { name : 'TIMELINE' , exact : true }). click ()
await page . getByLabel ( 'Timeline start date' ). fill ( '1987-10-10' )
await page . getByLabel ( 'Timeline end date' ). fill ( '1987-10-26' )
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'APPLY RANGE' , exact : true }). click ())
await expect ( page . locator ( '.timeline-label button' )). toHaveText ( '1987-10-10 — 1987-10-26' )
2026-08-14 15:02:00 +02:00
await classify ( page , 'Mara Voss' , 'PERSON' , 'Restoration project officer who sponsored the Warehouse 3 access pass.' )
await classify ( page , 'Elias Vale' , 'PERSON' , 'Driver of H&F 14; transported CO-771 to Warehouse 3.' )
await classify ( page , 'Greyhaven Preservation Society' , 'ORGANIZATION' , 'Commissioned the North Quay restoration and owned the missing lens.' )
await classify ( page , 'Calder Optical Works' , 'ORGANIZATION' , 'Supplier that released sealed crate CO-771 to the approved carrier.' )
await classify ( page , 'Harbor & Fell Logistics' , 'ORGANIZATION' , 'Approved carrier whose vehicle delivered the crate to the wrong address.' )
await classify ( page , 'Voss Antiquities Ltd' , 'ORGANIZATION' , 'Mara Voss’ s company, based at Warehouse 3 and named as auction consignor.' )
2026-08-14 16:18:13 +02:00
await page . getByRole ( 'button' , { name : 'RETURN TO BOARD' , exact : true }). click ()
2026-08-14 15:02:00 +02:00
await createEvent ( page , 'CO-771 left Calder for North Quay' , 'Calder released the sealed lens to Elias Vale in H&F 14 with no alternate delivery authority.' , '1987-10-16T16:40' , [ 'Carrier Dispatch Manifest' ])
await createEvent ( page , 'CO-771 was diverted to Warehouse 3' , 'Mara Voss sponsored access; H&F 14 entered loaded and left empty. The next-day memorandum was retrospective.' , '1987-10-17T22:08' , [ 'Old Glass Harbor Gate Ledger' , 'Warehouse 3 Security Photograph' , 'Delivery Redirection Memorandum' ])
await createEvent ( page , 'Voss Antiquities offered the lens for sale' , 'The auction description and seller reference identify the missing assembly and the company that stood to profit.' , '1987-10-24T12:00' , [ 'Company Register Extract' , 'Meridian Maritime Auction' ])
page . once ( 'dialog' , dialog => dialog . accept ( 'The redirection memorandum was written after the crate had already moved.' ))
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'NEW NOTE' , exact : true }). click ())
2026-08-14 16:37:50 +02:00
const elias = page . locator ( '.evidence-card.party' ). filter ({ has : page.getByRole ( 'heading' , { name : 'Elias Vale' , exact : true }) })
2026-08-14 15:02:00 +02:00
const movement = page . locator ( '.evidence-card.folder' ). filter ({ hasText : 'MOVEMENT RECORDS' })
2026-08-14 16:37:50 +02:00
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'Zoom out' , exact : true }). click ())
await waitForSave ( page , () => movement . getByRole ( 'button' , { name : 'OPEN' , exact : true }). click ())
const manifest = page . locator ( '.source-file-widget.open' ). filter ({ hasText : 'Carrier Dispatch Manifest' })
await elias . click ()
await page . getByRole ( 'button' , { name : 'Red thread' , exact : true }). click ()
const boardBox = await page . locator ( '.board-viewport' ). boundingBox ()
if ( ! boardBox ) throw new Error ( 'Board is not visible' )
await page . mouse . move ( boardBox . x + boardBox . width / 2 , boardBox . y + boardBox . height / 2 )
await expect ( page . locator ( '.connections .thread-preview path' )). toBeVisible ()
const manifestBox = await manifest . boundingBox ()
if ( ! manifestBox ) throw new Error ( 'Carrier Dispatch Manifest is not visible' )
await page . mouse . move ( manifestBox . x + manifestBox . width / 2 , manifestBox . y + manifestBox . height / 2 )
await manifest . click ()
const threadEditor = page . locator ( '.thread-editor' )
await expect ( threadEditor . getByText ( 'Elias Vale' , { exact : true })). toBeVisible ()
await expect ( threadEditor . getByText ( 'Carrier Dispatch Manifest · H&F 14' , { exact : true })). toBeVisible ()
await page . getByLabel ( 'Thread tag' ). fill ( 'Proof Elias is the driver' )
await page . getByRole ( 'slider' , { name : 'Thread tightness' , exact : true }). fill ( '85' )
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'ADD TAG & TIGHTEN' , exact : true }). click ())
await expect ( page . locator ( '.connections g.tightening path' )). toBeVisible ()
2026-08-14 15:02:00 +02:00
2026-08-14 18:11:51 +02:00
const procurement = page . locator ( '.evidence-card.folder' ). filter ({ hasText : 'PROCUREMENT & OWNERSHIP' })
await waitForSave ( page , () => procurement . getByRole ( 'button' , { name : 'OPEN' , exact : true }). click ())
const companyRegister = page . locator ( '.source-file-widget.open' ). filter ({ hasText : 'Company Register Extract' })
const memorandum = page . locator ( '.source-file-widget.open' ). filter ({ hasText : 'Delivery Redirection Memorandum' })
const photograph = page . locator ( '.source-file-widget.open' ). filter ({ hasText : 'Warehouse 3 Security Photograph' })
await companyRegister . click ()
await expect ( companyRegister ). toHaveClass ( /\bselected\b/ )
await page . getByRole ( 'button' , { name : 'Red thread' , exact : true }). click ()
await expect ( companyRegister ). toHaveClass ( /\blinking\b/ )
await memorandum . click ()
await expect ( threadEditor . getByText ( 'Company Register Extract · Voss Antiquities Ltd' , { exact : true })). toBeVisible ()
await expect ( threadEditor . getByText ( 'Delivery Redirection Memorandum' , { exact : true })). toBeVisible ()
await page . getByLabel ( 'Thread tag' ). fill ( 'Proves Voss owns Warehouse 3' )
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'ADD TAG & TIGHTEN' , exact : true }). click ())
await page . getByRole ( 'button' , { name : 'Red thread' , exact : true }). click ()
await photograph . click ()
await expect ( threadEditor . getByText ( 'Company Register Extract · Voss Antiquities Ltd' , { exact : true })). toBeVisible ()
await expect ( threadEditor . getByText ( 'Warehouse 3 Security Photograph' , { exact : true })). toBeVisible ()
await page . getByLabel ( 'Thread tag' ). fill ( 'Proves Voss owns Warehouse 3' )
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'ADD TAG & TIGHTEN' , exact : true }). click ())
2026-08-14 15:02:00 +02:00
await page . reload ()
await expect ( page . locator ( '.evidence-card.party' )). toHaveCount ( 6 )
await expect ( page . locator ( '.evidence-card.event' )). toHaveCount ( 3 )
await expect ( page . locator ( '.evidence-card.note' )). toHaveCount ( 1 )
await expect ( page . locator ( '.story-strip' )). toContainText ( 'CO-771 was diverted to Warehouse 3' )
await expect ( page . locator ( '.event-support-lines line' )). toHaveCount ( 6 )
2026-08-14 18:11:51 +02:00
await expect ( page . locator ( '.connections path' )). toHaveCount ( 3 )
const relationTag = page . locator ( '.thread-tag' ). filter ({ hasText : 'Proof Elias is the driver' })
2026-08-14 17:02:17 +02:00
await expect ( relationTag ). toContainText ( 'Proof Elias is the driver' )
2026-08-14 18:26:20 +02:00
const tagBefore = await relationTag . boundingBox ()
await waitForSave ( page , () => dragBy ( page , relationTag , 75 , - 18 ))
const tagAfter = await relationTag . boundingBox ()
expect ( tagBefore ). not . toBeNull ()
expect ( tagAfter ). not . toBeNull ()
expect ( Math . abs ( tagAfter ! . x - tagBefore ! . x ) + Math . abs ( tagAfter ! . y - tagBefore ! . y )). toBeGreaterThan ( 20 )
2026-08-14 17:02:17 +02:00
await relationTag . click ()
await expect ( relationTag ). toHaveClass ( /\bexpanded\b/ )
await expect ( relationTag ). toHaveAttribute ( 'aria-expanded' , 'true' )
await relationTag . click ()
await expect ( page . getByText ( 'Edit red thread' )). toBeVisible ()
2026-08-14 18:26:20 +02:00
expect ( await page . getByRole ( 'slider' , { name : 'Tag position' , exact : true }). inputValue ()). not . toBe ( '50' )
2026-08-14 17:07:30 +02:00
await page . locator ( '.tag-style-picker input[value="compact"]' ). check ()
await waitForSave ( page , () => page . getByRole ( 'button' , { name : 'SAVE THREAD' , exact : true }). click ())
await expect ( relationTag ). toHaveClass ( /\bcompact\b/ )
await expect ( relationTag ). not . toHaveClass ( /\bluggage-tag\b/ )
await relationTag . click ()
await expect ( page . getByText ( 'Edit red thread' )). toBeVisible ()
2026-08-14 17:02:17 +02:00
await page . getByRole ( 'button' , { name : 'Close thread editor' , exact : true }). click ()
2026-08-14 16:37:50 +02:00
const solved = await ( await request . get ( `/api/levels/ ${ playable . id } ` )). json ()
2026-08-14 18:26:20 +02:00
const eliasThread = solved . connections . find (( connection : { label? : string }) => connection . label === 'Proof Elias is the driver' )
expect ( eliasThread ). toEqual ( expect . objectContaining ({ tightness : 85 , tagStyle : 'compact' , tagPosition : expect.any ( Number ), tagOffset : expect.any ( Number ) }))
expect ( eliasThread . tagPosition ). not . toBe ( 50 )
expect ( Math . abs ( eliasThread . tagOffset )). toBeLessThanOrEqual ( 19 )
2026-08-14 18:11:51 +02:00
expect ( solved . connections . filter (( connection : { label? : string }) => connection . label === 'Proves Voss owns Warehouse 3' )). toHaveLength ( 2 )
2026-08-14 15:02:00 +02:00
const template = await ( await request . get ( '/api/templates' )). json () as { slug : string ; currentVersion : number }[]
expect ( template ). toContainEqual ( expect . objectContaining ({ slug : 'glass-harbor' , currentVersion : 1 }))
const authoringLevels = levels . filter ( level => level . id . startsWith ( 'glass-harbor-authoring-' ))
expect ( authoringLevels ). toHaveLength ( 1 )
const untouched = await ( await request . get ( `/api/levels/ ${ authoringLevels [ 0 ]. id } ?edit=1` )). json ()
expect ( untouched . evidence . filter (( item : { type : string }) => item . type === 'party' )). toHaveLength ( 0 )
expect ( untouched . evidence . filter (( item : { type : string }) => item . type === 'event' )). toHaveLength ( 0 )
expect ( untouched . brief . concepts . every (( concept : { resolvedPartyExhibitId? : string }) => ! concept . resolvedPartyExhibitId )). toBe ( true )
})