@crossworks/content-core 0.230.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +135 -0
- package/package.json +41 -0
- package/src/block-diff.test.ts +190 -0
- package/src/block-diff.ts +163 -0
- package/src/block-ids.test.ts +358 -0
- package/src/block-ids.ts +242 -0
- package/src/block-list.test.ts +241 -0
- package/src/block-list.ts +177 -0
- package/src/contacts-format.ts +260 -0
- package/src/doc-to-markdown.test.ts +194 -0
- package/src/doc-to-markdown.ts +315 -0
- package/src/formula-dimensions.test.ts +103 -0
- package/src/formula-dimensions.ts +231 -0
- package/src/formula-eval.ts +294 -0
- package/src/formula-seed.test.ts +175 -0
- package/src/formula-seed.ts +466 -0
- package/src/formula-signature.test.ts +336 -0
- package/src/formula-signature.ts +435 -0
- package/src/formula-spec.test.ts +458 -0
- package/src/formula-spec.ts +566 -0
- package/src/journal-options.test.ts +57 -0
- package/src/journal-options.ts +77 -0
- package/src/markdown-refs.test.ts +143 -0
- package/src/markdown-refs.ts +172 -0
- package/src/markdown-to-doc.test.ts +179 -0
- package/src/markdown-to-doc.ts +567 -0
- package/src/onboarding-questions.test.ts +75 -0
- package/src/onboarding-questions.ts +90 -0
- package/src/page-diff.test.ts +82 -0
- package/src/page-diff.ts +120 -0
- package/src/page-split.test.ts +141 -0
- package/src/page-split.ts +128 -0
- package/src/page-toc.test.ts +58 -0
- package/src/page-toc.ts +89 -0
- package/src/persona-bank.test.ts +67 -0
- package/src/persona-bank.ts +234 -0
- package/src/table-formula-mathjs.ts +259 -0
- package/src/table-formula.test.ts +157 -0
- package/src/table-formula.ts +496 -0
- package/src/table-model.test.ts +429 -0
- package/src/table-model.ts +870 -0
- package/src/thinking-tiers.ts +56 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
addColumn,
|
|
4
|
+
addRow,
|
|
5
|
+
addSelectOption,
|
|
6
|
+
applyView,
|
|
7
|
+
tableDocFromGrid,
|
|
8
|
+
cellIsEmpty,
|
|
9
|
+
coerceCell,
|
|
10
|
+
computeAggregate,
|
|
11
|
+
deleteColumn,
|
|
12
|
+
deleteRow,
|
|
13
|
+
emptyTableDoc,
|
|
14
|
+
ensureTableDoc,
|
|
15
|
+
ensureWorkbookDoc,
|
|
16
|
+
findColumnByName,
|
|
17
|
+
groupRows,
|
|
18
|
+
queryRows,
|
|
19
|
+
resolveCell,
|
|
20
|
+
setAggregate,
|
|
21
|
+
setCell,
|
|
22
|
+
setView,
|
|
23
|
+
updateColumn,
|
|
24
|
+
updateRow,
|
|
25
|
+
type TableDoc,
|
|
26
|
+
} from './table-model';
|
|
27
|
+
|
|
28
|
+
function grid(): TableDoc {
|
|
29
|
+
return {
|
|
30
|
+
columns: [
|
|
31
|
+
{ id: 'c_item', name: 'Item', type: 'text' },
|
|
32
|
+
{ id: 'c_qty', name: 'Qty', type: 'number' },
|
|
33
|
+
{ id: 'c_price', name: 'Price', type: 'currency', format: { currency: 'USD', decimals: 2 } },
|
|
34
|
+
],
|
|
35
|
+
rows: [
|
|
36
|
+
{ id: 'r1', cells: { c_item: 'Widget', c_qty: 2, c_price: 9.5 } },
|
|
37
|
+
{ id: 'r2', cells: { c_item: 'Gadget', c_qty: 3, c_price: 4 } },
|
|
38
|
+
],
|
|
39
|
+
aggregates: {},
|
|
40
|
+
views: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('ensureTableDoc', () => {
|
|
45
|
+
it('fills missing arrays and assigns ids', () => {
|
|
46
|
+
const doc = ensureTableDoc({ columns: [{ name: 'A', type: 'text' }] });
|
|
47
|
+
expect(doc.columns[0]!.id).toBeTruthy();
|
|
48
|
+
expect(doc.rows).toEqual([]);
|
|
49
|
+
expect(doc.aggregates).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns the SAME reference when nothing changes', () => {
|
|
53
|
+
const doc = grid();
|
|
54
|
+
expect(ensureTableDoc(doc)).toBe(doc);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('drops cells for unknown columns', () => {
|
|
58
|
+
const doc = ensureTableDoc({
|
|
59
|
+
columns: [{ id: 'c1', name: 'A', type: 'text' }],
|
|
60
|
+
rows: [{ id: 'r1', cells: { c1: 'x', ghost: 'y' } }],
|
|
61
|
+
});
|
|
62
|
+
expect(doc.rows[0]!.cells).toEqual({ c1: 'x' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('coerces an unknown column type to text', () => {
|
|
66
|
+
const doc = ensureTableDoc({ columns: [{ id: 'c1', name: 'A', type: 'wat' }], rows: [] });
|
|
67
|
+
expect(doc.columns[0]!.type).toBe('text');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('ensureWorkbookDoc', () => {
|
|
72
|
+
it('normalizes each tab through ensureTableDoc and defaults names', () => {
|
|
73
|
+
const wb = ensureWorkbookDoc({
|
|
74
|
+
tabs: [
|
|
75
|
+
{ ...grid(), name: ' Models ' },
|
|
76
|
+
{ columns: [{ name: 'A', type: 'text' }], rows: [], name: '' } as never,
|
|
77
|
+
],
|
|
78
|
+
});
|
|
79
|
+
expect(wb.tabs).toHaveLength(2);
|
|
80
|
+
expect(wb.tabs[0]!.name).toBe('Models');
|
|
81
|
+
expect(wb.tabs[1]!.name).toBe('Sheet2');
|
|
82
|
+
expect(wb.tabs[1]!.columns[0]!.id).toBeTruthy(); // ids assigned
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('never returns zero tabs', () => {
|
|
86
|
+
const wb = ensureWorkbookDoc({ tabs: [] });
|
|
87
|
+
expect(wb.tabs).toHaveLength(1);
|
|
88
|
+
expect(wb.tabs[0]!.name).toBe('Sheet1');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('preserves explicit tab ids', () => {
|
|
92
|
+
const wb = ensureWorkbookDoc({ tabs: [{ ...grid(), id: 'models', name: 'Models' }] });
|
|
93
|
+
expect(wb.tabs[0]!.id).toBe('models');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('coerceCell', () => {
|
|
98
|
+
it('parses numbers, stripping thousands separators', () => {
|
|
99
|
+
expect(coerceCell('1,234.5', 'number')).toBe(1234.5);
|
|
100
|
+
expect(coerceCell('', 'number')).toBeNull();
|
|
101
|
+
expect(coerceCell('nope', 'number')).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
it('coerces checkboxes from truthy strings', () => {
|
|
104
|
+
expect(coerceCell('yes', 'checkbox')).toBe(true);
|
|
105
|
+
expect(coerceCell('0', 'checkbox')).toBe(false);
|
|
106
|
+
expect(coerceCell(true, 'checkbox')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
it('splits multiselect strings', () => {
|
|
109
|
+
expect(coerceCell('a, b ,c', 'multiselect')).toEqual(['a', 'b', 'c']);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('row ops', () => {
|
|
114
|
+
it('adds a row, coercing cells to column types', () => {
|
|
115
|
+
const { doc, row } = addRow(grid(), { c_qty: '5', c_item: 'Bolt' });
|
|
116
|
+
expect(doc.rows).toHaveLength(3);
|
|
117
|
+
expect(row.cells.c_qty).toBe(5);
|
|
118
|
+
expect(row.cells.c_item).toBe('Bolt');
|
|
119
|
+
});
|
|
120
|
+
it('inserts after a given row', () => {
|
|
121
|
+
const { doc, row } = addRow(grid(), { c_item: 'Mid' }, 'r1');
|
|
122
|
+
expect(doc.rows[1]!.id).toBe(row.id);
|
|
123
|
+
});
|
|
124
|
+
it('updates a row by merge and clears emptied cells', () => {
|
|
125
|
+
const doc = updateRow(grid(), 'r1', { c_qty: 10, c_item: '' });
|
|
126
|
+
const r1 = doc.rows.find((r) => r.id === 'r1')!;
|
|
127
|
+
expect(r1.cells.c_qty).toBe(10);
|
|
128
|
+
expect('c_item' in r1.cells).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
it('setCell is a single-cell update', () => {
|
|
131
|
+
const doc = setCell(grid(), 'r2', 'c_price', 7);
|
|
132
|
+
expect(doc.rows.find((r) => r.id === 'r2')!.cells.c_price).toBe(7);
|
|
133
|
+
});
|
|
134
|
+
it('deletes a row', () => {
|
|
135
|
+
expect(deleteRow(grid(), 'r1').rows.map((r) => r.id)).toEqual(['r2']);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('column ops', () => {
|
|
140
|
+
it('re-coerces cells when a column type changes', () => {
|
|
141
|
+
const doc = updateColumn(grid(), 'c_qty', { type: 'text' });
|
|
142
|
+
expect(doc.rows[0]!.cells.c_qty).toBe('2');
|
|
143
|
+
});
|
|
144
|
+
it('deleteColumn prunes cells and aggregates', () => {
|
|
145
|
+
let doc = setAggregate(grid(), 'c_qty', 'sum');
|
|
146
|
+
doc = deleteColumn(doc, 'c_qty');
|
|
147
|
+
expect(doc.columns.find((c) => c.id === 'c_qty')).toBeUndefined();
|
|
148
|
+
expect(doc.rows[0]!.cells.c_qty).toBeUndefined();
|
|
149
|
+
expect(doc.aggregates?.c_qty).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
it('addColumn appends with an id', () => {
|
|
152
|
+
const { doc, column } = addColumn(grid(), { name: 'Tag', type: 'text' });
|
|
153
|
+
expect(column.id).toBeTruthy();
|
|
154
|
+
expect(doc.columns.at(-1)!.name).toBe('Tag');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('addSelectOption', () => {
|
|
159
|
+
function selDoc() {
|
|
160
|
+
return {
|
|
161
|
+
columns: [
|
|
162
|
+
{
|
|
163
|
+
id: 'c_s',
|
|
164
|
+
name: 'Status',
|
|
165
|
+
type: 'select' as const,
|
|
166
|
+
options: [{ id: 'open', label: 'Open' }],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
rows: [{ id: 'r1', cells: {} }],
|
|
170
|
+
aggregates: {},
|
|
171
|
+
views: [],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
it('appends a new option with a slug id', () => {
|
|
175
|
+
const doc = addSelectOption(selDoc(), 'c_s', 'In Progress');
|
|
176
|
+
expect(doc.columns[0]!.options).toEqual([
|
|
177
|
+
{ id: 'open', label: 'Open' },
|
|
178
|
+
{ id: 'in_progress', label: 'In Progress' },
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
it('is a case-insensitive no-op when the label already exists', () => {
|
|
182
|
+
const base = selDoc();
|
|
183
|
+
expect(addSelectOption(base, 'c_s', 'open')).toBe(base);
|
|
184
|
+
});
|
|
185
|
+
it('ignores blanks and unknown columns', () => {
|
|
186
|
+
const base = selDoc();
|
|
187
|
+
expect(addSelectOption(base, 'c_s', ' ')).toBe(base);
|
|
188
|
+
expect(addSelectOption(base, 'nope', 'X')).toBe(base);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('aggregates', () => {
|
|
193
|
+
it('sums and averages numeric columns', () => {
|
|
194
|
+
const doc = grid();
|
|
195
|
+
expect(computeAggregate(doc, 'c_qty', 'sum')).toBe(5);
|
|
196
|
+
expect(computeAggregate(doc, 'c_price', 'sum')).toBe(13.5);
|
|
197
|
+
expect(computeAggregate(doc, 'c_qty', 'avg')).toBe(2.5);
|
|
198
|
+
expect(computeAggregate(doc, 'c_qty', 'max')).toBe(3);
|
|
199
|
+
});
|
|
200
|
+
it('count counts rows; filled/empty count cells', () => {
|
|
201
|
+
const doc = updateRow(grid(), 'r2', { c_item: '' });
|
|
202
|
+
expect(computeAggregate(doc, 'c_item', 'count')).toBe(2);
|
|
203
|
+
expect(computeAggregate(doc, 'c_item', 'filled')).toBe(1);
|
|
204
|
+
expect(computeAggregate(doc, 'c_item', 'empty')).toBe(1);
|
|
205
|
+
});
|
|
206
|
+
it('returns null for a numeric aggregate on an all-text column', () => {
|
|
207
|
+
expect(computeAggregate(grid(), 'c_item', 'sum')).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('formula columns via resolveCell', () => {
|
|
212
|
+
it('computes a same-row expression', () => {
|
|
213
|
+
let doc = grid();
|
|
214
|
+
doc = addColumn(doc, { name: 'Total', type: 'formula', formula: '{Qty} * {Price}' }).doc;
|
|
215
|
+
const col = findColumnByName(doc, 'Total')!;
|
|
216
|
+
expect(resolveCell(doc, doc.rows[0]!, col)).toBe(19);
|
|
217
|
+
expect(resolveCell(doc, doc.rows[1]!, col)).toBe(12);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('aggregates resolve formula columns (sum/avg over computed cells)', () => {
|
|
221
|
+
let doc = grid();
|
|
222
|
+
doc = addColumn(doc, { name: 'Total', type: 'formula', formula: '{Qty} * {Price}' }).doc;
|
|
223
|
+
const colId = findColumnByName(doc, 'Total')!.id;
|
|
224
|
+
expect(computeAggregate(doc, colId, 'sum')).toBe(31); // 19 + 12
|
|
225
|
+
expect(computeAggregate(doc, colId, 'avg')).toBe(15.5);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('views (filter + sort)', () => {
|
|
230
|
+
it('filters then sorts', () => {
|
|
231
|
+
let doc = grid();
|
|
232
|
+
doc = addRow(doc, { c_item: 'Anvil', c_qty: 1, c_price: 50 }).doc;
|
|
233
|
+
doc = setView(doc, {
|
|
234
|
+
id: 'v1',
|
|
235
|
+
name: 'cheap-desc',
|
|
236
|
+
filters: [{ colId: 'c_price', op: 'lt', value: 40 }],
|
|
237
|
+
sort: [{ colId: 'c_qty', dir: 'desc' }],
|
|
238
|
+
});
|
|
239
|
+
const rows = applyView(doc, 'v1');
|
|
240
|
+
expect(rows.map((r) => r.cells.c_item)).toEqual(['Gadget', 'Widget']);
|
|
241
|
+
});
|
|
242
|
+
it('unknown view id returns all rows in document order', () => {
|
|
243
|
+
const doc = grid();
|
|
244
|
+
expect(applyView(doc, 'nope').map((r) => r.id)).toEqual(['r1', 'r2']);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('queryRows (ad-hoc filter + sort)', () => {
|
|
249
|
+
function bigGrid(): TableDoc {
|
|
250
|
+
let doc = grid();
|
|
251
|
+
doc = addRow(doc, { c_item: 'Anvil', c_qty: 1, c_price: 50 }).doc;
|
|
252
|
+
return doc;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
it('ANDs filters by default', () => {
|
|
256
|
+
const doc = bigGrid();
|
|
257
|
+
const rows = queryRows(doc, {
|
|
258
|
+
filters: [
|
|
259
|
+
{ colId: 'c_price', op: 'lt', value: 40 },
|
|
260
|
+
{ colId: 'c_qty', op: 'gte', value: 3 },
|
|
261
|
+
],
|
|
262
|
+
});
|
|
263
|
+
expect(rows.map((r) => r.cells.c_item)).toEqual(['Gadget']);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('ORs filters when match=any', () => {
|
|
267
|
+
const doc = bigGrid();
|
|
268
|
+
const rows = queryRows(doc, {
|
|
269
|
+
match: 'any',
|
|
270
|
+
filters: [
|
|
271
|
+
{ colId: 'c_item', op: 'eq', value: 'Widget' },
|
|
272
|
+
{ colId: 'c_price', op: 'gte', value: 50 },
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
expect(rows.map((r) => r.cells.c_item).sort()).toEqual(['Anvil', 'Widget']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('applies sort and leaves the doc unchanged', () => {
|
|
279
|
+
const doc = bigGrid();
|
|
280
|
+
const before = doc.rows.map((r) => r.id);
|
|
281
|
+
const rows = queryRows(doc, { sort: [{ colId: 'c_price', dir: 'desc' }] });
|
|
282
|
+
expect(rows.map((r) => r.cells.c_item)).toEqual(['Anvil', 'Widget', 'Gadget']);
|
|
283
|
+
expect(doc.rows.map((r) => r.id)).toEqual(before); // pure: no mutation
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('no filters returns every row in document order', () => {
|
|
287
|
+
const doc = bigGrid();
|
|
288
|
+
expect(queryRows(doc, {}).map((r) => r.id)).toEqual(['r1', 'r2', doc.rows[2]!.id]);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('excludes empty cells from ordered comparisons (no magnitude)', () => {
|
|
292
|
+
let doc = grid();
|
|
293
|
+
doc = addRow(doc, { c_item: 'Blank', c_qty: 5 }).doc; // no c_price → empty
|
|
294
|
+
// lt/gt/gte/lte must NOT match the blank-price row (would have via '' < '100')
|
|
295
|
+
for (const op of ['lt', 'gte', 'gt', 'lte'] as const) {
|
|
296
|
+
const rows = queryRows(doc, { filters: [{ colId: 'c_price', op, value: 100 }] });
|
|
297
|
+
expect(rows.some((r) => r.cells.c_item === 'Blank')).toBe(false);
|
|
298
|
+
}
|
|
299
|
+
expect(
|
|
300
|
+
queryRows(doc, { filters: [{ colId: 'c_price', op: 'lt', value: 100 }] })
|
|
301
|
+
.map((r) => r.cells.c_item)
|
|
302
|
+
.sort(),
|
|
303
|
+
).toEqual(['Gadget', 'Widget']);
|
|
304
|
+
// `empty` still finds it; string ordered-compare on a populated text column is unaffected
|
|
305
|
+
expect(
|
|
306
|
+
queryRows(doc, { filters: [{ colId: 'c_price', op: 'empty' }] }).map((r) => r.cells.c_item),
|
|
307
|
+
).toEqual(['Blank']);
|
|
308
|
+
expect(
|
|
309
|
+
queryRows(doc, { filters: [{ colId: 'c_item', op: 'gt', value: 'F' }] })
|
|
310
|
+
.map((r) => r.cells.c_item)
|
|
311
|
+
.sort(),
|
|
312
|
+
).toEqual(['Gadget', 'Widget']);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('groupRows (group by)', () => {
|
|
317
|
+
function catGrid(): TableDoc {
|
|
318
|
+
return {
|
|
319
|
+
columns: [
|
|
320
|
+
{ id: 'c_svc', name: 'Service', type: 'text' },
|
|
321
|
+
{ id: 'c_metal', name: 'Metallurgy', type: 'text' },
|
|
322
|
+
{ id: 'c_press', name: 'Pressure', type: 'number' },
|
|
323
|
+
],
|
|
324
|
+
rows: [
|
|
325
|
+
{ id: 'r1', cells: { c_svc: 'Steam', c_metal: 'CS', c_press: 1000 } },
|
|
326
|
+
{ id: 'r2', cells: { c_svc: 'Steam', c_metal: 'CS', c_press: 2000 } },
|
|
327
|
+
{ id: 'r3', cells: { c_svc: 'Amine', c_metal: 'SS', c_press: 500 } },
|
|
328
|
+
{ id: 'r4', cells: { c_svc: 'Amine', c_metal: 'CS', c_press: 3000 } },
|
|
329
|
+
],
|
|
330
|
+
aggregates: {},
|
|
331
|
+
views: [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
it('buckets by a column in first-seen order with correct counts', () => {
|
|
336
|
+
const buckets = groupRows(catGrid(), { groupColIds: ['c_svc'] });
|
|
337
|
+
expect(buckets.map((b) => b.key)).toEqual([['Steam'], ['Amine']]);
|
|
338
|
+
expect(buckets.map((b) => b.rows.length)).toEqual([2, 2]);
|
|
339
|
+
expect(buckets[0]!.rows.map((r) => r.id)).toEqual(['r1', 'r2']);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('per-group aggregates compose with computeAggregate over bucket.rows', () => {
|
|
343
|
+
const doc = catGrid();
|
|
344
|
+
const buckets = groupRows(doc, { groupColIds: ['c_svc'] });
|
|
345
|
+
const maxByService = buckets.map((b) => [
|
|
346
|
+
b.key[0],
|
|
347
|
+
computeAggregate(doc, 'c_press', 'max', b.rows),
|
|
348
|
+
]);
|
|
349
|
+
expect(maxByService).toEqual([
|
|
350
|
+
['Steam', 2000],
|
|
351
|
+
['Amine', 3000],
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('filters rows before grouping', () => {
|
|
356
|
+
const buckets = groupRows(catGrid(), {
|
|
357
|
+
groupColIds: ['c_metal'],
|
|
358
|
+
filters: [{ colId: 'c_press', op: 'gte', value: 1000 }],
|
|
359
|
+
});
|
|
360
|
+
// r3 (SS, 500) is filtered out → only a CS bucket of r1, r2, r4
|
|
361
|
+
expect(buckets.map((b) => b.key)).toEqual([['CS']]);
|
|
362
|
+
expect(buckets[0]!.rows.map((r) => r.id)).toEqual(['r1', 'r2', 'r4']);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('supports a multi-column composite key', () => {
|
|
366
|
+
const buckets = groupRows(catGrid(), { groupColIds: ['c_svc', 'c_metal'] });
|
|
367
|
+
expect(buckets.map((b) => b.key)).toEqual([
|
|
368
|
+
['Steam', 'CS'],
|
|
369
|
+
['Amine', 'SS'],
|
|
370
|
+
['Amine', 'CS'],
|
|
371
|
+
]);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('tableDocFromGrid (import)', () => {
|
|
376
|
+
it('builds a typed doc with ids and coerced cells', () => {
|
|
377
|
+
const doc = tableDocFromGrid({
|
|
378
|
+
columns: [
|
|
379
|
+
{ name: 'Item', type: 'text' },
|
|
380
|
+
{ name: 'Qty', type: 'number' },
|
|
381
|
+
{ name: 'Bogus', type: 'wat' },
|
|
382
|
+
],
|
|
383
|
+
rows: [
|
|
384
|
+
['Widget', 2, 'x'],
|
|
385
|
+
['Gadget', '3', null],
|
|
386
|
+
],
|
|
387
|
+
});
|
|
388
|
+
expect(doc.columns.map((c) => c.type)).toEqual(['text', 'number', 'text']);
|
|
389
|
+
expect(doc.columns.every((c) => c.id)).toBe(true);
|
|
390
|
+
const [r0, r1] = doc.rows;
|
|
391
|
+
expect(r0!.cells[doc.columns[1]!.id]).toBe(2);
|
|
392
|
+
expect(r1!.cells[doc.columns[1]!.id]).toBe(3); // '3' coerced to number
|
|
393
|
+
expect(doc.rows.every((r) => r.id)).toBe(true);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('emptyTableDoc', () => {
|
|
398
|
+
it('produces a usable starter grid', () => {
|
|
399
|
+
const doc = emptyTableDoc();
|
|
400
|
+
expect(doc.columns).toHaveLength(2);
|
|
401
|
+
expect(doc.rows).toHaveLength(3);
|
|
402
|
+
expect(cellIsEmpty(doc.rows[0]!.cells.whatever ?? null)).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe('linked-column cells coerce by storage mode (v2.2)', () => {
|
|
407
|
+
// A linked-select stores as text; updateRow/addRow must coerce via
|
|
408
|
+
// storageType, not the raw 'reference' type, so the picked value round-trips
|
|
409
|
+
// through the proven select path.
|
|
410
|
+
const linkedSelect = () =>
|
|
411
|
+
ensureTableDoc({
|
|
412
|
+
columns: [
|
|
413
|
+
{ id: 'c1', name: 'K', type: 'text' },
|
|
414
|
+
{ id: 'c2', name: 'Model', type: 'reference', ref: { tabId: 't', columnId: 's' } },
|
|
415
|
+
],
|
|
416
|
+
rows: [{ id: 'r1', cells: { c1: 'a', c2: 'X' } }],
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('updateRow stores the picked value as text', () => {
|
|
420
|
+
const doc = updateRow(linkedSelect(), 'r1', { c2: 'Y' });
|
|
421
|
+
expect(doc.rows[0]!.cells.c2).toBe('Y');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('addRow coerces a linked-select cell to text', () => {
|
|
425
|
+
const doc = addRow(linkedSelect(), { c1: 'b', c2: 42 }).doc;
|
|
426
|
+
const added = doc.rows[doc.rows.length - 1]!;
|
|
427
|
+
expect(added.cells.c2).toBe('42');
|
|
428
|
+
});
|
|
429
|
+
});
|