@datarecce/ui 0.1.40 → 0.1.41

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.
Files changed (32) hide show
  1. package/dist/api.d.mts +1 -1
  2. package/dist/{components-DTLQ2djq.js → components-DfXnN1Hx.js} +592 -13
  3. package/dist/components-DfXnN1Hx.js.map +1 -0
  4. package/dist/{components-B6oaPB5f.mjs → components-jh6r4tQn.mjs} +593 -14
  5. package/dist/components-jh6r4tQn.mjs.map +1 -0
  6. package/dist/components.d.mts +1 -1
  7. package/dist/components.js +1 -1
  8. package/dist/components.mjs +1 -1
  9. package/dist/hooks.d.mts +1 -1
  10. package/dist/{index-CbF0x3kW.d.mts → index-B5bpmv0i.d.mts} +70 -70
  11. package/dist/{index-CbF0x3kW.d.mts.map → index-B5bpmv0i.d.mts.map} +1 -1
  12. package/dist/index-B9lSPJTi.d.ts.map +1 -1
  13. package/dist/index.d.mts +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.mjs +1 -1
  16. package/dist/theme.d.mts +1 -1
  17. package/dist/types.d.mts +1 -1
  18. package/package.json +1 -1
  19. package/recce-source/docs/plans/2024-12-31-csv-download-design.md +121 -0
  20. package/recce-source/docs/plans/2024-12-31-csv-download-implementation.md +930 -0
  21. package/recce-source/js/src/components/run/RunResultPane.tsx +138 -14
  22. package/recce-source/js/src/lib/csv/extractors.test.ts +456 -0
  23. package/recce-source/js/src/lib/csv/extractors.ts +468 -0
  24. package/recce-source/js/src/lib/csv/format.test.ts +211 -0
  25. package/recce-source/js/src/lib/csv/format.ts +44 -0
  26. package/recce-source/js/src/lib/csv/index.test.ts +155 -0
  27. package/recce-source/js/src/lib/csv/index.ts +109 -0
  28. package/recce-source/js/src/lib/hooks/useCSVExport.ts +136 -0
  29. package/recce-source/recce/mcp_server.py +54 -30
  30. package/recce-source/recce/models/check.py +10 -2
  31. package/dist/components-B6oaPB5f.mjs.map +0 -1
  32. package/dist/components-DTLQ2djq.js.map +0 -1
