2026-08-14 12:57:06 +02:00
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
|
import type { CaseState, Evidence, WidgetRelation } from './types'
|
|
|
|
|
import {
|
|
|
|
|
MAX_BOARD_ZOOM,
|
|
|
|
|
MIN_BOARD_ZOOM,
|
|
|
|
|
clampBoardZoom,
|
|
|
|
|
containedIds,
|
2026-08-14 19:37:43 +02:00
|
|
|
discardExhibit,
|
2026-08-14 12:57:06 +02:00
|
|
|
folderIsOpen,
|
|
|
|
|
moveBoardPoint,
|
2026-08-14 15:02:00 +02:00
|
|
|
nextOpenBoardPosition,
|
2026-08-14 12:57:06 +02:00
|
|
|
normalizeCase,
|
|
|
|
|
panViewport,
|
|
|
|
|
relationPosition,
|
2026-08-14 18:26:20 +02:00
|
|
|
projectThreadTag,
|
2026-08-14 16:37:50 +02:00
|
|
|
threadCurve,
|
2026-08-14 18:26:20 +02:00
|
|
|
threadTagLateralLimit,
|
|
|
|
|
threadTagPlacement,
|
2026-08-14 12:57:06 +02:00
|
|
|
timelinePositionPercent,
|
|
|
|
|
timelineRange,
|
|
|
|
|
zoomFromWheel,
|
2026-08-14 15:45:03 +02:00
|
|
|
zoomFromPinch,
|
2026-08-14 16:44:43 +02:00
|
|
|
zoomViewportAt,
|
2026-08-14 12:57:06 +02:00
|
|
|
} from './boardDomain'
|
|
|
|
|
|
2026-08-14 16:37:50 +02:00
|
|
|
describe('red thread geometry', () => {
|
|
|
|
|
it('turns tightness into a taut or visibly sagging bezier curve', () => {
|
|
|
|
|
const taut = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 100)
|
|
|
|
|
const slack = threadCurve({ x: 0, y: 0 }, { x: 120, y: 60 }, 0)
|
|
|
|
|
expect(taut.midpoint).toEqual({ x: 60, y: 30 })
|
|
|
|
|
expect(slack.midpoint.y).toBeGreaterThan(taut.midpoint.y)
|
|
|
|
|
expect(slack.path).toMatch(/^M 0 0 C /)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('clamps tightness to its normalized percentage range', () => {
|
|
|
|
|
expect(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 200)).toEqual(threadCurve({ x: 0, y: 0 }, { x: 100, y: 0 }, 100))
|
|
|
|
|
})
|
2026-08-14 18:26:20 +02:00
|
|
|
|
|
|
|
|
it('places and projects tags by percentage along the curve', () => {
|
|
|
|
|
const from = { x: 0, y: 0 }, to = { x: 200, y: 0 }
|
|
|
|
|
expect(threadTagPlacement(from, to, 100, 25, 0)).toMatchObject({ x: 50, y: 0, positionPercent: 25 })
|
|
|
|
|
expect(projectThreadTag(from, to, 100, { x: 150, y: 8 })).toMatchObject({ positionPercent: 75, lateralOffset: 8 })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reduces lateral tag travel as the thread becomes taut', () => {
|
|
|
|
|
expect(threadTagLateralLimit(0)).toBe(70)
|
|
|
|
|
expect(threadTagLateralLimit(100)).toBe(10)
|
|
|
|
|
expect(threadTagPlacement({ x: 0, y: 0 }, { x: 200, y: 0 }, 100, 50, 100).lateralOffset).toBe(10)
|
|
|
|
|
})
|
2026-08-14 16:37:50 +02:00
|
|
|
})
|
|
|
|
|
|
2026-08-14 12:57:06 +02:00
|
|
|
const folder: Evidence = {
|
|
|
|
|
id: 'folder-1',
|
|
|
|
|
type: 'folder',
|
|
|
|
|
title: 'Folder',
|
|
|
|
|
content: '',
|
|
|
|
|
x: 200,
|
|
|
|
|
y: 300,
|
|
|
|
|
width: 260,
|
|
|
|
|
config: { open: false },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state: CaseState = {
|
2026-08-14 14:44:58 +02:00
|
|
|
brief: { body: '', concepts: [] },
|
2026-08-14 12:57:06 +02:00
|
|
|
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 })
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-14 15:45:03 +02:00
|
|
|
it('clamps toolbar, wheel, and touch pinch zoom to the same limits', () => {
|
2026-08-14 12:57:06 +02:00
|
|
|
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)
|
2026-08-14 15:45:03 +02:00
|
|
|
expect(zoomFromWheel(1, 100)).toBeCloseTo(0.85)
|
|
|
|
|
expect(zoomFromPinch(1, 100, 125)).toBeCloseTo(1.25)
|
|
|
|
|
expect(zoomFromPinch(1, 100, 50)).toBeCloseTo(0.5)
|
|
|
|
|
expect(zoomFromPinch(1, 0, 120)).toBe(1)
|
2026-08-14 12:57:06 +02:00
|
|
|
})
|
2026-08-14 15:02:00 +02:00
|
|
|
|
2026-08-14 16:44:43 +02:00
|
|
|
it('keeps the board point beneath the cursor fixed while zooming', () => {
|
|
|
|
|
const anchor = { x: 110, y: 120 }
|
|
|
|
|
const zoomed = zoomViewportAt(state.viewport, 1, anchor)
|
|
|
|
|
expect(zoomed).toEqual({ x: -90, y: -80, zoom: 1 })
|
|
|
|
|
expect(zoomed.x + 200 * zoomed.zoom).toBe(anchor.x)
|
|
|
|
|
expect(zoomed.y + 200 * zoomed.zoom).toBe(anchor.y)
|
|
|
|
|
expect(zoomViewportAt(zoomed, .5, anchor)).toEqual(state.viewport)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-14 15:02:00 +02:00
|
|
|
it('places new exhibits in deterministic open slots', () => {
|
|
|
|
|
const preferred = { x: 500, y: 400 }
|
|
|
|
|
expect(nextOpenBoardPosition([], preferred, { width: 280 })).toEqual(preferred)
|
|
|
|
|
expect(nextOpenBoardPosition([{ x: 500, y: 400, width: 280 }], preferred, { width: 280 })).toEqual({ x: 826, y: 400 })
|
|
|
|
|
expect(nextOpenBoardPosition([
|
|
|
|
|
{ x: 500, y: 400, width: 280 },
|
|
|
|
|
{ x: 826, y: 400, width: 280 },
|
|
|
|
|
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
|
|
|
|
})
|
2026-08-14 12:57:06 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
2026-08-14 15:40:54 +02:00
|
|
|
|
|
|
|
|
it('uses a valid configured date range without automatic year padding', () => {
|
|
|
|
|
const range = timelineRange(['1987-01-08', '1987-10-24'], 2026, { start: '1987-10-01', end: '1987-10-31' })
|
|
|
|
|
expect(new Date(range.start).toISOString()).toBe('1987-10-01T00:00:00.000Z')
|
|
|
|
|
expect(new Date(range.end).toISOString()).toBe('1987-10-31T23:59:59.999Z')
|
|
|
|
|
expect(timelinePositionPercent('1987-10-16', range)).toBeGreaterThan(48)
|
|
|
|
|
expect(timelinePositionPercent('1987-10-16', range)).toBeLessThan(52)
|
|
|
|
|
})
|
2026-08-14 12:57:06 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-08-14 19:37:43 +02:00
|
|
|
|
|
|
|
|
describe('exhibit disposal', () => {
|
|
|
|
|
it('removes an exhibit and every graph reference while retaining source documents', () => {
|
|
|
|
|
const note = { ...folder, id: 'note-1', type: 'note' as const, title: 'Working note' }
|
|
|
|
|
const event = { ...folder, id: 'event-1', type: 'event' as const, supportingEvidenceIds: [note.id, 'doc-1'] }
|
|
|
|
|
const party = { ...folder, id: 'party-1', type: 'party' as const, relatedEvidenceIds: [note.id] }
|
|
|
|
|
const discarded = discardExhibit({
|
|
|
|
|
...state,
|
|
|
|
|
documents: [{ id: 'doc-1', title: 'Source', kind: 'TEXT', date: '', body: [], regions: [], fileType: 'text', metadata: {} }],
|
|
|
|
|
evidence: [folder, note, event, party],
|
|
|
|
|
relations: [{ id: 'nested', fromWidgetId: folder.id, toWidgetId: note.id, type: 'contains' }],
|
|
|
|
|
connections: [{ id: 'thread', fromEvidenceId: note.id, toEvidenceId: party.id }],
|
|
|
|
|
brief: { body: '', concepts: [{ id: 'concept-1', label: 'Unknown', context: '', resolvedPartyExhibitId: note.id }] },
|
|
|
|
|
}, note.id)
|
|
|
|
|
|
|
|
|
|
expect(discarded.documents).toHaveLength(1)
|
|
|
|
|
expect(discarded.evidence.map(exhibit => exhibit.id)).not.toContain(note.id)
|
|
|
|
|
expect(discarded.relations).toEqual([])
|
|
|
|
|
expect(discarded.connections).toEqual([])
|
|
|
|
|
expect(discarded.evidence.find(exhibit => exhibit.id === event.id)?.supportingEvidenceIds).toEqual(['doc-1'])
|
|
|
|
|
expect(discarded.evidence.find(exhibit => exhibit.id === party.id)?.relatedEvidenceIds).toEqual([])
|
|
|
|
|
expect(discarded.brief.concepts[0].resolvedPartyExhibitId).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
})
|