inline-schema/src/csv-loader/loader.test.ts

562 lines
16 KiB
TypeScript
Raw Normal View History

2026-04-15 13:58:14 +08:00
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { parseCsv } from './loader';
import * as path from 'path';
import * as fs from 'fs';
const fixturesDir = path.join(__dirname, 'fixtures');
function readFixture(name: string): string {
return fs.readFileSync(path.join(fixturesDir, name), 'utf-8');
}
describe('parseCsv - basic parsing', () => {
it('should parse a simple CSV with primitive types', () => {
const csv = [
'name,age,active',
'string,number,boolean',
'Alice,30,true',
'Bob,25,false',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: 'Alice', age: 30, active: true });
expect(result.data[1]).toEqual({ name: 'Bob', age: 25, active: false });
});
it('should parse CSV with int and float columns', () => {
const csv = [
'id,count,price',
'int,int,float',
'1,5,9.99',
'2,3,4.50',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ id: 1, count: 5, price: 9.99 });
expect(result.data[1]).toEqual({ id: 2, count: 3, price: 4.5 });
});
it('should parse CSV with string literal columns (unquoted in CSV)', () => {
const csv = [
'name,status',
2026-04-15 14:09:52 +08:00
"string,'on' | 'off'",
2026-04-15 13:58:14 +08:00
'Alice,on',
'Bob,off',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: 'Alice', status: 'on' });
expect(result.data[1]).toEqual({ name: 'Bob', status: 'off' });
});
it('should parse CSV with array columns', () => {
const csv = [
'name,tags',
'string,string[]',
'Alice,[dev; admin]',
'Bob,[user]',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: 'Alice', tags: ['dev', 'admin'] });
expect(result.data[1]).toEqual({ name: 'Bob', tags: ['user'] });
});
it('should parse CSV with tuple columns', () => {
const csv = [
'name,coords',
'string,[number; number]',
'Alice,[1; 2]',
'Bob,[3; 4]',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({ name: 'Alice', coords: [1, 2] });
expect(result.data[1]).toEqual({ name: 'Bob', coords: [3, 4] });
});
it('should require at least 2 rows (header + schema)', () => {
const csv = 'name,age\nstring,number';
expect(() => parseCsv(csv, { emitTypes: false })).not.toThrow();
const csv1Row = 'name,age';
expect(() => parseCsv(csv1Row, { emitTypes: false })).toThrow('at least 2 rows');
});
it('should throw if header and schema count mismatch', () => {
const csv = 'name,age\nstring';
expect(() => parseCsv(csv, { emitTypes: false })).toThrow('does not match');
});
});
describe('parseCsv - reference resolution', () => {
it('should resolve single reference to another CSV table', () => {
const usersCsv = readFixture('users.csv');
const result = parseCsv(usersCsv, { emitTypes: false });
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
});
it('should resolve reference values using parseCsv with referenced tables', () => {
const ordersCsv = [
'id,customer,total',
'string,@users,number',
'1,1,100',
].join('\n');
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'orders.csv'),
});
expect(result.data).toHaveLength(1);
expect(result.data[0].customer).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});
expect(result.data[0].total).toBe(100);
});
it('should resolve array reference values', () => {
const ordersCsv = [
'id,items,total',
'string,@parts[],number',
'1,[1; 2],35.5',
].join('\n');
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'orders.csv'),
});
expect(result.data).toHaveLength(1);
const items = result.data[0].items as Record<string, unknown>[];
expect(items).toHaveLength(2);
expect(items[0]).toEqual({ id: '1', name: 'Widget', price: 10.5 });
expect(items[1]).toEqual({ id: '2', name: 'Gadget', price: 25 });
expect(result.data[0].total).toBe(35.5);
});
it('should resolve mixed single and array references', () => {
const ordersCsv = [
'id,customer,items,total',
'string,@users,@parts[],number',
'1,1,[1; 2],35.5',
].join('\n');
const result = parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'orders.csv'),
});
expect(result.data).toHaveLength(1);
expect(result.data[0].customer).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});
const items = result.data[0].items as Record<string, unknown>[];
expect(items).toHaveLength(2);
expect(items[0]).toEqual({ id: '1', name: 'Widget', price: 10.5 });
});
it('should throw error for reference to non-existent ID', () => {
const ordersCsv = [
'id,customer',
'string,@users',
'1,999',
].join('\n');
expect(() => parseCsv(ordersCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'orders.csv'),
})).toThrow(/not found/);
});
it('should throw error for reference to non-existent table', () => {
const csv = [
'id,ref',
'string,@nonexistent',
'1,someid',
].join('\n');
expect(() => parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
})).toThrow(/Failed to load referenced table/);
});
it('should collect reference table names', () => {
const csv = [
'id,customer,items',
'string,@users,@parts[]',
'1,1,[1]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.references.has('users')).toBe(true);
expect(result.references.has('parts')).toBe(true);
});
it('should use custom primary key', () => {
const nameCsv = [
'code,name',
'string,string',
'US,United States',
'UK,United Kingdom',
].join('\n');
const nameCsvPath = path.join(fixturesDir, 'countries.csv');
fs.writeFileSync(nameCsvPath, nameCsv);
try {
const refCsv = [
'id,country',
'string,@countries',
'1,US',
].join('\n');
const result = parseCsv(refCsv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'ref.csv'),
defaultPrimaryKey: 'code',
});
expect(result.data[0].country).toEqual({ code: 'US', name: 'United States' });
} finally {
fs.unlinkSync(nameCsvPath);
}
});
});
describe('parseCsv - circular reference detection', () => {
it('should detect self-referencing circular reference', () => {
const csv = [
'id,name,parent',
'string,string,@self_ref',
'1,Root,2',
].join('\n');
expect(() => parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'self_ref.csv'),
})).toThrow(/Circular reference detected/);
});
it('should detect mutual circular reference (A -> B -> A)', () => {
const csv = readFixture('circular_a.csv');
expect(() => parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'circular_a.csv'),
})).toThrow(/Circular reference detected/);
});
it('should allow same table referenced from multiple columns without circular reference', () => {
const usersCsv = readFixture('users.csv');
const csv = [
'id,creator,reviewer',
'string,@users,@users',
'1,1,2',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.data[0].creator).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
expect(result.data[0].reviewer).toEqual({ id: '2', name: 'Bob', email: 'bob@example.com' });
});
});
describe('parseCsv - references in combinatory schemas', () => {
it('should resolve reference inside a tuple', () => {
const csv = [
'id,info',
'string,[ref: @users; note: string]',
'1,[ref: 1; note: urgent]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.data).toHaveLength(1);
const info = result.data[0].info as unknown[];
expect(info).toHaveLength(2);
expect(info[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
expect(info[1]).toBe('urgent');
});
it('should resolve reference array inside a tuple', () => {
const csv = [
'id,info',
'string,[refs: @users[]; note: string]',
'1,[refs: [1; 2]; note: test]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.data).toHaveLength(1);
const info = result.data[0].info as unknown[];
expect(info).toHaveLength(2);
const refs = info[0] as Record<string, unknown>[];
expect(refs).toHaveLength(2);
expect(refs[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
expect(refs[1]).toEqual({ id: '2', name: 'Bob', email: 'bob@example.com' });
expect(info[1]).toBe('test');
});
it('should resolve array of tuples containing references', () => {
const csv = [
'id,pairs',
'string,[@users; number][]',
'1,[[1; 10]; [2; 20]]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.data).toHaveLength(1);
const pairs = result.data[0].pairs as unknown[][];
expect(pairs).toHaveLength(2);
expect(pairs[0]).toHaveLength(2);
expect(pairs[0][0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
expect(pairs[0][1]).toBe(10);
expect(pairs[1][0]).toEqual({ id: '2', name: 'Bob', email: 'bob@example.com' });
expect(pairs[1][1]).toBe(20);
});
it('should resolve reference in union (@users | string)', () => {
const csv = [
'id,value',
'string,@users | string',
'1,1',
'2,unknown',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.data).toHaveLength(2);
expect(result.data[0].value).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
expect(result.data[1].value).toBe('unknown');
});
it('should resolve reference in union (@users[] | string)', () => {
const csv = [
'id,value',
'string,@users[] | string',
'1,[1; 2]',
'2,none',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.data).toHaveLength(2);
const arr = result.data[0].value as Record<string, unknown>[];
expect(arr).toHaveLength(2);
expect(arr[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
expect(result.data[1].value).toBe('none');
});
it('should resolve array of reference unions (@users | @parts)[]', () => {
const csv = [
'id,items',
'string,(@users | @parts)[]',
'1,[1; 2]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.data).toHaveLength(1);
});
it('should resolve named tuple with reference and other fields', () => {
const csv = [
'id,details',
'string,[owner: @users; count: number]',
'1,[owner: 1; count: 5]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.data).toHaveLength(1);
const details = result.data[0].details as unknown[];
expect(details).toHaveLength(2);
expect(details[0]).toEqual({ id: '1', name: 'Alice', email: 'alice@example.com' });
expect(details[1]).toBe(5);
});
});
describe('parseCsv - type generation', () => {
it('should generate type definition with emitTypes enabled', () => {
const csv = [
'name,age',
'string,number',
'Alice,30',
].join('\n');
const result = parseCsv(csv, { emitTypes: true, resourceName: 'people' });
expect(result.typeDefinition).toBeDefined();
expect(result.typeDefinition).toContain('peopleTable');
expect(result.typeDefinition).toContain('readonly name: string');
expect(result.typeDefinition).toContain('readonly age: number');
});
it('should include reference imports in type definition', () => {
const csv = [
'id,customer',
'string,@users',
'1,1',
].join('\n');
const result = parseCsv(csv, {
emitTypes: true,
resourceName: 'orders',
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.typeDefinition).toBeDefined();
expect(result.typeDefinition).toContain('Users');
expect(result.typeDefinition).toContain('users.csv');
});
it('should not generate type definition when emitTypes is false', () => {
const csv = [
'name,age',
'string,number',
'Alice,30',
].join('\n');
const result = parseCsv(csv, { emitTypes: false });
expect(result.typeDefinition).toBeUndefined();
});
it('should generate correct type for reference column', () => {
const csv = [
'id,customer',
'string,@users',
'1,1',
].join('\n');
const result = parseCsv(csv, {
emitTypes: true,
resourceName: 'orders',
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.typeDefinition).toContain('readonly customer: Users');
});
it('should generate correct type for array reference column', () => {
const csv = [
'id,items',
'string,@parts[]',
'1,[1]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: true,
resourceName: 'orders',
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.typeDefinition).toContain('readonly items: readonly Parts[]');
});
it('should generate correct type for reference in tuple', () => {
const csv = [
'id,info',
'string,[ref: @users; note: string]',
'1,[ref: 1; note: test]',
].join('\n');
const result = parseCsv(csv, {
emitTypes: true,
resourceName: 'data',
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
expect(result.typeDefinition).toContain('Users');
});
});
describe('parseCsv - caching', () => {
it('should cache referenced table and not re-read on subsequent references', () => {
const usersCsv = readFixture('users.csv');
const csv = [
'id,creator,reviewer',
'string,@users,@users',
'1,1,2',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, 'test.csv'),
});
const creator = result.data[0].creator as Record<string, unknown>;
const reviewer = result.data[0].reviewer as Record<string, unknown>;
expect(creator).not.toEqual(reviewer);
expect(creator.id).toBe('1');
expect(reviewer.id).toBe('2');
});
});
describe('parseCsv - refBaseDir option', () => {
it('should use refBaseDir to resolve reference paths', () => {
const csv = [
'id,customer',
'string,@users',
'1,1',
].join('\n');
const result = parseCsv(csv, {
emitTypes: false,
refBaseDir: fixturesDir,
});
expect(result.data[0].customer).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});
});
});