206 lines
7.2 KiB
TypeScript
206 lines
7.2 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
|
||
import { EN_SURFACES } from '@/i18n/en-surfaces'
|
||
import { TR_SURFACES } from '@/i18n/tr-surfaces'
|
||
import type { StarmapGraph } from '@/types/hermes'
|
||
|
||
import { decodeShareCode, encodeShareCode, ShareCodeError, shareCodeErrorText } from './share-code'
|
||
|
||
function sampleGraph(): StarmapGraph {
|
||
return {
|
||
clusters: [],
|
||
edges: [
|
||
{ source: 'skill-a', target: 'skill-b' },
|
||
{ source: 'skill-b', target: 'memory:profile:0' }
|
||
],
|
||
memory: [
|
||
{ body: 'Prefers concise answers.', source: 'profile', timestamp: 1_700_000_000, title: 'Tone' },
|
||
{ body: 'Uses a worktree.', source: 'memory', timestamp: null, title: 'Env' }
|
||
],
|
||
nodes: [
|
||
{
|
||
category: 'devops',
|
||
createdBy: 'agent',
|
||
id: 'skill-a',
|
||
kind: 'skill',
|
||
label: 'skill-a',
|
||
pinned: true,
|
||
state: 'active',
|
||
timestamp: 1_699_900_000,
|
||
useCount: 7
|
||
},
|
||
{
|
||
category: 'devops',
|
||
createdBy: null,
|
||
id: 'skill-b',
|
||
kind: 'skill',
|
||
label: 'skill-b',
|
||
pinned: false,
|
||
state: 'draft',
|
||
timestamp: 1_699_950_000,
|
||
useCount: 0
|
||
},
|
||
{
|
||
category: 'memory',
|
||
createdBy: null,
|
||
id: 'memory:profile:0',
|
||
kind: 'memory',
|
||
label: 'A fact',
|
||
memorySource: 'profile',
|
||
pinned: false,
|
||
state: 'active',
|
||
timestamp: 1_700_000_000,
|
||
useCount: 0
|
||
}
|
||
],
|
||
stats: {}
|
||
}
|
||
}
|
||
|
||
// Decoded edges compared by node POSITION (ids are synthesized), so topology is
|
||
// the invariant, not the literal id strings.
|
||
const topology = (g: StarmapGraph): [number, number][] => {
|
||
const idx = new Map(g.nodes.map((n, i) => [n.id, i]))
|
||
|
||
return g.edges.map(e => [idx.get(e.source)!, idx.get(e.target)!])
|
||
}
|
||
|
||
describe('share-code', () => {
|
||
// The viz contract: everything the star map RENDERS survives — kinds, radius
|
||
// inputs, time position, edge topology — while text is dropped (it's a loadout,
|
||
// not a backup).
|
||
it('preserves the visualization', () => {
|
||
const g = sampleGraph()
|
||
const decoded = decodeShareCode(encodeShareCode(g))
|
||
const span = 1_700_000_000 - 1_699_900_000
|
||
const tol = Math.ceil(span / 4095) + 1
|
||
|
||
expect(decoded.nodes).toHaveLength(g.nodes.length)
|
||
|
||
decoded.nodes.forEach((d, i) => {
|
||
const o = g.nodes[i]!
|
||
expect(d.kind).toBe(o.kind)
|
||
expect(d.label).toBe(o.label)
|
||
expect(d.useCount).toBe(o.useCount)
|
||
expect(d.state).toBe(o.state)
|
||
expect(d.pinned).toBe(o.pinned)
|
||
expect(d.category).toBe(o.category)
|
||
expect(d.memorySource).toBe(o.memorySource)
|
||
expect(d.createdBy).toBe(o.createdBy)
|
||
|
||
if (o.timestamp == null) {
|
||
expect(d.timestamp).toBeNull()
|
||
} else {
|
||
expect(Math.abs((d.timestamp ?? 0) - o.timestamp)).toBeLessThanOrEqual(tol)
|
||
}
|
||
})
|
||
|
||
expect(topology(decoded)).toEqual(topology(g))
|
||
})
|
||
|
||
it('drops memory prose (loadout is viz-only)', () => {
|
||
expect(decodeShareCode(encodeShareCode(sampleGraph())).memory).toHaveLength(0)
|
||
})
|
||
|
||
it('rebuilds clusters from node categories', () => {
|
||
const decoded = decodeShareCode(encodeShareCode(sampleGraph()))
|
||
|
||
expect(decoded.clusters.find(c => c.category === 'devops')?.count).toBe(2)
|
||
})
|
||
|
||
it('produces a short, opaque, prefixed code', () => {
|
||
const code = encodeShareCode(sampleGraph())
|
||
|
||
expect(code.startsWith('HML')).toBe(true)
|
||
expect(code.slice(3)).toMatch(/^[A-Za-z0-9_-]+$/)
|
||
// Strictly smaller than the naive JSON it replaces — the whole point.
|
||
expect(code.length).toBeLessThan(JSON.stringify(sampleGraph()).length)
|
||
})
|
||
|
||
it('stays compact on a large graph (no string bloat)', () => {
|
||
const nodes = Array.from({ length: 500 }, (_, i) => ({
|
||
category: `cat-${i % 8}`,
|
||
createdBy: 'agent' as const,
|
||
id: `s${i}`,
|
||
kind: 'skill' as const,
|
||
label: `A fairly verbose skill label number ${i}`,
|
||
pinned: false,
|
||
state: 'active',
|
||
timestamp: 1_700_000_000 + i * 3600,
|
||
useCount: i % 50
|
||
}))
|
||
|
||
const graph: StarmapGraph = { clusters: [], edges: [], memory: [], nodes, stats: {} }
|
||
const code = encodeShareCode(graph)
|
||
|
||
// Deflate keeps even verbose, repetitive labels far below the naive JSON.
|
||
expect(code.length).toBeLessThan(JSON.stringify(graph).length / 5)
|
||
})
|
||
|
||
it('handles an empty graph', () => {
|
||
const decoded = decodeShareCode(encodeShareCode({ clusters: [], edges: [], memory: [], nodes: [], stats: {} }))
|
||
|
||
expect(decoded.nodes).toHaveLength(0)
|
||
expect(decoded.edges).toHaveLength(0)
|
||
})
|
||
|
||
it('drops edges whose endpoints are missing', () => {
|
||
const g = sampleGraph()
|
||
g.edges.push({ source: 'skill-a', target: 'does-not-exist' })
|
||
|
||
expect(decodeShareCode(encodeShareCode(g)).edges).toHaveLength(2)
|
||
})
|
||
|
||
it('rejects garbage with a ShareCodeError', () => {
|
||
expect(() => decodeShareCode('not a real code !!!')).toThrow(ShareCodeError)
|
||
expect(() => decodeShareCode('')).toThrow(ShareCodeError)
|
||
})
|
||
|
||
it('formats real decoding failures in the selected language without changing valid codes', () => {
|
||
const graph = sampleGraph()
|
||
const valid = encodeShareCode(graph)
|
||
|
||
const capture = (code: string): unknown => {
|
||
try { decodeShareCode(code) } catch (error) { return error }
|
||
throw new Error('Expected a decoding failure')
|
||
}
|
||
|
||
const invalid = capture('not a real code !!!')
|
||
expect(shareCodeErrorText(invalid, TR_SURFACES.mapErrors)).toBe('Bu metin bir harita koduna benzemiyor.')
|
||
expect(shareCodeErrorText(invalid, EN_SURFACES.mapErrors)).toBe("That doesn't look like a map code.")
|
||
|
||
const raw = atob(valid.slice(3).replace(/-/g, '+').replace(/_/g, '/'))
|
||
const expected = raw.charCodeAt(0)
|
||
const actual = (expected + 1) % 256
|
||
|
||
const otherVersion = 'HML' + btoa(String.fromCharCode(actual) + raw.slice(1))
|
||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||
|
||
expect(shareCodeErrorText(capture(otherVersion), TR_SURFACES.mapErrors))
|
||
.toBe(TR_SURFACES.mapErrors.version(actual, expected))
|
||
const middle = Math.floor(valid.length / 2)
|
||
const corrupted = valid.slice(0, middle) + (valid[middle] === 'A' ? 'B' : 'A') + valid.slice(middle + 1)
|
||
expect(shareCodeErrorText(capture(corrupted), TR_SURFACES.mapErrors)).toBe(TR_SURFACES.mapErrors.corrupted)
|
||
expect(shareCodeErrorText(new Error('internal details'), TR_SURFACES.mapErrors)).toBe('Harita kodu okunamadı.')
|
||
expect(encodeShareCode(graph)).toBe(valid)
|
||
expect(decodeShareCode(valid).nodes).toHaveLength(graph.nodes.length)
|
||
})
|
||
|
||
it('rejects a corrupted (bit-flipped) code', () => {
|
||
const code = encodeShareCode(sampleGraph())
|
||
// Flip a mid-payload char (trailing base64 bits can be dropped on decode).
|
||
const i = Math.floor(code.length / 2)
|
||
const corrupted = code.slice(0, i) + (code[i] === 'A' ? 'B' : 'A') + code.slice(i + 1)
|
||
|
||
expect(() => decodeShareCode(corrupted)).toThrow(ShareCodeError)
|
||
})
|
||
|
||
it('tolerates whitespace, including internal wraps', () => {
|
||
const code = encodeShareCode(sampleGraph())
|
||
const wrapped = ` ${code.slice(0, 20)}\n${code.slice(20)}\t`
|
||
|
||
expect(() => decodeShareCode(wrapped)).not.toThrow()
|
||
expect(decodeShareCode(wrapped).nodes).toHaveLength(sampleGraph().nodes.length)
|
||
})
|
||
})
|