Skip to content

Commit bf1a452

Browse files
dani-polaniclaude
andcommitted
test: extract align logic to lib and add unit tests
Moves buildAlignUrl and parseAlignBody from +server.ts into src/lib/api/align.ts so they can be unit tested. Adds 17 tests covering happy paths, round-trip decoding, token ID mapping, and error cases (non-adjacent lines, out-of-range indices). All 48 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6b35f36 commit bf1a452

3 files changed

Lines changed: 271 additions & 122 deletions

File tree

bitext/src/lib/api/align.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { buildAlignUrl, parseAlignBody } from './align.js';
3+
import { decodeState } from '$lib/serialization/decode.js';
4+
5+
const ORIGIN = 'https://example.com';
6+
7+
// --- parseAlignBody ---
8+
9+
describe('parseAlignBody', () => {
10+
it('rejects non-object body', () => {
11+
expect(parseAlignBody(null)).toMatchObject({ err: expect.any(String) });
12+
expect(parseAlignBody('string')).toMatchObject({ err: expect.any(String) });
13+
});
14+
15+
it('rejects missing lines', () => {
16+
expect(parseAlignBody({})).toMatchObject({ err: expect.stringContaining('"lines"') });
17+
});
18+
19+
it('rejects empty lines array', () => {
20+
expect(parseAlignBody({ lines: [] })).toMatchObject({ err: expect.any(String) });
21+
});
22+
23+
it('rejects non-string line', () => {
24+
expect(parseAlignBody({ lines: [1, 2] })).toMatchObject({ err: expect.any(String) });
25+
});
26+
27+
it('rejects more than 8 lines', () => {
28+
expect(parseAlignBody({ lines: Array(9).fill('x') })).toMatchObject({ err: expect.any(String) });
29+
});
30+
31+
it('accepts lines without alignments', () => {
32+
const result = parseAlignBody({ lines: ['Hello', 'Bonjour'] });
33+
expect(result).toMatchObject({ ok: { lines: ['Hello', 'Bonjour'], alignments: [] } });
34+
});
35+
36+
it('accepts lines with alignments', () => {
37+
const result = parseAlignBody({ lines: ['Hello', 'Bonjour'], alignments: [[0, 0, 1, 0]] });
38+
expect(result).toMatchObject({ ok: { lines: ['Hello', 'Bonjour'], alignments: [[0, 0, 1, 0]] } });
39+
});
40+
41+
it('rejects alignment tuples that are not length-4 integer arrays', () => {
42+
expect(parseAlignBody({ lines: ['a', 'b'], alignments: [[0, 0, 1]] })).toMatchObject({
43+
err: expect.any(String)
44+
});
45+
expect(parseAlignBody({ lines: ['a', 'b'], alignments: [[0, 0, 1, 0.5]] })).toMatchObject({
46+
err: expect.any(String)
47+
});
48+
});
49+
});
50+
51+
// --- buildAlignUrl ---
52+
53+
describe('buildAlignUrl', () => {
54+
it('returns a URL with ?data= for two lines', () => {
55+
const result = buildAlignUrl(ORIGIN, { lines: ['Hello world', 'Bonjour le monde'] });
56+
expect(result).not.toHaveProperty('err');
57+
if ('url' in result) {
58+
expect(result.url).toMatch(/^https:\/\/example\.com\/\?data=/);
59+
}
60+
});
61+
62+
it('the encoded URL round-trips: decoding it yields the original lines', () => {
63+
const lines = ['Hello world', 'Bonjour le monde'];
64+
const result = buildAlignUrl(ORIGIN, { lines });
65+
expect(result).not.toHaveProperty('err');
66+
if (!('url' in result)) return;
67+
68+
const dataParam = new URL(result.url).searchParams.get('data');
69+
expect(dataParam).toBeTruthy();
70+
const state = decodeState(dataParam);
71+
expect(state.project.lines.map((l) => l.rawText)).toEqual(lines);
72+
});
73+
74+
it('encodes alignments correctly: connections appear in decoded state', () => {
75+
const result = buildAlignUrl(ORIGIN, {
76+
lines: ['Hello world', 'Bonjour le monde'],
77+
alignments: [[0, 0, 1, 0]]
78+
});
79+
expect(result).not.toHaveProperty('err');
80+
if (!('url' in result)) return;
81+
82+
const dataParam = new URL(result.url).searchParams.get('data');
83+
const state = decodeState(dataParam);
84+
expect(state.project.connections).toHaveLength(1);
85+
expect(state.project.connections[0]!.upperTokenId).toBe('l0-0');
86+
expect(state.project.connections[0]!.lowerTokenId).toBe('l1-0');
87+
});
88+
89+
it('maps word indices to correct token IDs for multi-word line', () => {
90+
// "Bonjour le monde": word 2 → "monde" → token id l1-2
91+
const result = buildAlignUrl(ORIGIN, {
92+
lines: ['Hello world', 'Bonjour le monde'],
93+
alignments: [[0, 1, 1, 2]]
94+
});
95+
if (!('url' in result)) throw new Error('expected url');
96+
const state = decodeState(new URL(result.url).searchParams.get('data'));
97+
expect(state.project.connections[0]!.upperTokenId).toBe('l0-1'); // "world"
98+
expect(state.project.connections[0]!.lowerTokenId).toBe('l1-2'); // "monde"
99+
});
100+
101+
it('handles 3 lines with alignments between each adjacent pair', () => {
102+
const result = buildAlignUrl(ORIGIN, {
103+
lines: ['A B', 'C D', 'E F'],
104+
alignments: [
105+
[0, 0, 1, 0],
106+
[1, 1, 2, 1]
107+
]
108+
});
109+
expect(result).not.toHaveProperty('err');
110+
if (!('url' in result)) return;
111+
const state = decodeState(new URL(result.url).searchParams.get('data'));
112+
expect(state.project.connections).toHaveLength(2);
113+
});
114+
115+
it('rejects line index out of range', () => {
116+
const result = buildAlignUrl(ORIGIN, {
117+
lines: ['a', 'b'],
118+
alignments: [[0, 0, 5, 0]]
119+
});
120+
expect(result).toMatchObject({ err: expect.stringContaining('lineB=5') });
121+
});
122+
123+
it('rejects non-adjacent lines', () => {
124+
const result = buildAlignUrl(ORIGIN, {
125+
lines: ['a', 'b', 'c'],
126+
alignments: [[0, 0, 2, 0]]
127+
});
128+
expect(result).toMatchObject({ err: expect.stringContaining('not adjacent') });
129+
});
130+
131+
it('rejects word index out of range with helpful message', () => {
132+
const result = buildAlignUrl(ORIGIN, {
133+
lines: ['Hello', 'World'],
134+
alignments: [[0, 5, 1, 0]]
135+
});
136+
expect(result).toMatchObject({ err: expect.stringContaining('word 5 out of range') });
137+
});
138+
139+
it('works with a single line and no alignments', () => {
140+
const result = buildAlignUrl(ORIGIN, { lines: ['Solo line'] });
141+
expect(result).not.toHaveProperty('err');
142+
});
143+
144+
it('handles 8 lines (max)', () => {
145+
const lines = Array.from({ length: 8 }, (_, i) => `line ${i}`);
146+
const result = buildAlignUrl(ORIGIN, { lines });
147+
expect(result).not.toHaveProperty('err');
148+
});
149+
});

