📄 src/models/test/relations.test.ts
D-OPEN SOVEREIGN
1/*
2 * Copyright (c) 2026 DataPond D-Library Pty Ltd. ("The Sanctuary")
3 * Non-Profit Public Library — The World Library
4 * 
5 * D-SAFE Certification issued by POND Enterprise.
6 * Published under the D-Open Code Sovereign Licence v1.0 framework
7 * DataPond D-Library All exclusive Right reserved on modifiying the code - CC Attribution to data pond, Non modifiable, Non Commercial.
8 * https://registry.world.bibliotech.com/licence
9 * Source code donated to DataPond D-Library by it's author: data pond.
10 * 
11 * Technical Guardian ("The Shield"): Pond Enterprise Pty Ltd. (ACN 694 747 987)
12 * All liability claims about D-SAFE certification issues coming from the published D-Safe direction must be addressed with Pond Enterprise Pty Ltd.
13 * Code Modification automatically voids the certified liability protection provided by Pond Enterprise.
14 */
15import { describe, it, expect, beforeEach } from 'vitest';
16import {
17    writeSidecar, parseSidecar, colsFromSidecar, extractRecordCols,
18    ColumnType, type SidecarInput,
19} from '../enriched-index.ts';
20import { SidecarType } from '../enums.ts';
21import { Tag, NewTagWithId, type TagExtendedCols } from '../generated-src/Tag.ts';
22import { Book, NewBookWithId, type BookExtendedCols } from '../generated-src/Book.ts';
23import { clear, configureDb } from '../db.ts';
24import { setDefaultLanguage } from '../orm.ts';
25import { PatchInstance } from '../patcher.ts';
26
27beforeEach(() => {
28    clear();
29    configureDb({ cacheLimit: 10_000 });
30    setDefaultLanguage('eng');
31    PatchInstance.clearQueue();
32});
33
34// A zero-filled BookExtendedCols of the given UIDs, so mergeExtended (which
35// reads every scalar column) never dereferences undefined.
36const u8 = (n: number) => new Uint8Array(n);
37const emptyBookExtended = (ids: number[]): BookExtendedCols => ({
38    ids: new Uint16Array(ids),
39    descriptions: ids.map(() => null),
40    prerequisites: ids.map(() => null),
41    deweyDecimals: ids.map(() => null),
42    locCallNumbers: ids.map(() => null),
43    copyrightStatus: u8(ids.length), textQuality: u8(ids.length), illustrations: u8(ids.length),
44    educationQuality: u8(ids.length), practicality: u8(ids.length), accessibilityLevel: u8(ids.length),
45    teacherMaterials: u8(ids.length), pedagogicalScaffolding: u8(ids.length), clarityOfExplanation: u8(ids.length),
46    workedExamples: u8(ids.length), exercisesAndAssessment: u8(ids.length), selfStudyReadiness: u8(ids.length),
47    referenceApparatus: u8(ids.length), safetyGuidance: u8(ids.length), contentCurrency: u8(ids.length),
48    ecoRegeneration: u8(ids.length), communityLiving: u8(ids.length), promotesPeace: u8(ids.length),
49    businessDevelopment: u8(ids.length),
50});
51
52describe('Generalized relation serialization (option A)', () => {
53    it('round-trips multiple named has-relation tails, preserving per-record lists & order', () => {
54        const input: SidecarInput = {
55            sidecarType: SidecarType.Extended,
56            recordCount: 3,
57            columns: [
58                { name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([100, 101, 102]) },
59                { name: 'descriptions', type: ColumnType.NullableString, data: ['a', null, 'c'] },
60            ],
61            relations: [
62                { name: 'tags', data: [[2, 16], [], [16]] },
63                { name: 'books', data: [[500], [501, 502], []] },
64            ],
65        };
66        const parsed = parseSidecar(writeSidecar(input));
67
68        expect(parsed.relations!.get('tags')).toEqual([[2, 16], [], [16]]);
69        expect(parsed.relations!.get('books')).toEqual([[500], [501, 502], []]);
70        // No `authors` relation present → the back-compat alias stays undefined.
71        expect(parsed.authorIds).toBeUndefined();
72    });
73
74    it('is self-describing: the parser needs no schema knowledge of the relation names', () => {
75        // A relation name the parser has never heard of still round-trips.
76        const parsed = parseSidecar(writeSidecar({
77            sidecarType: SidecarType.Extended,
78            recordCount: 1,
79            columns: [{ name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([0]) }],
80            relations: [{ name: 'someFutureRelation', data: [[42, 43]] }],
81        }));
82        expect(parsed.relations!.get('someFutureRelation')).toEqual([[42, 43]]);
83    });
84
85    it('keeps authorIds sugar working alongside named relations', () => {
86        const parsed = parseSidecar(writeSidecar({
87            sidecarType: SidecarType.Extended,
88            recordCount: 2,
89            columns: [{ name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([0, 1]) }],
90            authorIds: [[7], [8, 9]],
91            relations: [{ name: 'tags', data: [[1], [2]] }],
92        }));
93        expect(parsed.authorIds).toEqual([[7], [8, 9]]);
94        expect(parsed.relations!.get('authors')).toEqual([[7], [8, 9]]);
95        expect(parsed.relations!.get('tags')).toEqual([[1], [2]]);
96    });
97
98    it('exposes relations by name through colsFromSidecar and extractRecordCols', () => {
99        const parsed = parseSidecar(writeSidecar({
100            sidecarType: SidecarType.Extended,
101            recordCount: 3,
102            columns: [{ name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([100, 101, 102]) }],
103            relations: [
104                { name: 'tags', data: [[2, 16], [], [16]] },
105                { name: 'books', data: [[500], [501, 502], []] },
106            ],
107        }));
108
109        const cols = colsFromSidecar(parsed);
110        expect(cols['tags']).toEqual([[2, 16], [], [16]]);
111        expect(cols['books']).toEqual([[500], [501, 502], []]);
112
113        const single = extractRecordCols(parsed, 1);
114        expect(single['tags']).toEqual([[]]);
115        expect(single['books']).toEqual([[501, 502]]);
116    });
117
118    it('an Extended sidecar with no relations leaves relations/authorIds undefined', () => {
119        const parsed = parseSidecar(writeSidecar({
120            sidecarType: SidecarType.Extended,
121            recordCount: 1,
122            columns: [{ name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([0]) }],
123        }));
124        expect(parsed.relations).toBeUndefined();
125        expect(parsed.authorIds).toBeUndefined();
126    });
127});
128
129describe('Relation tails hydrate the ORM graph', () => {
130    it('Tag.mergeExtended populates the tags (10) and books (11) has-sets', () => {
131        const tag = NewTagWithId(16);
132        const cols: TagExtendedCols = {
133            ids: new Uint16Array([16]),
134            descriptions: ['Science'],
135            tags: [[2]],            // Tag 16 has sub-tag 2
136            books: [[500, 501]],    // Tag 16 has books 500, 501
137            ddcNotations: [null],
138        };
139        tag.mergeExtended(cols, 0, 'eng');
140
141        expect(tag.tagsIds).toEqual([2]);
142        expect([...tag.booksIds].sort((a, b) => a - b)).toEqual([500, 501]);
143    });
144
145    it('full pipeline: build → parse → colsFromSidecar → mergeExtended for a Tag', () => {
146        const parsed = parseSidecar(writeSidecar({
147            sidecarType: SidecarType.Extended,
148            recordCount: 1,
149            columns: [
150                { name: 'ids', type: ColumnType.Uint16, data: new Uint16Array([16]) },
151                { name: 'descriptions', type: ColumnType.NullableString, data: ['Science'] },
152            ],
153            relations: [
154                { name: 'tags', data: [[2]] },
155                { name: 'books', data: [[500, 501]] },
156            ],
157        }));
158        const cols = colsFromSidecar(parsed) as TagExtendedCols;
159
160        const tag = NewTagWithId(16);
161        tag.mergeExtended(cols, 0, 'eng');
162
163        expect(tag.tagsIds).toEqual([2]);
164        expect([...tag.booksIds].sort((a, b) => a - b)).toEqual([500, 501]);
165        expect(tag.description).toBe('Science');
166    });
167
168    it('regression: Book.authors still hydrates from the authorIds tail', () => {
169        const book = NewBookWithId(10);
170        book.mergeExtended({ ...emptyBookExtended([10]), authorIds: [[5, 7]] }, 0, 'eng');
171        expect([...book.authorsIds].sort((a, b) => a - b)).toEqual([5, 7]);
172    });
173});
174