diff --git a/README.md b/README.md index e8677aa..bfc84e6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index 00323db..a7b2796 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 diff --git a/package.json b/package.json index 9b10d9e..ce86d06 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts new file mode 100644 index 0000000..c16d4bb --- /dev/null +++ b/server/api.integration.test.ts @@ -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 +let appServer: Awaited['server'] +let appPool: Awaited['pool'] +let baseUrl = '' + +async function availablePort() { + return new Promise((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((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 } }) + }) +}) diff --git a/server/index.ts b/server/index.ts index 0f9bec6..f34646b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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) +} diff --git a/server/migrate.ts b/server/migrate.ts index 5c38f5a..da7ee56 100644 --- a/server/migrate.ts +++ b/server/migrate.ts @@ -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) diff --git a/server/migrations.integration.test.ts b/server/migrations.integration.test.ts new file mode 100644 index 0000000..b74c50e --- /dev/null +++ b/server/migrations.integration.test.ts @@ -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 +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) + }) +}) diff --git a/server/migrations.ts b/server/migrations.ts new file mode 100644 index 0000000..8041856 --- /dev/null +++ b/server/migrations.ts @@ -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() + } +} diff --git a/src/App.tsx b/src/App.tsx index d7305ce..f7b9719 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() - 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(null) const [noLevels, setNoLevels] = useState(false) @@ -214,9 +191,9 @@ export function App() { - + {Math.round(caseState.viewport.zoom * 100)}% - + {draggingFiles &&
ADD SOURCE DOCUMENTSDROP FILES INTO THIS LEVEL
} @@ -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 } -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
TEMPORAL INDEXTIMELINE{items.length} DATED ITEMS
{years.map(year => {year})}{items.map((item, i) => )}
SOURCE SELECTED
} diff --git a/src/boardDomain.test.ts b/src/boardDomain.test.ts new file mode 100644 index 0000000..f37d338 --- /dev/null +++ b/src/boardDomain.test.ts @@ -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) + }) +}) diff --git a/src/boardDomain.ts b/src/boardDomain.ts new file mode 100644 index 0000000..989aed7 --- /dev/null +++ b/src/boardDomain.ts @@ -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, '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), + })), + } +}