test: add comprehensive tests for csvToModule

- Add unit tests for accessor-based output, circular references,
  and reverse reference resolution in `csvToModule`.
- Extract fixture loading logic into `test-utils.ts`.
- Refactor `loader.test.ts` to use the new test utilities.
This commit is contained in:
hypercross 2026-04-20 01:29:59 +08:00
parent f94e9b68e4
commit f66f60aa0e
3 changed files with 391 additions and 393 deletions

View File

@ -1,14 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { parseCsv } from "./loader"; import { parseCsv } from "./loader";
import * as path from "path"; import * as path from "path";
import * as fs from "fs"; import { fixturesDir, readFixture } from "./test-utils";
import { csvToModule } from "./module-gen"; import 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", () => { describe("parseCsv - basic parsing", () => {
it("should parse a simple CSV with primitive types", () => { it("should parse a simple CSV with primitive types", () => {
@ -770,245 +764,6 @@ describe("parseCsv - resolveReferences: false", () => {
}); });
}); });
describe("csvToModule - accessor-based output", () => {
it("should emit accessor function for tables without references", () => {
const csv = ["name,age", "string,number", "Alice,30"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("export default function getData()");
expect(result.js).not.toContain("import ");
expect(result.js).not.toContain("Lookup");
});
it("should emit accessor function for tables with references", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_resolved = _raw;");
});
it("should emit accessor function for tables with array references", () => {
const csv = ["id,items", "string,@parts[]", "1,[1; 2]"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _parts from './parts.csv'");
expect(result.js).toContain("_partsLookup");
expect(result.js).toContain(".map(id =>");
});
it("should emit multiple imports for multiple reference tables", () => {
const csv = [
"id,customer,items",
"string,@users,@parts[]",
"1,1,[1; 2]",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("import _parts from './parts.csv'");
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_partsLookup");
});
it("should generate function type in dts for tables with references", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "orders",
});
expect(result.dts).toContain("declare function getData(): ordersTable");
expect(result.dts).toContain("export default getData");
expect(result.dts).not.toContain("declare const data");
});
it("should generate function type in dts for tables without references", () => {
const csv = ["name,age", "string,number", "Alice,30"].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "people",
});
expect(result.dts).toContain("declare function getData(): peopleTable");
expect(result.dts).toContain("export default getData");
expect(result.dts).not.toContain("declare const data");
});
it("should handle nested references in tuples", () => {
const csv = [
"id,info",
"string,[ref: @users; note: string]",
"1,[ref: 1; note: urgent]",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("_usersLookup");
});
});
describe("csvToModule - circular reference support", () => {
it("should emit accessor for self-referencing table without self-import", () => {
const csv = readFixture("self_ref.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("_self_refLookup = new Map(_raw.map");
expect(result.js).toContain(
"parent: _self_refLookup.get(String(row.parent))",
);
});
it("should emit accessor for cross-referencing tables", () => {
const csv = readFixture("circular_a.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain("related:");
});
it("should emit accessor for self-referencing table with nested reference in tuple", () => {
const csv = [
"id,name,parent_info",
"string,string,[parent: @self_ref; role: string]",
"1,Root,[parent: 2; role: admin]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("parent_info:");
expect(result.js).toContain(
"_self_refLookup.get(String(row.parent_info[0]))",
);
});
it("should emit accessor for self-referencing table with nested reference in union with fallback", () => {
const csv = [
"id,name,ref_or_val",
"string,string,@self_ref | string",
"1,Root,2",
"2,Child,none",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("ref_or_val:");
expect(result.js).toContain(
"_self_refLookup.get(String(row.ref_or_val)) ?? row.ref_or_val",
);
});
it("should emit accessor for self-referencing table with nested reference array in tuple", () => {
const csv = [
"id,name,children",
"string,string,[kids: @self_ref[]]",
"1,Root,[[2]]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("children:");
});
it("should generate correct type definition for self-referencing table using local singular type", () => {
const csv = readFixture("self_ref.csv");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "nodes",
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.dts).toContain("declare function getData(): nodesTable");
expect(result.dts).toContain("readonly parent: Nodes");
expect(result.dts).not.toContain(
"import type { Self_ref } from './self_ref.csv'",
);
expect(result.dts).not.toContain("Self_ref");
});
it("should emit accessor for cross-referencing tables with array references", () => {
const csv = readFixture("circular_a.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain(".map(id =>");
});
it("should emit accessor for nested cross-reference in tuple", () => {
const csv = [
"id,name,info",
"string,string,[ref: @circular_b; note: string]",
"1,A,[ref: 1; note: linked]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain("export default function getData()");
});
it("should generate union fallback with ?? for non-reference union members", () => {
const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join(
"\n",
);
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("?? row.value");
});
});
describe("parseCsv - reverse reference resolution", () => { describe("parseCsv - reverse reference resolution", () => {
it("should resolve reverse reference from comment declaration", () => { it("should resolve reverse reference from comment declaration", () => {
// Create a temporary orders CSV with a plain string foreign key // Create a temporary orders CSV with a plain string foreign key
@ -1281,149 +1036,3 @@ describe("parseCsv - reverse reference with resolveReferences: false", () => {
).not.toThrow(); ).not.toThrow();
}); });
}); });
describe("csvToModule - reverse reference output", () => {
it("should emit reverse lookup code for reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _orders from './orders.csv'");
expect(result.js).toContain("_ordersBy_customer");
expect(result.js).toContain("orders:");
expect(result.js).toContain("_ordersBy_customer.get(String(row.id))");
});
it("should emit null fallback for optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)?",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain(
"_ordersBy_customer.get(String(row.id)) || null",
);
});
it("should emit empty array fallback for non-optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("_ordersBy_customer.get(String(row.id)) || []");
});
it("should handle self-referencing reverse reference without self-import", () => {
const csv = [
"id,name",
"string,string",
"# children := ~self_ref(parent)",
"1,Root",
"2,Child",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refBy_parent");
expect(result.js).toContain("for (const r of _raw)");
expect(result.js).toContain("children:");
});
it("should generate correct type definition for reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "users",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.dts).toContain("readonly orders: Orders[]");
expect(result.dts).toContain("import type { Orders }");
});
it("should generate nullable type for optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)?",
"1,Alice",
].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "users",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.dts).toContain("readonly orders: Orders[] | null");
});
it("should combine forward and reverse references to the same table", () => {
// When the current table references itself via both @ and ~,
// no self-import is needed
const csv = [
"id,name,manager",
"string,string,@users",
"# reports := ~users(manager)",
"1,Alice,2",
"2,Bob,",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "users.csv"),
});
// Forward reference uses lookup built from _raw (self)
expect(result.js).toContain("_usersLookup");
// Reverse reference uses reverse lookup built from _raw (self)
expect(result.js).toContain("_usersBy_manager");
// No self-import since the current file IS users.csv
expect(result.js).not.toContain("import _users from './users.csv'");
});
it("should import referenced table when forward and reverse reference a different table", () => {
const csv = [
"id,name,creator",
"string,string,@users",
"# reviews := ~users(reviewer)",
"1,Doc,1",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
// Forward reference uses lookup
expect(result.js).toContain("_usersLookup");
// Reverse reference uses reverse lookup
expect(result.js).toContain("_usersBy_reviewer");
// users is a different table, so it should be imported
expect(result.js).toContain("import _users from './users.csv'");
});
});

