test: add board and postgres safety net

This commit is contained in:
2026-08-14 12:57:06 +02:00
parent ff5e5dc63e
commit 9a4da41b49
11 changed files with 445 additions and 91 deletions
+16
View File
@@ -30,6 +30,22 @@ npm run dev
Open `http://localhost:5173`; Vite proxies `/api` to port 8787.
## Test
Run the fast domain tests without external services:
```bash
npm test
```
With the development PostgreSQL container running, exercise the migration ledger and persistence API against uniquely named disposable databases:
```bash
npm run test:integration
```
The integration suite drops only the temporary databases it creates. Override `TEST_DATABASE_URL` when PostgreSQL is not available at the development default on port 5433.
## Data and API
The server stores its tables and migration ledger in the dedicated `osint` schema of PostgreSQL. `DATABASE_URL` is required.
+5 -5
View File
@@ -14,16 +14,16 @@ This is the ordered implementation roadmap following the accepted exhibit model.
- [x] Track `.gitignore`, `.dockerignore`, and `.env.example`; confirm that secrets and generated artifacts cannot enter Git accidentally.
- [x] Commit the current working POC and tag the checkpoint `poc-pre-exhibit-model`.
- [x] Confirm `main` is pushed to the Ramanujan-hosted `origin` repository.
- [ ] Keep `npm run build` green and make `npm test` run real tests rather than an empty suite.
- [x] Keep `npm run build` green and make `npm test` run real tests rather than an empty suite.
## Milestone 1: focused safety net
- [ ] Test screen/board coordinate conversion across pan and zoom levels.
- [ ] Test timeline date-to-pixel projection and recomputation after viewport resizing.
- [x] Test screen/board coordinate conversion across pan and zoom levels.
- [x] Test timeline date-to-pixel projection and recomputation after viewport resizing.
- [ ] Test the interaction boundary between exhibit dragging, hand-tool panning, and board-only pinch zoom.
- [ ] Test folder open/close behavior, retained file positions, and containment bands.
- [x] Test folder open/close behavior and retained file positions; add visual coverage for containment bands with the browser smoke test.
- [ ] Test document upload, metadata persistence, board save/reload, and reset.
- [ ] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
- [x] Run migrations and API integration tests against disposable PostgreSQL, not SQLite or mocked persistence.
- [ ] Add one browser smoke test: open a level, drag an exhibit, zoom, open a folder, move a file, reload, and verify persistence.
## Milestone 2: exhibit-schema cutover
+2 -1
View File
@@ -10,7 +10,8 @@
"build": "tsc -b && vite build",
"start": "NODE_ENV=production tsx server/index.ts",
"migrate:up": "tsx server/migrate.ts",
"test": "vitest run"
"test": "vitest run --exclude '**/*.integration.test.ts'",
"test:integration": "TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgres://osint:osint_secret@localhost:5433/osint_dev} vitest run server/*.integration.test.ts"
},
"dependencies": {
"cors": "2.8.5",
+87
View File
@@ -0,0 +1,87 @@
import { createServer } from 'node:net'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { CaseState } from '../src/types.js'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_api_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let appServer: Awaited<typeof import('./index.js')>['server']
let appPool: Awaited<typeof import('./index.js')>['pool']
let baseUrl = ''
async function availablePort() {
return new Promise<number>((resolve, reject) => {
const probe = createServer()
probe.once('error', reject)
probe.listen(0, '127.0.0.1', () => {
const address = probe.address()
const port = typeof address === 'object' && address ? address.port : 0
probe.close(error => error ? reject(error) : resolve(port))
})
})
}
suite('level persistence API', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres'
adminClient = new Client({ connectionString: adminUrl.toString() })
await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}`
const databaseUrl = testUrl.toString()
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
await runMigrations(databaseUrl, migrationsDir, () => undefined)
const port = await availablePort()
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.PORT = String(port)
const serverModule = await import('./index.js')
appServer = serverModule.server
appPool = serverModule.pool
baseUrl = `http://127.0.0.1:${port}`
})
afterAll(async () => {
if (appServer) await new Promise<void>((resolve, reject) => appServer.close(error => error ? reject(error) : resolve()))
if (appPool) await appPool.end()
if (!adminClient) return
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
it('persists authored viewport, exhibits, and relation positions', async () => {
const createResponse = await fetch(`${baseUrl}/api/levels`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
})
expect(createResponse.status).toBe(201)
const state = await createResponse.json() as CaseState
state.viewport = { x: 91, y: -42, zoom: 0.85 }
state.documents = [{ id: 'doc-1', title: 'Evidence', kind: 'IMAGE', date: '2021-04-17', publishedAt: '2021-04-17T12:00:00Z', body: [], regions: [], fileType: 'image', metadata: {} }]
state.evidence = [{ id: 'folder-1', type: 'folder', title: 'Folder', content: 'Evidence folder', x: 685, y: 417, width: 260, config: { open: true }, containedDocumentIds: ['doc-1'] }]
state.relations = [{ id: 'membership-1', fromWidgetId: 'folder-1', toWidgetId: 'doc-1', type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state),
})
expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' })
const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loaded.viewport).toEqual(state.viewport)
expect(loaded.evidence[0]).toMatchObject({ id: 'folder-1', x: 685, y: 417, config: { open: true } })
expect(loaded.relations[0]).toMatchObject({ id: 'membership-1', config: { x: 1051, y: 417 } })
})
})
+11 -4
View File
@@ -16,7 +16,7 @@ if (!databaseUrl) {
process.exit(1)
}
const pool = new Pool({ connectionString: databaseUrl })
export const pool = new Pool({ connectionString: databaseUrl })
const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
type WidgetRow = {
@@ -154,6 +154,10 @@ async function savePlaythrough(client: PoolClient, state: CaseState) {
async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
await client.query('UPDATE osint.levels SET title = $2, subtitle = $3, updated_at = NOW() WHERE id = $1', [state.id, state.title, state.subtitle])
await client.query(`INSERT INTO osint.playthroughs (id, level_id, viewport, updated_at)
VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (id) DO UPDATE SET viewport = EXCLUDED.viewport, updated_at = NOW()`,
[`default:${state.id}`, state.id, JSON.stringify(state.viewport)])
await client.query('DELETE FROM osint.level_connections WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widget_relations WHERE level_id = $1', [state.id])
await client.query('DELETE FROM osint.widgets WHERE level_id = $1', [state.id])
@@ -184,7 +188,7 @@ async function saveAuthoredLevel(client: PoolClient, state: CaseState) {
}
}
const app = express()
export const app = express()
app.disable('x-powered-by')
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
app.use(express.json({ limit: '2mb' }))
@@ -285,6 +289,9 @@ app.use((error: unknown, _req: express.Request, res: express.Response, _next: ex
const here = path.dirname(fileURLToPath(import.meta.url)); const dist = path.resolve(here, '..', 'dist')
if (fs.existsSync(dist)) { app.use(express.static(dist)); app.get('*splat', (_req, res) => res.sendFile(path.join(dist, 'index.html'))) }
const port = Number(process.env.PORT || 8787)
const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
export const server = app.listen(port, '0.0.0.0', () => console.log(`GUPI OSINT Board listening on http://localhost:${port}`))
async function shutdown() { server.close(); await pool.end(); process.exit(0) }
process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown)
if (!process.env.VITEST) {
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
}
+2 -40
View File
@@ -1,11 +1,8 @@
import 'dotenv/config'
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import { runMigrations } from './migrations.js'
const { Client } = pg
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error('DATABASE_URL is required')
@@ -13,39 +10,4 @@ if (!databaseUrl) {
}
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const client = new Client({ connectionString: databaseUrl })
await client.connect()
try {
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
name TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
for (const name of files) {
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
const checksum = createHash('sha256').update(sql).digest('hex')
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
if (existing.rows[0]) {
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
console.log(`skip ${name}`)
continue
}
await client.query('BEGIN')
try {
await client.query(sql)
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
await client.query('COMMIT')
console.log(`apply ${name}`)
} catch (error) {
await client.query('ROLLBACK')
throw error
}
}
console.log('OSINT migrations complete.')
} finally {
await client.end()
}
await runMigrations(databaseUrl, migrationsDir)
+53
View File
@@ -0,0 +1,53 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runMigrations } from './migrations.js'
const { Client } = pg
const baseDatabaseUrl = process.env.TEST_DATABASE_URL
const suite = baseDatabaseUrl ? describe : describe.skip
const databaseName = `osint_test_${process.pid}_${Date.now()}`
let adminClient: InstanceType<typeof Client>
let testDatabaseUrl = ''
suite('PostgreSQL migrations', () => {
beforeAll(async () => {
const adminUrl = new URL(baseDatabaseUrl!)
adminUrl.pathname = '/postgres'
adminClient = new Client({ connectionString: adminUrl.toString() })
await adminClient.connect()
await adminClient.query(`CREATE DATABASE "${databaseName}"`)
const testUrl = new URL(baseDatabaseUrl!)
testUrl.pathname = `/${databaseName}`
testDatabaseUrl = testUrl.toString()
})
afterAll(async () => {
if (!adminClient) return
await adminClient.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1', [databaseName])
await adminClient.query(`DROP DATABASE IF EXISTS "${databaseName}"`)
await adminClient.end()
})
it('applies every migration transactionally and is idempotent', async () => {
const migrationsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations')
const firstRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => firstRun.push(message))
expect(firstRun.filter(message => message.startsWith('apply '))).toHaveLength(5)
const client = new Client({ connectionString: testDatabaseUrl })
await client.connect()
const tables = await client.query<{ table_name: string }>(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'osint'`)
const tableNames = tables.rows.map(row => row.table_name)
expect(tableNames).toEqual(expect.arrayContaining(['levels', 'widgets', 'widget_relations', 'playthroughs', 'assets', 'schema_migrations']))
const ledger = await client.query<{ count: string }>('SELECT COUNT(*)::text AS count FROM osint.schema_migrations')
expect(ledger.rows[0].count).toBe('5')
await client.end()
const secondRun: string[] = []
await runMigrations(testDatabaseUrl, migrationsDir, message => secondRun.push(message))
expect(secondRun.filter(message => message.startsWith('skip '))).toHaveLength(5)
expect(secondRun.some(message => message.startsWith('apply '))).toBe(false)
})
})
+44
View File
@@ -0,0 +1,44 @@
import { createHash } from 'node:crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import pg from 'pg'
const { Client } = pg
export async function runMigrations(databaseUrl: string, migrationsDir: string, log: (message: string) => void = console.log) {
const client = new Client({ connectionString: databaseUrl })
await client.connect()
try {
await client.query('CREATE SCHEMA IF NOT EXISTS osint')
await client.query(`CREATE TABLE IF NOT EXISTS osint.schema_migrations (
name TEXT PRIMARY KEY,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`)
const files = (await fs.readdir(migrationsDir)).filter(name => /^\d+.*\.sql$/.test(name)).sort()
for (const name of files) {
const sql = await fs.readFile(path.join(migrationsDir, name), 'utf8')
const checksum = createHash('sha256').update(sql).digest('hex')
const existing = await client.query<{ checksum: string }>('SELECT checksum FROM osint.schema_migrations WHERE name = $1', [name])
if (existing.rows[0]) {
if (existing.rows[0].checksum !== checksum) throw new Error(`Applied migration was modified: ${name}`)
log(`skip ${name}`)
continue
}
await client.query('BEGIN')
try {
await client.query(sql)
await client.query('INSERT INTO osint.schema_migrations (name, checksum) VALUES ($1, $2)', [name, checksum])
await client.query('COMMIT')
log(`apply ${name}`)
} catch (error) {
await client.query('ROLLBACK')
throw error
}
}
log('OSINT migrations complete.')
} finally {
await client.end()
}
}
+9 -41
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { BookOpen, CalendarClock, ChevronRight, CircleHelp, FileText, Folder, FolderOpen, Hand, Image as ImageIcon, Link2, Minus, MousePointer2, Network, NotebookPen, Pencil, Plus, RotateCcw, Search, Trash2, Upload, X, ZoomIn, ZoomOut } from 'lucide-react'
import type { CaseDocument, CaseState, Evidence, SourceFileType, WidgetRelation } from './types'
import { clampBoardZoom, containedIds, dateValue, folderIsOpen, moveBoardPoint, normalizeCase, panViewport, relationPosition, timelinePositionPercent, timelineRange, zoomFromWheel } from './boardDomain'
const BOARD_W = 2400
const BOARD_H = 1500
@@ -17,30 +18,6 @@ function connectionPoint(item: Evidence) {
type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: string; kind: 'document' | 'widget'; evidenceId?: string; documentId?: string }
function normalizeCase(state: CaseState): CaseState {
const relations = Array.isArray(state.relations) ? state.relations : state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({ id: `contains:${widget.id}:${documentId}`, fromWidgetId: widget.id, toWidgetId: documentId, type: 'contains', sortOrder: index })))
const membership = new Map<string, string[]>()
for (const relation of relations.filter(relation => relation.type === 'contains').sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))) membership.set(relation.fromWidgetId, [...(membership.get(relation.fromWidgetId) || []), relation.toWidgetId])
return { ...state, relations,
documents: state.documents.map(document => ({ ...document, fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'), metadata: document.metadata || {} })),
evidence: state.evidence.map(widget => ({ ...widget, type: widget.type === 'evidence' ? 'folder' : widget.type, config: widget.config || {}, containedDocumentIds: membership.get(widget.id) || [] })) }
}
function containedIds(state: CaseState, widgetId: string) {
return state.relations.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)).map(relation => relation.toWidgetId)
}
function folderIsOpen(folder: Evidence) { return folder.config?.open === true }
function relationPosition(state: CaseState, relation: WidgetRelation) {
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
const order = relation.sortOrder || 0
const configuredX = Number(relation.config?.x), configuredY = Number(relation.config?.y)
return {
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
}
}
export function App() {
const [caseState, setCaseState] = useState<CaseState | null>(null)
const [noLevels, setNoLevels] = useState(false)
@@ -214,9 +191,9 @@ export function App() {
<button onClick={addNote}><NotebookPen size={17}/> NEW NOTE</button>
<button className={linkFrom ? 'active' : ''} disabled={!selected} onClick={() => setLinkFrom(linkFrom ? null : selected)}><Link2 size={17}/> {linkFrom ? 'SELECT TARGET' : 'CONNECT'}</button>
<span />
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
<button aria-label="Zoom out" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom - .1) } }))}><ZoomOut size={18}/></button>
<b>{Math.round(caseState.viewport.zoom * 100)}%</b>
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.min(1.5, s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
<button aria-label="Zoom in" onClick={() => update(s => ({ ...s, viewport: { ...s.viewport, zoom: clampBoardZoom(s.viewport.zoom + .1) } }))}><ZoomIn size={18}/></button>
<button aria-label="Reset" onClick={reset}><RotateCcw size={17}/></button>
</div>
{draggingFiles && <div className="file-drop-overlay"><div><Upload size={28}/><b>ADD SOURCE DOCUMENTS</b><span>DROP FILES INTO THIS LEVEL</span></div></div>}
@@ -259,7 +236,7 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
event.stopPropagation()
update(s => ({ ...s, viewport: { ...s.viewport, zoom: Math.max(.45, Math.min(1.5, s.viewport.zoom - event.deltaY * .006)) } }))
update(s => ({ ...s, viewport: { ...s.viewport, zoom: zoomFromWheel(s.viewport.zoom, event.deltaY) } }))
}
board.addEventListener('wheel', handlePinch, { passive: false })
return () => board.removeEventListener('wheel', handlePinch)
@@ -276,9 +253,9 @@ function Board({ state, selected, linkFrom, tool, boardRef, update, onCardClick,
if (!drag.current) return
const dx = event.clientX - drag.current.startX, dy = event.clientY - drag.current.startY
if (Math.abs(dx) + Math.abs(dy) > 4) drag.current.moved = true
if (drag.current.kind === 'widget') update(s => ({ ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } : e) }))
else if (drag.current.kind === 'relation') update(s => ({ ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), x: drag.current!.originX + dx / s.viewport.zoom, y: drag.current!.originY + dy / s.viewport.zoom } } : relation) }))
else update(s => ({ ...s, viewport: { ...s.viewport, x: drag.current!.originX + dx, y: drag.current!.originY + dy } }))
if (drag.current.kind === 'widget') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, evidence: s.evidence.map(e => e.id === drag.current!.id ? { ...e, ...next } : e) } })
else if (drag.current.kind === 'relation') update(s => { const next = moveBoardPoint({ x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }, s.viewport.zoom); return { ...s, relations: s.relations.map(relation => relation.id === drag.current!.id ? { ...relation, config: { ...(relation.config || {}), ...next } } : relation) } })
else update(s => ({ ...s, viewport: panViewport({ ...s.viewport, x: drag.current!.originX, y: drag.current!.originY }, { x: dx, y: dy }) }))
}
const finishDrag = () => { if (drag.current) suppressClick.current = Boolean(drag.current.moved); drag.current = null }
const toggleFolder = (id: string) => update(s => ({ ...s, evidence: s.evidence.map(widget => widget.id === id ? { ...widget, config: { ...(widget.config || {}), open: !folderIsOpen(widget) } } : widget) }))
@@ -350,19 +327,10 @@ function TemporalLinks({ items, layoutKey }: { items: TemporalItem[]; layoutKey:
return <svg className="temporal-links" aria-hidden="true">{lines.map(line => <line key={line.id} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2}/>)}</svg>
}
function dateValue(date: string) {
const parsed = Date.parse(date)
return Number.isFinite(parsed) ? parsed : 0
}
function Timeline({ items, selected, onSelect }: { items: TemporalItem[]; selected: string | null; onSelect: (item: TemporalItem) => void }) {
const itemYears = items.map(item => Number(item.date.slice(0, 4))).filter(Number.isFinite)
let startYear = itemYears.length ? Math.min(...itemYears) : new Date().getFullYear() - 2
let endYear = itemYears.length ? Math.max(...itemYears) : startYear + 4
if (endYear - startYear < 4) { const missing = 4 - (endYear - startYear); startYear -= Math.floor(missing / 2); endYear += Math.ceil(missing / 2) }
const start = Date.UTC(startYear, 0, 1), end = Date.UTC(endYear, 11, 31)
const { startYear, endYear, start, end } = timelineRange(items.map(item => item.date))
const years = Array.from({ length: endYear - startYear + 1 }, (_, index) => startYear + index)
const position = (date: string) => Math.max(0, Math.min(100, ((dateValue(date) - start) / (end - start)) * 100))
const position = (date: string) => timelinePositionPercent(date, { start, end })
return <footer className="timeline"><div className="timeline-label"><small>TEMPORAL INDEX</small><b>TIMELINE</b><span>{items.length} DATED ITEMS</span></div><div className="timeline-track"><div className="axis"/>{years.map(year => <span className="year" key={year} style={{ left: `${position(`${year}-01-01`)}%` }}>{year}</span>)}{items.map((item, i) => <button key={item.id} data-marker-id={item.id} className={`marker ${item.kind} ${item.evidenceId === selected ? 'selected' : ''}`} style={{ left: `${position(item.date)}%`, top: i % 2 ? 12 : 31 }} onClick={() => onSelect(item)} title={`${item.date.slice(0, 10)}${item.label}`}><i/></button>)}</div><div className="timeline-key"><span><i/> SOURCE</span><span className="amber"><i/> SELECTED</span></div></footer>
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import type { CaseState, Evidence, WidgetRelation } from './types'
import {
MAX_BOARD_ZOOM,
MIN_BOARD_ZOOM,
clampBoardZoom,
containedIds,
folderIsOpen,
moveBoardPoint,
normalizeCase,
panViewport,
relationPosition,
timelinePositionPercent,
timelineRange,
zoomFromWheel,
} from './boardDomain'
const folder: Evidence = {
id: 'folder-1',
type: 'folder',
title: 'Folder',
content: '',
x: 200,
y: 300,
width: 260,
config: { open: false },
}
const state: CaseState = {
id: 'test-level',
title: 'Test',
subtitle: '',
documents: [],
evidence: [folder],
relations: [],
connections: [],
viewport: { x: 10, y: 20, zoom: 0.5 },
}
describe('board coordinate math', () => {
it('moves board objects by screen distance divided by zoom', () => {
expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 0.5)).toEqual({ x: 200, y: 30 })
expect(moveBoardPoint({ x: 100, y: 80 }, { x: 50, y: -25 }, 1.25)).toEqual({ x: 140, y: 60 })
})
it('rejects invalid zoom during coordinate conversion', () => {
expect(() => moveBoardPoint({ x: 0, y: 0 }, { x: 10, y: 10 }, 0)).toThrow(RangeError)
})
it('pans in screen coordinates without scaling the delta', () => {
expect(panViewport(state.viewport, { x: 25, y: -10 })).toEqual({ x: 35, y: 10, zoom: 0.5 })
})
it('clamps toolbar and pinch zoom to the same limits', () => {
expect(clampBoardZoom(-10)).toBe(MIN_BOARD_ZOOM)
expect(clampBoardZoom(10)).toBe(MAX_BOARD_ZOOM)
expect(zoomFromWheel(1, 10_000)).toBe(MIN_BOARD_ZOOM)
expect(zoomFromWheel(1, -10_000)).toBe(MAX_BOARD_ZOOM)
})
})
describe('timeline projection', () => {
it('creates at least a four-year range around a single year', () => {
const range = timelineRange(['2021-03-12'])
expect(range.endYear - range.startYear).toBe(4)
expect(range.startYear).toBeLessThanOrEqual(2021)
expect(range.endYear).toBeGreaterThanOrEqual(2021)
})
it('uses all supplied years when the evidence spans a wider range', () => {
expect(timelineRange(['2012-01-01', '2024-06-01'])).toMatchObject({ startYear: 2012, endYear: 2024 })
})
it('projects dates proportionally and clamps dates outside the range', () => {
const range = timelineRange(['2020-01-01', '2024-12-31'])
expect(timelinePositionPercent('2010-01-01', range)).toBe(0)
expect(timelinePositionPercent('2030-01-01', range)).toBe(100)
expect(timelinePositionPercent('2022-07-02', range)).toBeGreaterThan(49)
expect(timelinePositionPercent('2022-07-02', range)).toBeLessThan(51)
})
})
describe('folder domain behavior', () => {
const relations: WidgetRelation[] = [
{ id: 'later', fromWidgetId: folder.id, toWidgetId: 'doc-2', type: 'contains', sortOrder: 2 },
{ id: 'other', fromWidgetId: 'folder-2', toWidgetId: 'doc-x', type: 'contains', sortOrder: 0 },
{ id: 'first', fromWidgetId: folder.id, toWidgetId: 'doc-1', type: 'contains', sortOrder: 0 },
]
it('orders and scopes contained documents by their normalized relations', () => {
expect(containedIds({ ...state, relations }, folder.id)).toEqual(['doc-1', 'doc-2'])
})
it('only treats an explicit boolean true as open', () => {
expect(folderIsOpen(folder)).toBe(false)
expect(folderIsOpen({ ...folder, config: { open: true } })).toBe(true)
expect(folderIsOpen({ ...folder, config: { open: 'true' } })).toBe(false)
})
it('retains a configured expanded file position', () => {
const relation = { ...relations[0], config: { x: 720, y: 415 } }
expect(relationPosition({ ...state, relations }, relation)).toEqual({ x: 720, y: 415 })
})
it('derives a deterministic position when a relation has not been moved', () => {
expect(relationPosition({ ...state, relations }, relations[2])).toEqual({ x: 550, y: 270 })
expect(relationPosition({ ...state, relations }, relations[0])).toEqual({ x: 960, y: 270 })
})
it('normalizes legacy containment without changing source ownership', () => {
const legacy = {
...state,
documents: [{ id: 'doc-1', title: 'Image', kind: 'IMAGE', date: '', body: [], regions: [], mimeType: 'image/png' }],
evidence: [{ ...folder, type: 'evidence', sourceDocumentId: 'doc-1', containedDocumentIds: ['doc-1'], config: undefined }],
relations: undefined,
} as unknown as CaseState
const normalized = normalizeCase(legacy)
expect(normalized.evidence[0]).toMatchObject({ type: 'folder', config: {}, containedDocumentIds: ['doc-1'] })
expect(normalized.documents[0]).toMatchObject({ fileType: 'image', metadata: {} })
expect(normalized.relations).toHaveLength(1)
})
})
+94
View File
@@ -0,0 +1,94 @@
import type { CaseState, Evidence, Viewport, WidgetRelation } from './types'
export const MIN_BOARD_ZOOM = 0.45
export const MAX_BOARD_ZOOM = 1.5
export function clampBoardZoom(zoom: number) {
return Math.max(MIN_BOARD_ZOOM, Math.min(MAX_BOARD_ZOOM, zoom))
}
export function zoomFromWheel(currentZoom: number, deltaY: number) {
return clampBoardZoom(currentZoom - deltaY * 0.006)
}
export function moveBoardPoint(origin: { x: number; y: number }, screenDelta: { x: number; y: number }, zoom: number) {
if (!Number.isFinite(zoom) || zoom <= 0) throw new RangeError('Board zoom must be positive')
return { x: origin.x + screenDelta.x / zoom, y: origin.y + screenDelta.y / zoom }
}
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
}
export function dateValue(date: string) {
const parsed = Date.parse(date)
return Number.isFinite(parsed) ? parsed : 0
}
export function timelineRange(dates: string[], fallbackYear = new Date().getFullYear()) {
const years = dates
.map(date => Number(date.slice(0, 4)))
.filter(year => Number.isFinite(year) && year >= 1 && year <= 9999)
let startYear = years.length ? Math.min(...years) : fallbackYear - 2
let endYear = years.length ? Math.max(...years) : startYear + 4
if (endYear - startYear < 4) {
const missing = 4 - (endYear - startYear)
startYear -= Math.floor(missing / 2)
endYear += Math.ceil(missing / 2)
}
return { startYear, endYear, start: Date.UTC(startYear, 0, 1), end: Date.UTC(endYear, 11, 31) }
}
export function timelinePositionPercent(date: string, range: Pick<ReturnType<typeof timelineRange>, 'start' | 'end'>) {
if (range.end <= range.start) throw new RangeError('Timeline range must have positive duration')
return Math.max(0, Math.min(100, ((dateValue(date) - range.start) / (range.end - range.start)) * 100))
}
export function containedIds(state: CaseState, widgetId: string) {
return state.relations
.filter(relation => relation.type === 'contains' && relation.fromWidgetId === widgetId)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
.map(relation => relation.toWidgetId)
}
export function folderIsOpen(folder: Evidence) {
return folder.config?.open === true
}
export function relationPosition(state: CaseState, relation: WidgetRelation) {
const folder = state.evidence.find(widget => widget.id === relation.fromWidgetId)
const order = relation.sortOrder || 0
const configuredX = Number(relation.config?.x)
const configuredY = Number(relation.config?.y)
return {
x: Number.isFinite(configuredX) ? configuredX : (folder?.x || 100) + (folder?.width || 240) + 90 + (order % 3) * 205,
y: Number.isFinite(configuredY) ? configuredY : (folder?.y || 100) - 30 + Math.floor(order / 3) * 185,
}
}
export function normalizeCase(state: CaseState): CaseState {
const relations = Array.isArray(state.relations)
? state.relations
: state.evidence.flatMap(widget => (widget.containedDocumentIds || (widget.sourceDocumentId ? [widget.sourceDocumentId] : [])).map((documentId, index) => ({
id: `contains:${widget.id}:${documentId}`,
fromWidgetId: widget.id,
toWidgetId: documentId,
type: 'contains',
sortOrder: index,
})))
const normalized = { ...state, relations }
return {
...normalized,
documents: state.documents.map(document => ({
...document,
fileType: document.fileType || (document.mimeType?.startsWith('image/') ? 'image' : document.mimeType === 'application/pdf' ? 'pdf' : 'file'),
metadata: document.metadata || {},
})),
evidence: state.evidence.map(widget => ({
...widget,
type: widget.type === 'evidence' ? 'folder' : widget.type,
config: widget.config || {},
containedDocumentIds: containedIds(normalized, widget.id),
})),
}
}