bitext/src/lib/api/align.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { encodeState } from '$lib/serialization/encode.js';
2+
import { tokenize, tokenizeOptionsFromVisualSettings } from '$lib/domain/tokens.js';
3+
import { createConnectionId, type Connection } from '$lib/domain/alignment.js';
4+
import { assignColorsInOrder } from '$lib/domain/palettes.js';
5+
import {
6+
defaultVisualSettingsV2,
7+
SCHEMA_VERSION,
8+
type AppStateV2,
9+
type LineV2
10+
} from '$lib/serialization/schema.js';
11+
12+
const DEFAULT_FONT = { family: 'Inter', source: 'google' as const };
13+
const DEFAULT_TEXT_SIZE_PX = 36;
14+
const DEFAULT_WORD_GAP_PX = 14;
15+
16+
export type AlignmentTuple = [number, number, number, number];
17+
18+
export interface AlignRequest {
19+
lines: string[];
20+
alignments?: AlignmentTuple[];
21+
}
22+
23+
export type AlignResult = { url: string } | { err: string };
24+
25+
export function parseAlignBody(body: unknown): { ok: AlignRequest } | { err: string } {
26+
if (!body || typeof body !== 'object') return { err: 'Body must be a JSON object' };
27+
const b = body as Record<string, unknown>;
28+
29+
if (!Array.isArray(b.lines) || b.lines.length === 0)
30+
return { err: '"lines" must be a non-empty array' };
31+
32+
const lines: string[] = [];
33+
for (const l of b.lines) {
34+
if (typeof l !== 'string') return { err: 'Each element of "lines" must be a string' };
35+
lines.push(l);
36+
}
37+
if (lines.length > 8) return { err: 'Maximum 8 lines allowed' };
38+
39+
const alignments: AlignmentTuple[] = [];
40+
if (b.alignments !== undefined) {
41+
if (!Array.isArray(b.alignments)) return { err: '"alignments" must be an array' };
42+
for (const a of b.alignments) {
43+
if (
44+
!Array.isArray(a) ||
45+
a.length !== 4 ||
46+
a.some((x) => typeof x !== 'number' || !Number.isInteger(x))
47+
)
48+
return { err: 'Each alignment must be [lineA, wordA, lineB, wordB] (integers)' };
49+
alignments.push(a as AlignmentTuple);
50+
}
51+
}
52+
53+
return { ok: { lines, alignments } };
54+
}
55+
56+
export function buildAlignUrl(origin: string, req: AlignRequest): AlignResult {
57+
const settings = defaultVisualSettingsV2();
58+
const tzOpts = tokenizeOptionsFromVisualSettings(settings);
59+
const { lines, alignments = [] } = req;
60+
61+
const lineObjects: LineV2[] = lines.map((rawText, i) => ({
62+
id: `l${i}`,
63+
rawText,
64+
font: { ...DEFAULT_FONT },
65+
textSizePx: DEFAULT_TEXT_SIZE_PX,
66+
gapWordPx: DEFAULT_WORD_GAP_PX
67+
}));
68+
69+
const tokensByLine = lineObjects.map((line) => tokenize(line.rawText, line.id, tzOpts));
70+
const colors = assignColorsInOrder(settings.palette, Math.max(alignments.length, 1));
71+
72+
const connections: Connection[] = [];
73+
for (let idx = 0; idx < alignments.length; idx++) {
74+
const [lineA, wordA, lineB, wordB] = alignments[idx]!;
75+
76+
if (lineA < 0 || lineA >= lines.length)
77+
return { err: `alignments[${idx}]: lineA=${lineA} out of range (0–${lines.length - 1})` };
78+
if (lineB < 0 || lineB >= lines.length)
79+
return { err: `alignments[${idx}]: lineB=${lineB} out of range (0–${lines.length - 1})` };
80+
if (Math.abs(lineA - lineB) !== 1)
81+
return {
82+
err: `alignments[${idx}]: lines ${lineA} and ${lineB} are not adjacent (connections only allowed between adjacent lines)`
83+
};
84+
85+
const upperIdx = Math.min(lineA, lineB);
86+
const lowerIdx = Math.max(lineA, lineB);
87+
const upperWordIdx = lineA < lineB ? wordA : wordB;
88+
const lowerWordIdx = lineA < lineB ? wordB : wordA;
89+
90+
const upperTokens = tokensByLine[upperIdx]!;
91+
const lowerTokens = tokensByLine[lowerIdx]!;
92+
93+
if (upperWordIdx < 0 || upperWordIdx >= upperTokens.length)
94+
return {
95+
err: `alignments[${idx}]: word ${upperWordIdx} out of range for line ${upperIdx} ("${lines[upperIdx]}" has ${upperTokens.length} word(s))`
96+
};
97+
if (lowerWordIdx < 0 || lowerWordIdx >= lowerTokens.length)
98+
return {
99+
err: `alignments[${idx}]: word ${lowerWordIdx} out of range for line ${lowerIdx} ("${lines[lowerIdx]}" has ${lowerTokens.length} word(s))`
100+
};
101+
102+
connections.push({
103+
id: createConnectionId(),
104+
upperTokenId: upperTokens[upperWordIdx]!.id,
105+
lowerTokenId: lowerTokens[lowerWordIdx]!.id,
106+
color: colors[idx % colors.length]
107+
});
108+
}
109+
110+
const state: AppStateV2 = {
111+
v: SCHEMA_VERSION,
112+
project: { lines: lineObjects, pairControls: [], linePairGaps: [], connections },
113+
settings
114+
};
115+
116+
const dataParam = encodeState(state);
117+
const u = new URL('/', origin);
118+
u.searchParams.set('data', dataParam);
119+
return { url: u.toString() };
120+
}

0 commit comments

Comments
 (0)