188 lines
6.0 KiB
TypeScript
188 lines
6.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { convertDocument } from "../../src/core/convert";
|
|
import { DATA_LIMITS } from "../../src/core/limits";
|
|
import { objectValue } from "../../src/core/model";
|
|
import { parseDataDocument } from "../../src/core/parse";
|
|
import { DataToolsError } from "../../src/core/types";
|
|
|
|
describe("bounded format parsers", () => {
|
|
it("preserves JSON number lexemes exactly", () => {
|
|
const source =
|
|
'{"integer":9007199254740993123456789,"decimal":0.123456789012345678901}';
|
|
const document = parseDataDocument({ source, format: "json" });
|
|
expect(objectValue(document.root, "integer")).toMatchObject({
|
|
type: "number",
|
|
raw: "9007199254740993123456789",
|
|
representation: "exact",
|
|
});
|
|
expect(convertDocument(document, "json").text).toContain(
|
|
"0.123456789012345678901",
|
|
);
|
|
});
|
|
|
|
it("rejects duplicate and prototype-affecting JSON keys", () => {
|
|
expect(() =>
|
|
parseDataDocument({ source: '{"a":1,"a":2}', format: "json" }),
|
|
).toThrow(DataToolsError);
|
|
expect(() =>
|
|
parseDataDocument({ source: '{"__proto__":1}', format: "json" }),
|
|
).toThrow(/dangerous object key/iu);
|
|
});
|
|
|
|
it("uses YAML 1.2 core semantics and rejects custom tags and merges", () => {
|
|
const document = parseDataDocument({
|
|
source: "answer: yes\nactual: true\ninteger: 9007199254740993\n",
|
|
format: "yaml",
|
|
});
|
|
expect(objectValue(document.root, "answer")).toEqual({
|
|
type: "string",
|
|
value: "yes",
|
|
});
|
|
expect(objectValue(document.root, "integer")).toMatchObject({
|
|
type: "number",
|
|
raw: "9007199254740993",
|
|
});
|
|
expect(() =>
|
|
parseDataDocument({ source: "value: !custom test", format: "yaml" }),
|
|
).toThrow(/tag/iu);
|
|
expect(() =>
|
|
parseDataDocument({
|
|
source: "base: &base\n a: 1\ncopy:\n <<: *base\n",
|
|
format: "yaml",
|
|
}),
|
|
).toThrow(/merge/iu);
|
|
});
|
|
|
|
it("keeps TOML integers exact and models date/time values", () => {
|
|
const document = parseDataDocument({
|
|
source:
|
|
'title = "example"\nlarge = 9007199254740993\ncreated = 2026-09-01T10:15:30Z\n',
|
|
format: "toml",
|
|
});
|
|
expect(objectValue(document.root, "large")).toMatchObject({
|
|
type: "number",
|
|
raw: "9007199254740993",
|
|
representation: "bigint",
|
|
});
|
|
expect(objectValue(document.root, "created")).toMatchObject({
|
|
type: "date",
|
|
dateKind: "offset-date-time",
|
|
});
|
|
});
|
|
|
|
it("rejects XML declarations with active resolution semantics", () => {
|
|
expect(() =>
|
|
parseDataDocument({
|
|
source: '<!DOCTYPE root [<!ENTITY x "expanded">]><root>&x;</root>',
|
|
format: "xml",
|
|
}),
|
|
).toThrow(/DOCTYPE/iu);
|
|
expect(() =>
|
|
parseDataDocument({
|
|
source:
|
|
'<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="file:///etc/passwd"/></root>',
|
|
format: "xml",
|
|
}),
|
|
).toThrow(/XInclude/iu);
|
|
expect(() =>
|
|
parseDataDocument({
|
|
source:
|
|
'<root xmlns:load="http://www.w3.org/2001/XInclude"><load:include href="https://example.test/private"/></root>',
|
|
format: "xml",
|
|
}),
|
|
).toThrow(/XInclude/iu);
|
|
});
|
|
|
|
it("preserves XML namespaces, attributes and ordered children explicitly", () => {
|
|
const document = parseDataDocument({
|
|
source:
|
|
'<n:root xmlns:n="urn:test" id="7">before<n:item>one</n:item><!--note-->after</n:root>',
|
|
format: "xml",
|
|
});
|
|
expect(document.model).toBe("xml-explicit");
|
|
expect(objectValue(document.root, "name")).toEqual({
|
|
type: "string",
|
|
value: "n:root",
|
|
});
|
|
expect(objectValue(document.root, "namespace")).toEqual({
|
|
type: "string",
|
|
value: "urn:test",
|
|
});
|
|
expect(convertDocument(document, "xml").text).toContain(
|
|
"<n:item>one</n:item>",
|
|
);
|
|
});
|
|
|
|
it("parses delimited data as strings, including quoted fields", () => {
|
|
const document = parseDataDocument({
|
|
source: 'name,value\r\nAda,"1,200"\r\nZero,001',
|
|
format: "csv",
|
|
});
|
|
expect(document.model).toBe("tabular");
|
|
expect(document.root).toMatchObject({ type: "array" });
|
|
expect(document.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: "delimited.strings" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("preflights delimited dimensions before allocating the parsed table", () => {
|
|
const row = new Array(DATA_LIMITS.maxColumns).fill("x").join(",");
|
|
const source = new Array(
|
|
Math.floor(DATA_LIMITS.maxCells / DATA_LIMITS.maxColumns) + 2,
|
|
)
|
|
.fill(row)
|
|
.join("\n");
|
|
expect(() => parseDataDocument({ source, format: "csv" })).toThrow(
|
|
/Cell count/iu,
|
|
);
|
|
});
|
|
|
|
it("reports an exact NDJSON line and ignores blank lines", () => {
|
|
const document = parseDataDocument({
|
|
source: '{"id":1}\n\n{"id":2}\n',
|
|
format: "ndjson",
|
|
});
|
|
expect(document.root).toMatchObject({ type: "array", items: [{}, {}] });
|
|
expect(document.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: "ndjson.blank-lines" }),
|
|
]),
|
|
);
|
|
expect(() =>
|
|
parseDataDocument({
|
|
source: '{"id":1}\n{"id":}\n',
|
|
format: "ndjson",
|
|
}),
|
|
).toThrow(/line 2/iu);
|
|
});
|
|
|
|
it("detects by content or filename and discloses a mismatch", () => {
|
|
expect(
|
|
parseDataDocument({
|
|
source: '{"ok":true}\n{"ok":false}',
|
|
format: "auto",
|
|
}).format,
|
|
).toBe("ndjson");
|
|
const mismatch = parseDataDocument({
|
|
source: '{"looks":"json"}',
|
|
format: "auto",
|
|
filename: "values.yaml",
|
|
});
|
|
expect(mismatch.format).toBe("yaml");
|
|
expect(mismatch.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ code: "format.possible-mismatch" }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("rejects inputs deeper than the structural cap before modelling", () => {
|
|
const source = `${"[".repeat(DATA_LIMITS.maxDepth + 1)}0${"]".repeat(DATA_LIMITS.maxDepth + 1)}`;
|
|
expect(() => parseDataDocument({ source, format: "json" })).toThrow(
|
|
/depth/iu,
|
|
);
|
|
});
|
|
});
|