@@ -0,0 +1,468 @@
1
+ /**
2
+ * CSV data extractors for each run type
3
+ */
4
+
5
+ import type { QueryDiffResult } from "@/lib/api/adhocQuery";
6
+ import type { ProfileDiffResult, TopKDiffResult } from "@/lib/api/profile";
7
+ import type { RowCountDiffResult } from "@/lib/api/rowcount";
8
+ import type { DataFrame } from "@/lib/api/types";
9
+ import type { ValueDiffResult } from "@/lib/api/valuediff";
10
+
11
+ export interface CSVData {
12
+ columns: string[];
13
+ rows: unknown[][];
14
+ }
15
+
16
+ export interface CSVExportOptions {
17
+ displayMode?: "inline" | "side_by_side";
18
+ primaryKeys?: string[];
19
+ }
20
+
21
+ /**
22
+ * Format a cell value for inline diff mode
23
+ * If base and current are the same, return the value
24
+ * If different, return "(base_value) (current_value)"
25
+ */
26
+ function formatInlineDiffCell(
27
+ baseValue: unknown,
28
+ currentValue: unknown,
29
+ ): unknown {
30
+ // Convert to string for comparison
31
+ const baseStr = baseValue == null ? "" : String(baseValue);
32
+ const currentStr = currentValue == null ? "" : String(currentValue);
33
+
34
+ if (baseStr === currentStr) {
35
+ return baseValue;
36
+ }
37
+
38
+ // Format as "(base) (current)" when different
39
+ const baseDisplay = baseValue == null ? "" : `(${baseValue})`;
40
+ const currentDisplay = currentValue == null ? "" : `(${currentValue})`;
41
+ return `${baseDisplay} ${currentDisplay}`.trim();
42
+ }
43
+
44
+ /**
45
+ * Extract columns and rows from a DataFrame
46
+ */
47
+ function extractDataFrame(df: DataFrame | undefined): CSVData | null {
48
+ if (!df || !df.columns || !df.data) {
49
+ return null;
50
+ }
51
+ return {
52
+ columns: df.columns.map((col) => col.name),
53
+ rows: df.data.map((row) => [...row]),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Extract CSV data from query result (single environment)
59
+ */
60
+ function extractQuery(result: unknown): CSVData | null {
61
+ return extractDataFrame(result as DataFrame);
62
+ }
63
+
64
+ /**
65
+ * Extract CSV data from query_base result
66
+ */
67
+ function extractQueryBase(result: unknown): CSVData | null {
68
+ // query_base returns a DataFrame directly (QueryResult = DataFrame)
69
+ return extractDataFrame(result as DataFrame);
70
+ }
71
+
72
+ /**
73
+ * Extract CSV data from query_diff result
74
+ * Supports two result shapes:
75
+ * 1. { diff: DataFrame } - joined diff result (QueryDiffJoinResultView)
76
+ * 2. { base: DataFrame, current: DataFrame } - separate base/current (QueryDiffResultView)
77
+ *
78
+ * Display modes:
79
+ * - "inline": Merged rows where same values shown as-is, differing values shown as "(base) (current)"
80
+ * - "side_by_side": Single row per record with base__col, current__col columns
81
+ *
82
+ * Note: When base and current have different row counts (e.g., added/removed rows),
83
+ * the merge is done positionally. Extra rows will show null for the missing environment.
84
+ */
85
+ function extractQueryDiff(
86
+ result: unknown,
87
+ options?: CSVExportOptions,
88
+ ): CSVData | null {
89
+ const typed = result as QueryDiffResult;
90
+ const displayMode = options?.displayMode ?? "inline";
91
+ const primaryKeys = options?.primaryKeys ?? [];
92
+
93
+ // First, check if diff DataFrame exists (joined result)
94
+ if (typed?.diff) {
95
+ return extractQueryDiffJoined(typed.diff, displayMode, primaryKeys);
96
+ }
97
+
98
+ // Fall back to base/current DataFrames
99
+ return extractQueryDiffSeparate(typed, displayMode);
100
+ }
101
+
102
+ /**
103
+ * Extract CSV from joined diff DataFrame (QueryDiffJoinResultView)
104
+ * The diff DataFrame has columns like: pk, col1, col2, in_a, in_b
105
+ * where in_a/in_b indicate presence in base/current
106
+ *
107
+ * The DataFrame may have separate rows for base (in_a=true) and current (in_b=true)
108
+ * records. This function groups them by primary key and merges into single output rows.
109
+ *
110
+ * Produces same layout as extractQueryDiffSeparate for consistency.
111
+ */
112
+ function extractQueryDiffJoined(
113
+ diff: DataFrame,
114
+ displayMode: "inline" | "side_by_side",
115
+ primaryKeys: string[],
116
+ ): CSVData | null {
117
+ if (!diff?.columns || !diff?.data) return null;
118
+
119
+ // Find in_a and in_b column indices
120
+ const inAIndex = diff.columns.findIndex(
121
+ (col) => col.key.toLowerCase() === "in_a",
122
+ );
123
+ const inBIndex = diff.columns.findIndex(
124
+ (col) => col.key.toLowerCase() === "in_b",
125
+ );
126
+
127
+ // Get data columns (exclude in_a and in_b)
128
+ const dataColumns = diff.columns.filter(
129
+ (col) =>
130
+ col.key.toLowerCase() !== "in_a" && col.key.toLowerCase() !== "in_b",
131
+ );
132
+ const dataColumnNames = dataColumns.map((col) => col.name);
133
+ const dataColumnIndices = dataColumns.map((col) =>
134
+ diff.columns.findIndex((c) => c.key === col.key),
135
+ );
136
+
137
+ // Find primary key column indices
138
+ const pkIndices = primaryKeys
139
+ .map((pk) => diff.columns.findIndex((col) => col.key === pk))
140
+ .filter((idx) => idx >= 0);
141
+
142
+ // Extract row values for data columns only
143
+ const extractRowValues = (rowData: unknown[]): unknown[] => {
144
+ return dataColumnIndices.map((colIndex) => rowData[colIndex]);
145
+ };
146
+
147
+ // Generate primary key string for grouping
148
+ const getPrimaryKeyValue = (rowData: unknown[]): string => {
149
+ if (pkIndices.length === 0) {
150
+ // No primary keys - use row index (will be set later)
151
+ return "";
152
+ }
153
+ return pkIndices.map((idx) => String(rowData[idx] ?? "")).join("|||");
154
+ };
155
+
156
+ // Group rows by primary key, separating base and current
157
+ const groupedRows: Map<
158
+ string,
159
+ { base: unknown[] | null; current: unknown[] | null }
160
+ > = new Map();
161
+ const rowOrder: string[] = []; // Track insertion order
162
+
163
+ diff.data.forEach((rowData, index) => {
164
+ const inA = inAIndex >= 0 ? rowData[inAIndex] : true;
165
+ const inB = inBIndex >= 0 ? rowData[inBIndex] : true;
166
+
167
+ // Use primary key or index for grouping
168
+ let pkValue = getPrimaryKeyValue(rowData);
169
+ if (pkValue === "") {
170
+ pkValue = String(index);
171
+ }
172
+
173
+ if (!groupedRows.has(pkValue)) {
174
+ groupedRows.set(pkValue, { base: null, current: null });
175
+ rowOrder.push(pkValue);
176
+ }
177
+
178
+ const group = groupedRows.get(pkValue);
179
+ if (!group) return;
180
+
181
+ const values = extractRowValues(rowData);
182
+
183
+ if (inA) {
184
+ group.base = values;
185
+ }
186
+ if (inB) {
187
+ group.current = values;
188
+ }
189
+ });
190
+
191
+ if (displayMode === "side_by_side") {
192
+ // Side-by-side: columns like base__col1, current__col1, base__col2, current__col2
193
+ const columns: string[] = [];
194
+ dataColumnNames.forEach((name) => {
195
+ columns.push(`base__${name}`, `current__${name}`);
196
+ });
197
+
198
+ const rows: unknown[][] = [];
199
+
200
+ for (const pkValue of rowOrder) {
201
+ const group = groupedRows.get(pkValue);
202
+ if (!group) continue;
203
+
204
+ const baseValues = group.base;
205
+ const currentValues = group.current;
206
+
207
+ const row: unknown[] = [];
208
+ dataColumnNames.forEach((_, colIndex) => {
209
+ row.push(baseValues ? baseValues[colIndex] : null);
210
+ row.push(currentValues ? currentValues[colIndex] : null);
211
+ });
212
+
213
+ rows.push(row);
214
+ }
215
+
216
+ return { columns, rows };
217
+ }
218
+
219
+ // Inline mode: merged rows with diff shown in parentheses
220
+ // Format: value if same, "(base_value) (current_value)" if different
221
+ const columns = [...dataColumnNames];
222
+ const rows: unknown[][] = [];
223
+
224
+ for (const pkValue of rowOrder) {
225
+ const group = groupedRows.get(pkValue);
226
+ if (!group) continue;
227
+
228
+ const baseValues = group.base;
229
+ const currentValues = group.current;
230
+
231
+ // Merge base and current into single row
232
+ const row: unknown[] = [];
233
+ dataColumnNames.forEach((_, colIndex) => {
234
+ const baseVal = baseValues ? baseValues[colIndex] : null;
235
+ const currentVal = currentValues ? currentValues[colIndex] : null;
236
+ row.push(formatInlineDiffCell(baseVal, currentVal));
237
+ });
238
+
239
+ rows.push(row);
240
+ }
241
+
242
+ return { columns, rows };
243
+ }
244
+
245
+ /**
246
+ * Extract CSV from separate base/current DataFrames (QueryDiffResultView)
247
+ */
248
+ function extractQueryDiffSeparate(
249
+ typed: QueryDiffResult,
250
+ displayMode: "inline" | "side_by_side",
251
+ ): CSVData | null {
252
+ const df = typed?.current || typed?.base;
253
+ if (!df) return null;
254
+
255
+ // If only one exists, just return it
256
+ if (!typed?.base || !typed?.current) {
257
+ return extractDataFrame(df);
258
+ }
259
+
260
+ const columnNames = typed.current.columns.map((c) => c.name);
261
+
262
+ if (displayMode === "side_by_side") {
263
+ // Side-by-side: columns like base__col1, current__col1, base__col2, current__col2
264
+ const columns: string[] = [];
265
+ columnNames.forEach((name) => {
266
+ columns.push(`base__${name}`, `current__${name}`);
267
+ });
268
+
269
+ const rows: unknown[][] = [];
270
+ const maxRows = Math.max(typed.base.data.length, typed.current.data.length);
271
+
272
+ for (let i = 0; i < maxRows; i++) {
273
+ const row: unknown[] = [];
274
+ const baseRow = i < typed.base.data.length ? typed.base.data[i] : null;
275
+ const currentRow =
276
+ i < typed.current.data.length ? typed.current.data[i] : null;
277
+
278
+ columnNames.forEach((_, colIndex) => {
279
+ row.push(baseRow ? baseRow[colIndex] : null);
280
+ row.push(currentRow ? currentRow[colIndex] : null);
281
+ });
282
+
283
+ rows.push(row);
284
+ }
285
+
286
+ return { columns, rows };
287
+ }
288
+
289
+ // Inline mode: merged rows with diff shown in parentheses
290
+ // Format: value if same, "(base_value) (current_value)" if different
291
+ const columns = [...columnNames];
292
+ const rows: unknown[][] = [];
293
+
294
+ const maxRows = Math.max(typed.base.data.length, typed.current.data.length);
295
+ for (let i = 0; i < maxRows; i++) {
296
+ const baseRow = i < typed.base.data.length ? typed.base.data[i] : null;
297
+ const currentRow =
298
+ i < typed.current.data.length ? typed.current.data[i] : null;
299
+
300
+ // Merge base and current into single row
301
+ const row: unknown[] = [];
302
+ columnNames.forEach((_, colIndex) => {
303
+ const baseVal = baseRow ? baseRow[colIndex] : null;
304
+ const currentVal = currentRow ? currentRow[colIndex] : null;
305
+ row.push(formatInlineDiffCell(baseVal, currentVal));
306
+ });
307
+
308
+ rows.push(row);
309
+ }
310
+
311
+ return { columns, rows };
312
+ }
313
+
314
+ /**
315
+ * Extract CSV data from profile_diff result
316
+ */
317
+ function extractProfileDiff(result: unknown): CSVData | null {
318
+ const typed = result as ProfileDiffResult;
319
+
320
+ // Profile data has metrics as columns, one row per profiled column
321
+ const df = typed?.current || typed?.base;
322
+ if (!df) return null;
323
+
324
+ // If both exist, combine with source column
325
+ if (typed?.base && typed?.current) {
326
+ const columns = ["_source", ...typed.current.columns.map((c) => c.name)];
327
+ const rows: unknown[][] = [];
328
+
329
+ typed.base.data.forEach((row) => {
330
+ rows.push(["base", ...row]);
331
+ });
332
+ typed.current.data.forEach((row) => {
333
+ rows.push(["current", ...row]);
334
+ });
335
+
336
+ return { columns, rows };
337
+ }
338
+
339
+ return extractDataFrame(df);
340
+ }
341
+
342
+ /**
343
+ * Extract CSV data from row_count_diff result
344
+ */
345
+ function extractRowCountDiff(result: unknown): CSVData | null {
346
+ const typed = result as RowCountDiffResult;
347
+ if (!typed || typeof typed !== "object") return null;
348
+
349
+ const columns = [
350
+ "node",
351
+ "base_count",
352
+ "current_count",
353
+ "diff",
354
+ "diff_percent",
355
+ ];
356
+ const rows: unknown[][] = [];
357
+
358
+ for (const [nodeName, counts] of Object.entries(typed)) {
359
+ if (counts && typeof counts === "object") {
360
+ const base = (counts as { base?: number | null }).base;
361
+ const current = (counts as { curr?: number | null }).curr;
362
+ const diff = base != null && current != null ? current - base : null;
363
+ const diffPercent =
364
+ base && diff !== null ? ((diff / base) * 100).toFixed(2) + "%" : null;
365
+ rows.push([nodeName, base, current, diff, diffPercent]);
366
+ }
367
+ }
368
+
369
+ return { columns, rows };
370
+ }
371
+
372
+ /**
373
+ * Extract CSV data from value_diff result
374
+ */
375
+ function extractValueDiff(result: unknown): CSVData | null {
376
+ const typed = result as ValueDiffResult;
377
+ if (!typed?.data) return null;
378
+ return extractDataFrame(typed.data);
379
+ }
380
+
381
+ /**
382
+ * Extract CSV data from value_diff_detail result
383
+ */
384
+ function extractValueDiffDetail(result: unknown): CSVData | null {
385
+ return extractDataFrame(result as DataFrame);
386
+ }
387
+
388
+ /**
389
+ * Extract CSV data from top_k_diff result
390
+ */
391
+ function extractTopKDiff(result: unknown): CSVData | null {
392
+ const typed = result as TopKDiffResult;
393
+
394
+ // Check if either base or current has values
395
+ const hasBaseValues = !!typed?.base?.values;
396
+ const hasCurrentValues = !!typed?.current?.values;
397
+ if (!hasBaseValues && !hasCurrentValues) return null;
398
+
399
+ // TopK has { values: [...], counts: [...], valids: number }
400
+ const columns = ["_source", "value", "count"];
401
+ const rows: unknown[][] = [];
402
+
403
+ if (typed?.base?.values) {
404
+ typed.base.values.forEach((value, index) => {
405
+ rows.push(["base", value, typed.base.counts[index]]);
406
+ });
407
+ }
408
+ if (typed?.current?.values) {
409
+ typed.current.values.forEach((value, index) => {
410
+ rows.push(["current", value, typed.current.counts[index]]);
411
+ });
412
+ }
413
+
414
+ return { columns, rows };
415
+ }
416
+
417
+ /**
418
+ * Map of run types to their extractor functions
419
+ * Some extractors accept options (like query_diff for displayMode)
420
+ */
421
+ const extractors: Record<
422
+ string,
423
+ (result: unknown, options?: CSVExportOptions) => CSVData | null
424
+ > = {
425
+ query: extractQuery,
426
+ query_base: extractQueryBase,
427
+ query_diff: extractQueryDiff,
428
+ profile: extractProfileDiff,
429
+ profile_diff: extractProfileDiff,
430
+ row_count: extractRowCountDiff,
431
+ row_count_diff: extractRowCountDiff,
432
+ value_diff: extractValueDiff,
433
+ value_diff_detail: extractValueDiffDetail,
434
+ top_k_diff: extractTopKDiff,
435
+ };
436
+
437
+ /**
438
+ * Extract CSV data from a run result
439
+ * @param runType - The type of run (query, query_diff, etc.)
440
+ * @param result - The run result data
441
+ * @param options - Optional export options (e.g., displayMode for query_diff)
442
+ * @returns CSVData or null if the run type doesn't support CSV export
443
+ */
444
+ export function extractCSVData(
445
+ runType: string,
446
+ result: unknown,
447
+ options?: CSVExportOptions,
448
+ ): CSVData | null {
449
+ const extractor = extractors[runType];
450
+ if (!extractor) return null;
451
+
452
+ try {
453
+ return extractor(result, options);
454
+ } catch (error) {
455
+ console.error(
456
+ `Failed to extract CSV data for run type "${runType}":`,
457
+ error,
458
+ );
459
+ return null;
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Check if a run type supports CSV export
465
+ */
466
+ export function supportsCSVExport(runType: string): boolean {
467
+ return runType in extractors;
468
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Tests for CSV formatting utilities
3
+ */
4
+ import { toCSV } from "./format";
5
+
6
+ describe("toCSV", () => {
7
+ describe("basic formatting", () => {
8
+ test("should format simple data with headers", () => {
9
+ const columns = ["name", "age"];
10
+ const rows = [
11
+ ["Alice", 30],
12
+ ["Bob", 25],
13
+ ];
14
+
15
+ const result = toCSV(columns, rows);
16
+
17
+ // Should have BOM prefix
18
+ expect(result.startsWith("\uFEFF")).toBe(true);
19
+ // Should have correct content
20
+ expect(result).toContain("name,age");
21
+ expect(result).toContain("Alice,30");
22
+ expect(result).toContain("Bob,25");
23
+ });
24
+
25
+ test("should use CRLF line endings", () => {
26
+ const columns = ["col1"];
27
+ const rows = [["a"], ["b"]];
28
+
29
+ const result = toCSV(columns, rows);
30
+
31
+ // Remove BOM for easier checking
32
+ const content = result.slice(1);
33
+ expect(content).toBe("col1\r\na\r\nb");
34
+ });
35
+
36
+ test("should handle empty rows", () => {
37
+ const columns = ["col1", "col2"];
38
+ const rows: unknown[][] = [];
39
+
40
+ const result = toCSV(columns, rows);
41
+
42
+ expect(result).toBe("\uFEFFcol1,col2");
43
+ });
44
+
45
+ test("should handle single column", () => {
46
+ const columns = ["value"];
47
+ const rows = [[1], [2], [3]];
48
+
49
+ const result = toCSV(columns, rows);
50
+ const content = result.slice(1);
51
+
52
+ expect(content).toBe("value\r\n1\r\n2\r\n3");
53
+ });
54
+ });
55
+
56
+ describe("special character escaping", () => {
57
+ test("should escape values containing commas", () => {
58
+ const columns = ["name"];
59
+ const rows = [["Smith, John"]];
60
+
61
+ const result = toCSV(columns, rows);
62
+
63
+ expect(result).toContain('"Smith, John"');
64
+ });
65
+
66
+ test("should escape values containing double quotes", () => {
67
+ const columns = ["quote"];
68
+ const rows = [['He said "hello"']];
69
+
70
+ const result = toCSV(columns, rows);
71
+
72
+ // Quotes should be doubled and wrapped
73
+ expect(result).toContain('"He said ""hello"""');
74
+ });
75
+
76
+ test("should escape values containing newlines", () => {
77
+ const columns = ["text"];
78
+ const rows = [["line1\nline2"]];
79
+
80
+ const result = toCSV(columns, rows);
81
+
82
+ expect(result).toContain('"line1\nline2"');
83
+ });
84
+
85
+ test("should escape values containing carriage returns", () => {
86
+ const columns = ["text"];
87
+ const rows = [["line1\rline2"]];
88
+
89
+ const result = toCSV(columns, rows);
90
+
91
+ expect(result).toContain('"line1\rline2"');
92
+ });
93
+
94
+ test("should escape values with multiple special characters", () => {
95
+ const columns = ["complex"];
96
+ const rows = [['value with "quotes", commas, and\nnewlines']];
97
+
98
+ const result = toCSV(columns, rows);
99
+
100
+ expect(result).toContain(
101
+ '"value with ""quotes"", commas, and\nnewlines"',
102
+ );
103
+ });
104
+
105
+ test("should not escape values without special characters", () => {
106
+ const columns = ["simple"];
107
+ const rows = [["plain text"]];
108
+
109
+ const result = toCSV(columns, rows);
110
+ const content = result.slice(1);
111
+
112
+ expect(content).toBe("simple\r\nplain text");
113
+ });
114
+ });
115
+
116
+ describe("null and undefined handling", () => {
117
+ test("should convert null to empty string", () => {
118
+ const columns = ["value"];
119
+ const rows = [[null]];
120
+
121
+ const result = toCSV(columns, rows);
122
+ const content = result.slice(1);
123
+
124
+ expect(content).toBe("value\r\n");
125
+ });
126
+
127
+ test("should convert undefined to empty string", () => {
128
+ const columns = ["value"];
129
+ const rows = [[undefined]];
130
+
131
+ const result = toCSV(columns, rows);
132
+ const content = result.slice(1);
133
+
134
+ expect(content).toBe("value\r\n");
135
+ });
136
+
137
+ test("should handle mixed null and values", () => {
138
+ const columns = ["a", "b", "c"];
139
+ const rows = [[1, null, 3]];
140
+
141
+ const result = toCSV(columns, rows);
142
+
143
+ expect(result).toContain("1,,3");
144
+ });
145
+ });
146
+
147
+ describe("data type handling", () => {
148
+ test("should convert numbers to strings", () => {
149
+ const columns = ["num"];
150
+ const rows = [[42], [3.14], [-100]];
151
+
152
+ const result = toCSV(columns, rows);
153
+
154
+ expect(result).toContain("42");
155
+ expect(result).toContain("3.14");
156
+ expect(result).toContain("-100");
157
+ });
158
+
159
+ test("should convert booleans to strings", () => {
160
+ const columns = ["bool"];
161
+ const rows = [[true], [false]];
162
+
163
+ const result = toCSV(columns, rows);
164
+
165
+ expect(result).toContain("true");
166
+ expect(result).toContain("false");
167
+ });
168
+
169
+ test("should JSON stringify objects", () => {
170
+ const columns = ["obj"];
171
+ const rows = [[{ key: "value" }]];
172
+
173
+ const result = toCSV(columns, rows);
174
+
175
+ // Objects get JSON stringified, quotes get doubled and wrapped
176
+ // {"key":"value"} becomes "{""key"":""value""}"
177
+ expect(result).toContain('"{""key"":""value""}"');
178
+ });
179
+
180
+ test("should JSON stringify arrays", () => {
181
+ const columns = ["arr"];
182
+ const rows = [[[1, 2, 3]]];
183
+
184
+ const result = toCSV(columns, rows);
185
+
186
+ // Arrays get JSON stringified, contains commas so gets quoted
187
+ expect(result).toContain('"[1,2,3]"');
188
+ });
189
+ });
190
+
191
+ describe("UTF-8 BOM", () => {
192
+ test("should include UTF-8 BOM at start for Excel compatibility", () => {
193
+ const columns = ["col"];
194
+ const rows = [["data"]];
195
+
196
+ const result = toCSV(columns, rows);
197
+
198
+ expect(result.charCodeAt(0)).toBe(0xfeff);
199
+ });
200
+
201
+ test("should handle unicode characters", () => {
202
+ const columns = ["unicode"];
203
+ const rows = [["Hello \u4e16\u754c"], ["\u00e9\u00e0\u00fc"]];
204
+
205
+ const result = toCSV(columns, rows);
206
+
207
+ expect(result).toContain("Hello \u4e16\u754c");
208
+ expect(result).toContain("\u00e9\u00e0\u00fc");
209
+ });
210
+ });
211
+ });