View File

@ -0,0 +1,381 @@
import { describe, it, expect } from "vitest";
import { csvToModule } from "./module-gen";
import * as path from "path";
import { fixturesDir, readFixture } from "./test-utils";
describe("csvToModule - accessor-based output", () => {
it("should emit accessor function for tables without references", () => {
const csv = ["name,age", "string,number", "Alice,30"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("export default function getData()");
expect(result.js).not.toContain("import ");
expect(result.js).not.toContain("Lookup");
});
it("should emit accessor function for tables with references", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_resolved = _raw;");
});
it("should emit accessor function for tables with array references", () => {
const csv = ["id,items", "string,@parts[]", "1,[1; 2]"].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _parts from './parts.csv'");
expect(result.js).toContain("_partsLookup");
expect(result.js).toContain(".map(id =>");
});
it("should emit multiple imports for multiple reference tables", () => {
const csv = [
"id,customer,items",
"string,@users,@parts[]",
"1,1,[1; 2]",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("import _parts from './parts.csv'");
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_partsLookup");
});
it("should generate function type in dts for tables with references", () => {
const csv = ["id,customer", "string,@users", "1,1"].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "orders",
});
expect(result.dts).toContain("declare function getData(): ordersTable");
expect(result.dts).toContain("export default getData");
expect(result.dts).not.toContain("declare const data");
});
it("should generate function type in dts for tables without references", () => {
const csv = ["name,age", "string,number", "Alice,30"].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "people",
});
expect(result.dts).toContain("declare function getData(): peopleTable");
expect(result.dts).toContain("export default getData");
expect(result.dts).not.toContain("declare const data");
});
it("should handle nested references in tuples", () => {
const csv = [
"id,info",
"string,[ref: @users; note: string]",
"1,[ref: 1; note: urgent]",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _users from './users.csv'");
expect(result.js).toContain("_usersLookup");
});
});
describe("csvToModule - circular reference support", () => {
it("should emit accessor for self-referencing table without self-import", () => {
const csv = readFixture("self_ref.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("_self_refLookup = new Map(_raw.map");
expect(result.js).toContain(
"parent: _self_refLookup.get(String(row.parent))",
);
});
it("should emit accessor for cross-referencing tables", () => {
const csv = readFixture("circular_a.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain("related:");
});
it("should emit accessor for self-referencing table with nested reference in tuple", () => {
const csv = [
"id,name,parent_info",
"string,string,[parent: @self_ref; role: string]",
"1,Root,[parent: 2; role: admin]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("parent_info:");
expect(result.js).toContain(
"_self_refLookup.get(String(row.parent_info[0]))",
);
});
it("should emit accessor for self-referencing table with nested reference in union with fallback", () => {
const csv = [
"id,name,ref_or_val",
"string,string,@self_ref | string",
"1,Root,2",
"2,Child,none",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("ref_or_val:");
expect(result.js).toContain(
"_self_refLookup.get(String(row.ref_or_val)) ?? row.ref_or_val",
);
});
it("should emit accessor for self-referencing table with nested reference array in tuple", () => {
const csv = [
"id,name,children",
"string,string,[kids: @self_ref[]]",
"1,Root,[[2]]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refLookup");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("children:");
});
it("should generate correct type definition for self-referencing table using local singular type", () => {
const csv = readFixture("self_ref.csv");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "nodes",
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.dts).toContain("declare function getData(): nodesTable");
expect(result.dts).toContain("readonly parent: Nodes");
expect(result.dts).not.toContain(
"import type { Self_ref } from './self_ref.csv'",
);
expect(result.dts).not.toContain("Self_ref");
});
it("should emit accessor for cross-referencing tables with array references", () => {
const csv = readFixture("circular_a.csv");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("export default function getData()");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain(".map(id =>");
});
it("should emit accessor for nested cross-reference in tuple", () => {
const csv = [
"id,name,info",
"string,string,[ref: @circular_b; note: string]",
"1,A,[ref: 1; note: linked]",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "circular_a.csv"),
});
expect(result.js).toContain("import _circular_b from './circular_b.csv'");
expect(result.js).toContain("_circular_bLookup");
expect(result.js).toContain("export default function getData()");
});
it("should generate union fallback with ?? for non-reference union members", () => {
const csv = ["id,value", "string,@users | string", "1,1", "2,unknown"].join(
"\n",
);
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("?? row.value");
});
});
describe("csvToModule - reverse reference output", () => {
it("should emit reverse lookup code for reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("import _orders from './orders.csv'");
expect(result.js).toContain("_ordersBy_customer");
expect(result.js).toContain("orders:");
expect(result.js).toContain("_ordersBy_customer.get(String(row.id))");
});
it("should emit null fallback for optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)?",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain(
"_ordersBy_customer.get(String(row.id)) || null",
);
});
it("should emit empty array fallback for non-optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, { emitTypes: false });
expect(result.js).toContain("_ordersBy_customer.get(String(row.id)) || []");
});
it("should handle self-referencing reverse reference without self-import", () => {
const csv = [
"id,name",
"string,string",
"# children := ~self_ref(parent)",
"1,Root",
"2,Child",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "self_ref.csv"),
});
expect(result.js).not.toContain("import _self_ref from './self_ref.csv'");
expect(result.js).toContain("_self_refBy_parent");
expect(result.js).toContain("for (const r of _raw)");
expect(result.js).toContain("children:");
});
it("should generate correct type definition for reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)",
"1,Alice",
].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "users",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.dts).toContain("readonly orders: Orders[]");
expect(result.dts).toContain("import type { Orders }");
});
it("should generate nullable type for optional reverse references", () => {
const csv = [
"id,name",
"string,string",
"# orders := ~orders(customer)?",
"1,Alice",
].join("\n");
const result = csvToModule(csv, {
emitTypes: true,
resourceName: "users",
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.dts).toContain("readonly orders: Orders[] | null");
});
it("should combine forward and reverse references to the same table", () => {
const csv = [
"id,name,manager",
"string,string,@users",
"# reports := ~users(manager)",
"1,Alice,2",
"2,Bob,",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "users.csv"),
});
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_usersBy_manager");
expect(result.js).not.toContain("import _users from './users.csv'");
});
it("should import referenced table when forward and reverse reference a different table", () => {
const csv = [
"id,name,creator",
"string,string,@users",
"# reviews := ~users(reviewer)",
"1,Doc,1",
].join("\n");
const result = csvToModule(csv, {
emitTypes: false,
currentFilePath: path.join(fixturesDir, "test.csv"),
});
expect(result.js).toContain("_usersLookup");
expect(result.js).toContain("_usersBy_reviewer");
expect(result.js).toContain("import _users from './users.csv'");
});
});

View File

@ -0,0 +1,8 @@
import * as path from "path";
import * as fs from "fs";
export const fixturesDir = path.join(__dirname, "fixtures");
export function readFixture(name: string): string {
return fs.readFileSync(path.join(fixturesDir, name), "utf-8");
}