test: add board and postgres safety net
This commit is contained in:
+9
-41
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
})),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user