184 lines
14 KiB
TypeScript
184 lines
14 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test'
|
||
|
||
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()
|
||
}
|
||
|
||
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) {
|
||
if (!await page.locator('.brief-panel').isVisible()) await page.getByRole('button', { name: 'Case brief', exact: true }).click()
|
||
const concept = page.locator('.brief-concepts section').filter({ hasText: name })
|
||
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()
|
||
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 }) => {
|
||
test.setTimeout(90_000)
|
||
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)
|
||
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()
|
||
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()
|
||
await expect(page.locator('.evidence-card.folder')).toHaveCount(3)
|
||
await expect(page.locator('.timeline .marker')).toHaveCount(8)
|
||
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')
|
||
|
||
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.')
|
||
await page.getByRole('button', { name: 'RETURN TO BOARD', exact: true }).click()
|
||
|
||
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())
|
||
|
||
const elias = page.locator('.evidence-card.party').filter({ has: page.getByRole('heading', { name: 'Elias Vale', exact: true }) })
|
||
const movement = page.locator('.evidence-card.folder').filter({ hasText: 'MOVEMENT RECORDS' })
|
||
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 expect(manifest.locator('.text-source-excerpt')).toContainText('HARBOR & FELL LOGISTICS')
|
||
await expect(manifest.locator('.source-file-preview svg')).toHaveCount(0)
|
||
const manifestCue = page.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })
|
||
await waitForSave(page, () => manifestCue.fill('ELIAS · H&F 14'))
|
||
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()
|
||
|
||
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())
|
||
|
||
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.getByRole('textbox', { name: 'Memory cue for Carrier Dispatch Manifest · H&F 14', exact: true })).toHaveValue('ELIAS · H&F 14')
|
||
await expect(page.locator('.story-strip')).toContainText('CO-771 was diverted to Warehouse 3')
|
||
await expect(page.locator('.event-support-lines line')).toHaveCount(6)
|
||
await expect(page.locator('.connections path')).toHaveCount(3)
|
||
const relationTag = page.locator('.thread-tag').filter({ hasText: 'Proof Elias is the driver' })
|
||
await expect(relationTag).toContainText('Proof Elias is the driver')
|
||
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)
|
||
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()
|
||
expect(await page.getByRole('slider', { name: 'Tag position', exact: true }).inputValue()).not.toBe('50')
|
||
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()
|
||
await page.getByRole('button', { name: 'Close thread editor', exact: true }).click()
|
||
|
||
const solved = await (await request.get(`/api/levels/${playable.id}`)).json()
|
||
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)
|
||
expect(solved.connections.filter((connection: { label?: string }) => connection.label === 'Proves Voss owns Warehouse 3')).toHaveLength(2)
|
||
|
||
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)
|
||
})
|