feat: integrate SimpleCADAPI 2.0.2 CAD workflows
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
canonicalJsonBytes,
|
||||
parseCanonicalJson,
|
||||
parseStrictJson,
|
||||
validateNormalizedProduct,
|
||||
withSceneRevision,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("canonical JSON renders finite Numbers from their value", () => {
|
||||
assert.equal(canonicalJsonBytes(1e30).toString("utf8"), "1e+30");
|
||||
assert.equal(canonicalJsonBytes(parseStrictJson("1e30")).toString("utf8"), "1e+30");
|
||||
|
||||
const nested = parseStrictJson('{"large":1e30,"small":[1E-7]}');
|
||||
assert.equal(
|
||||
canonicalJsonBytes(nested).toString("utf8"),
|
||||
'{"large":1e+30,"small":[1e-7]}',
|
||||
);
|
||||
assert.deepEqual(parseCanonicalJson("1e+30"), 1e30);
|
||||
assert.throws(() => parseCanonicalJson("1e30"), /canonical/);
|
||||
});
|
||||
|
||||
test("canonical JSON normalizes negative zero at root and nested positions", () => {
|
||||
const root = parseStrictJson("-0");
|
||||
assert.equal(Object.is(root, -0), true);
|
||||
assert.equal(canonicalJsonBytes(root).toString("utf8"), "0");
|
||||
|
||||
const nested = parseStrictJson('{"values":[-0,0e0]}');
|
||||
assert.equal(canonicalJsonBytes(nested).toString("utf8"), '{"values":[0,0]}');
|
||||
assert.throws(() => parseCanonicalJson("-0"), /canonical/);
|
||||
});
|
||||
|
||||
test("cloning parsed values does not change canonical bytes or scene revision", () => {
|
||||
const scene = parseStrictJson('{"measurement":1e30,"nested":{"epsilon":1e-7}}') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const clone = structuredClone(scene);
|
||||
|
||||
assert.deepEqual(canonicalJsonBytes(clone), canonicalJsonBytes(scene));
|
||||
assert.deepEqual(withSceneRevision(clone), withSceneRevision(scene));
|
||||
});
|
||||
|
||||
test("unsafe integer JSON is rejected lexically without affecting exponent Numbers", () => {
|
||||
assert.throws(() => parseStrictJson("9007199254740992"), /safe domain/);
|
||||
assert.throws(() => parseStrictJson('{"value":-9007199254740992}'), /safe domain/);
|
||||
assert.equal(
|
||||
canonicalJsonBytes(Number.MAX_SAFE_INTEGER + 1).toString("utf8"),
|
||||
"9007199254740992",
|
||||
);
|
||||
assert.equal(canonicalJsonBytes(parseStrictJson("1e30")).toString("utf8"), "1e+30");
|
||||
});
|
||||
|
||||
test("strict JSON rejects unpaired surrogates in values and object keys", () => {
|
||||
assert.throws(() => parseStrictJson('{"value":"\\ud800"}'), /surrogate/);
|
||||
assert.throws(() => parseStrictJson('{"\\ud800":0}'), /surrogate/);
|
||||
});
|
||||
|
||||
test("serialized JSON over the depth limit reports a budget issue", () => {
|
||||
let serialized = "0";
|
||||
for (let depth = 0; depth < 65; depth += 1) serialized = `{"x":${serialized}}`;
|
||||
|
||||
const result = validateNormalizedProduct(serialized);
|
||||
assert.equal(result.firstError?.code, "resource_limit_exceeded");
|
||||
assert.equal(result.firstError?.phase, "budget");
|
||||
assert.equal(result.firstError?.path, "/x".repeat(65));
|
||||
});
|
||||
|
||||
test("serialized non-finite tokens and overflow report nonfinite_json_number", () => {
|
||||
for (const serialized of [
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"-Infinity",
|
||||
"1e999",
|
||||
'[0,NaN]',
|
||||
'{"value":1e999}',
|
||||
]) {
|
||||
const result = validateNormalizedProduct(serialized);
|
||||
assert.equal(result.firstError?.code, "nonfinite_json_number", serialized);
|
||||
assert.equal(result.firstError?.phase, "parse", serialized);
|
||||
}
|
||||
|
||||
const programmatic = validateNormalizedProduct({ metadata: { value: Number.NaN } });
|
||||
assert.equal(programmatic.firstError?.code, "nonfinite_json_number");
|
||||
assert.equal(programmatic.firstError?.path, "/metadata/value");
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { withSceneRevision } from "../src/index.js";
|
||||
|
||||
export interface ExpectedIssue { code: string; path: string }
|
||||
export interface Mutation {
|
||||
operation: "add" | "delete" | "set";
|
||||
path: (string | number)[];
|
||||
value?: unknown;
|
||||
}
|
||||
export interface SceneFieldCase {
|
||||
name: string;
|
||||
valid: boolean;
|
||||
expected: ExpectedIssue | null;
|
||||
mutations: Mutation[];
|
||||
recompute_revision: boolean;
|
||||
}
|
||||
export interface PackageCase {
|
||||
name: string;
|
||||
valid: boolean;
|
||||
expected: ExpectedIssue | null;
|
||||
manifest_base64: string;
|
||||
blob_uris: string[];
|
||||
blob_mutations?: Record<string, string>;
|
||||
}
|
||||
export interface SceneShapeCase extends PackageCase {
|
||||
expected_shape: SceneShapeFacts;
|
||||
}
|
||||
export interface SceneShapeFacts {
|
||||
definition_count: number;
|
||||
definition_occurrence_counts: Record<string, number>;
|
||||
edge_asset_count: number;
|
||||
entity_asset_count: number;
|
||||
geometry_asset_count: number;
|
||||
maximum_depth: number;
|
||||
node_count: number;
|
||||
root_node_ids: string[];
|
||||
}
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
assert.ok(value !== null && typeof value === "object" && !Array.isArray(value));
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function asRecords(value: unknown): JsonRecord[] {
|
||||
assert.ok(Array.isArray(value));
|
||||
return value.map(asRecord);
|
||||
}
|
||||
|
||||
export function corpusBlobs(
|
||||
pool: Record<string, string>,
|
||||
item: PackageCase,
|
||||
): Map<string, Uint8Array> {
|
||||
const blobs = new Map<string, Uint8Array>(
|
||||
item.blob_uris.map((uri) => [uri, Buffer.from(pool[uri], "base64")]),
|
||||
);
|
||||
for (const [uri, payload] of Object.entries(item.blob_mutations ?? {})) {
|
||||
blobs.set(uri, Buffer.from(payload, "base64"));
|
||||
}
|
||||
return blobs;
|
||||
}
|
||||
|
||||
export function applySceneFieldCase(
|
||||
scene: JsonRecord,
|
||||
item: SceneFieldCase,
|
||||
): JsonRecord {
|
||||
const result = structuredClone(scene);
|
||||
for (const mutation of item.mutations) {
|
||||
let current: JsonRecord | unknown[] = result;
|
||||
for (const part of mutation.path.slice(0, -1)) {
|
||||
current = Array.isArray(current)
|
||||
? current[part as number] as JsonRecord | unknown[]
|
||||
: current[part as string] as JsonRecord | unknown[];
|
||||
}
|
||||
const final = mutation.path.at(-1)!;
|
||||
if (mutation.operation === "delete") {
|
||||
if (Array.isArray(current)) delete current[final as number];
|
||||
else delete current[final as string];
|
||||
} else if (Array.isArray(current)) {
|
||||
current[final as number] = structuredClone(mutation.value);
|
||||
} else {
|
||||
current[final as string] = structuredClone(mutation.value);
|
||||
}
|
||||
}
|
||||
return item.recompute_revision ? withSceneRevision(result) : result;
|
||||
}
|
||||
|
||||
export function sceneShapeFacts(scene: JsonRecord): SceneShapeFacts {
|
||||
const nodes = asRecords(scene.nodes);
|
||||
const definitionOccurrenceCounts: Record<string, number> = {};
|
||||
for (const node of nodes) {
|
||||
const definitionId = String(node.definition_id);
|
||||
definitionOccurrenceCounts[definitionId] =
|
||||
(definitionOccurrenceCounts[definitionId] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
definition_count: asRecords(scene.definitions).length,
|
||||
definition_occurrence_counts: Object.fromEntries(
|
||||
Object.entries(definitionOccurrenceCounts).sort(([left], [right]) =>
|
||||
Buffer.compare(Buffer.from(left), Buffer.from(right))
|
||||
),
|
||||
),
|
||||
edge_asset_count: asRecords(scene.edge_assets).length,
|
||||
entity_asset_count: asRecords(scene.entity_assets).length,
|
||||
geometry_asset_count: asRecords(scene.geometry_assets).length,
|
||||
maximum_depth: Math.max(
|
||||
...nodes.map((node) => {
|
||||
const source = asRecord(node.source);
|
||||
return Array.isArray(source.component_path) ? source.component_path.length : 0;
|
||||
}),
|
||||
),
|
||||
node_count: nodes.length,
|
||||
root_node_ids: nodes
|
||||
.filter((node) => node.parent_node_id === null)
|
||||
.map((node) => String(node.node_id)),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
ConnectorBindingDocument,
|
||||
ContractDocumentValidationError,
|
||||
EntityDocument,
|
||||
type ImmutableContractDocument,
|
||||
NormalizedProductDocument,
|
||||
parseCanonicalJson,
|
||||
parseStrictJson,
|
||||
PresentationDocument,
|
||||
SceneDocument,
|
||||
sha256,
|
||||
} from "../src/index.js";
|
||||
|
||||
interface ValidationCase {
|
||||
valid: boolean;
|
||||
payload_base64: string;
|
||||
}
|
||||
|
||||
interface Corpus {
|
||||
artifacts: Record<string, { base64: string }>;
|
||||
presentation_cases: ValidationCase[];
|
||||
connector_binding_cases: ValidationCase[];
|
||||
normalized_product_cases: ValidationCase[];
|
||||
}
|
||||
|
||||
interface TestFactory {
|
||||
fromValue(value: unknown): ImmutableContractDocument<unknown>;
|
||||
parse(data: Uint8Array | string): ImmutableContractDocument<unknown>;
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const corpus = parseStrictJson(
|
||||
readFileSync(resolve(here, "../../../test/fixtures/scene-contract/corpus.json")),
|
||||
) as Corpus;
|
||||
|
||||
const validCase = (cases: ValidationCase[]): Buffer => {
|
||||
const item = cases.find((candidate) => candidate.valid);
|
||||
assert.ok(item !== undefined);
|
||||
return Buffer.from(item.payload_base64, "base64");
|
||||
};
|
||||
|
||||
const documents: readonly [string, TestFactory, Buffer][] = [
|
||||
[
|
||||
"scene",
|
||||
SceneDocument as unknown as TestFactory,
|
||||
Buffer.from(corpus.artifacts.scene.base64, "base64"),
|
||||
],
|
||||
[
|
||||
"entities",
|
||||
EntityDocument as unknown as TestFactory,
|
||||
Buffer.from(corpus.artifacts.entities.base64, "base64"),
|
||||
],
|
||||
[
|
||||
"presentation",
|
||||
PresentationDocument as unknown as TestFactory,
|
||||
validCase(corpus.presentation_cases),
|
||||
],
|
||||
[
|
||||
"connector binding",
|
||||
ConnectorBindingDocument as unknown as TestFactory,
|
||||
validCase(corpus.connector_binding_cases),
|
||||
],
|
||||
[
|
||||
"normalized product",
|
||||
NormalizedProductDocument as unknown as TestFactory,
|
||||
validCase(corpus.normalized_product_cases),
|
||||
],
|
||||
];
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
assert.ok(value !== null && typeof value === "object" && !Array.isArray(value));
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function assertDeepFrozen(value: unknown): void {
|
||||
if (value === null || typeof value !== "object") return;
|
||||
assert.equal(Object.isFrozen(value), true);
|
||||
for (const child of Object.values(value)) assertDeepFrozen(child);
|
||||
}
|
||||
|
||||
function nestedContainer(value: Record<string, unknown>): Record<string, unknown> | unknown[] {
|
||||
const result = Object.values(value).find(
|
||||
(child) => child !== null && typeof child === "object",
|
||||
);
|
||||
assert.ok(result !== undefined);
|
||||
return result as Record<string, unknown> | unknown[];
|
||||
}
|
||||
|
||||
test("all contract document factories construct, parse, validate, and round trip", () => {
|
||||
for (const [name, factory, payload] of documents) {
|
||||
const input = record(parseCanonicalJson(payload));
|
||||
const constructed = factory.fromValue(input);
|
||||
const parsed = factory.parse(payload);
|
||||
|
||||
assert.deepEqual(constructed.canonicalBytes, payload, name);
|
||||
assert.deepEqual(parsed.canonicalBytes, payload, name);
|
||||
assert.equal(constructed.canonicalHash, sha256(payload), name);
|
||||
assert.deepEqual(constructed.toMutable(), parsed.toMutable(), name);
|
||||
assert.deepEqual(factory.fromValue(constructed.value).canonicalBytes, payload, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("contract documents resist nested mutation and isolate mutable copies", () => {
|
||||
for (const [name, factory, payload] of documents) {
|
||||
const input = record(parseCanonicalJson(payload));
|
||||
const document = factory.fromValue(input);
|
||||
const expectedHash = document.canonicalHash;
|
||||
const expectedBytes = document.canonicalBytes;
|
||||
|
||||
assert.equal(Object.isFrozen(document), true, name);
|
||||
assertDeepFrozen(document.value);
|
||||
assert.throws(() => {
|
||||
record(document.value).mutation = true;
|
||||
}, TypeError, name);
|
||||
const frozenNested = nestedContainer(record(document.value));
|
||||
assert.throws(() => {
|
||||
if (Array.isArray(frozenNested)) frozenNested.push(null);
|
||||
else frozenNested.mutation = true;
|
||||
}, TypeError, name);
|
||||
|
||||
input.mutation = true;
|
||||
const firstCopy = record(document.toMutable());
|
||||
const secondCopy = record(document.toMutable());
|
||||
const mutableNested = nestedContainer(firstCopy);
|
||||
if (Array.isArray(mutableNested)) mutableNested.push(null);
|
||||
else mutableNested.mutation = true;
|
||||
firstCopy.mutation = true;
|
||||
|
||||
assert.equal(Object.hasOwn(record(document.value), "mutation"), false, name);
|
||||
assert.equal(Object.hasOwn(secondCopy, "mutation"), false, name);
|
||||
assert.notDeepEqual(firstCopy, secondCopy, name);
|
||||
|
||||
const exposedBytes = document.canonicalBytes;
|
||||
exposedBytes[0] ^= 0xff;
|
||||
assert.deepEqual(document.canonicalBytes, expectedBytes, name);
|
||||
assert.equal(document.canonicalHash, expectedHash, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("document constructors reject invalid values, noncanonical input, and stale revisions", () => {
|
||||
for (const [name, factory] of documents) {
|
||||
assert.throws(
|
||||
() => factory.fromValue({}),
|
||||
ContractDocumentValidationError,
|
||||
name,
|
||||
);
|
||||
}
|
||||
assert.throws(() => SceneDocument.parse('{ "schema_version": "1.0" }'), /canonical/);
|
||||
|
||||
const scene = record(
|
||||
parseCanonicalJson(Buffer.from(corpus.artifacts.scene.base64, "base64")),
|
||||
);
|
||||
scene.revision = `sha256:${"0".repeat(64)}`;
|
||||
assert.throws(
|
||||
() => SceneDocument.fromValue(scene as never),
|
||||
(error: unknown) =>
|
||||
error instanceof ContractDocumentValidationError &&
|
||||
error.report.firstError?.code === "revision_mismatch",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
canonicalJsonBytes,
|
||||
computeSceneRevision,
|
||||
parseStrictJson,
|
||||
sha256,
|
||||
} from "../src/canonical.js";
|
||||
import { report } from "../src/report.js";
|
||||
import {
|
||||
computePackageBudgetTotals,
|
||||
packageBudgetIssues,
|
||||
validateSceneManifest,
|
||||
validateScenePackage,
|
||||
} from "../src/validation.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface Artifact {
|
||||
base64: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
interface Corpus {
|
||||
artifacts: Record<string, Artifact>;
|
||||
presentation_cases: { valid: boolean; payload_base64: string }[];
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const corpusPath = resolve(here, "../../../test/fixtures/scene-contract/corpus.json");
|
||||
const corpus = parseStrictJson(readFileSync(corpusPath)) as Corpus;
|
||||
const decode = (value: string): Buffer => Buffer.from(value, "base64");
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
assert.ok(value !== null && typeof value === "object" && !Array.isArray(value));
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function asRecords(value: unknown): JsonRecord[] {
|
||||
assert.ok(Array.isArray(value));
|
||||
return value.map(asRecord);
|
||||
}
|
||||
|
||||
function validScene(): JsonRecord {
|
||||
return asRecord(parseStrictJson(decode(corpus.artifacts.scene.base64)));
|
||||
}
|
||||
|
||||
function artifactBlobs(): Map<string, Buffer> {
|
||||
const result = new Map<string, Buffer>();
|
||||
for (const artifact of Object.values(corpus.artifacts)) {
|
||||
if (artifact.uri !== undefined) result.set(artifact.uri, decode(artifact.base64));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validPresentation(): JsonRecord {
|
||||
const value = corpus.presentation_cases.find((item) => item.valid);
|
||||
assert.ok(value !== undefined);
|
||||
return asRecord(parseStrictJson(decode(value.payload_base64)));
|
||||
}
|
||||
|
||||
function utf8Compare(left: string, right: string): number {
|
||||
return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8"));
|
||||
}
|
||||
|
||||
function attachPresentation(scene: JsonRecord, blobs: Map<string, Buffer>): { presentation: JsonRecord; blobs: Map<string, Buffer> } {
|
||||
const presentation = validPresentation();
|
||||
const presentationId = presentation.presentation_id as string;
|
||||
const expectedAppearanceIds = new Map<unknown, string>();
|
||||
const sceneAppearances = asRecords(scene.appearances);
|
||||
for (const authored of asRecords(presentation.appearances)) {
|
||||
const evaluated: JsonRecord = {
|
||||
alpha_mode: authored.alpha_mode,
|
||||
base_color: authored.base_color,
|
||||
double_sided: authored.double_sided,
|
||||
edge_color: authored.edge_color,
|
||||
metallic: authored.metallic,
|
||||
name: authored.name,
|
||||
roughness: authored.roughness,
|
||||
sdk_metadata: {},
|
||||
source: {
|
||||
appearance_name: authored.name,
|
||||
kind: "presentation",
|
||||
presentation_id: presentationId,
|
||||
},
|
||||
};
|
||||
const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(evaluated)).slice("sha256:".length)}`;
|
||||
expectedAppearanceIds.set(authored.name, appearanceId);
|
||||
sceneAppearances.push({ appearance_id: appearanceId, ...evaluated });
|
||||
}
|
||||
sceneAppearances.sort((left, right) => utf8Compare(left.appearance_id as string, right.appearance_id as string));
|
||||
scene.appearances = sceneAppearances;
|
||||
|
||||
const nodeMap = new Map(asRecords(scene.nodes).map((node) => [node.node_id, node]));
|
||||
for (const override of asRecords(presentation.node_overrides)) {
|
||||
const node = nodeMap.get(override.node_id);
|
||||
assert.ok(node !== undefined);
|
||||
if (override.visible !== undefined) node.visible = override.visible;
|
||||
if (override.appearance_name !== undefined) node.appearance_override_id = expectedAppearanceIds.get(override.appearance_name);
|
||||
}
|
||||
scene.cameras = asRecords(presentation.cameras).map((camera) => ({
|
||||
camera_id: `camera/${presentationId}/${String(camera.name)}`,
|
||||
...camera,
|
||||
}));
|
||||
|
||||
const presentationBytes = canonicalJsonBytes(presentation);
|
||||
asRecord(scene.compile_options).embed_presentation = true;
|
||||
scene.presentation_source = {
|
||||
artifact_hash: sha256(presentationBytes),
|
||||
embedded_artifact_byte_length: presentationBytes.length,
|
||||
embedded_artifact_uri: "presentation/presentation.json",
|
||||
presentation_id: presentationId,
|
||||
schema_version: "1.0",
|
||||
};
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
const resultBlobs = new Map(blobs);
|
||||
resultBlobs.set("presentation/presentation.json", presentationBytes);
|
||||
return { presentation, blobs: resultBlobs };
|
||||
}
|
||||
|
||||
function replacePresentation(scene: JsonRecord, blobs: Map<string, Buffer>, payload: Buffer): Map<string, Buffer> {
|
||||
const source = asRecord(scene.presentation_source);
|
||||
source.artifact_hash = sha256(payload);
|
||||
source.embedded_artifact_byte_length = payload.length;
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
const result = new Map(blobs);
|
||||
result.set("presentation/presentation.json", payload);
|
||||
return result;
|
||||
}
|
||||
|
||||
test("package budget formula and first error match Python with declarative limits", () => {
|
||||
const totals = computePackageBudgetTotals({
|
||||
sceneJsonBytes: 7,
|
||||
glbDecodedBufferBytes: 11,
|
||||
entityJsonBytes: 13,
|
||||
otherImmutableJsonBytes: 17,
|
||||
entityCount: 23,
|
||||
entityVertexCount: 19,
|
||||
triangleVertexCount: 29,
|
||||
triangleCount: 31,
|
||||
lineVertexCount: 37,
|
||||
lineSegmentCount: 41,
|
||||
});
|
||||
assert.deepEqual(totals, {
|
||||
staticDecodedBufferBytes: 656,
|
||||
entityCount: 23,
|
||||
triangleVertexTotal: 29,
|
||||
triangleTotal: 31,
|
||||
lineVertexTotal: 37,
|
||||
lineSegmentTotal: 41,
|
||||
});
|
||||
assert.deepEqual(packageBudgetIssues(totals, {
|
||||
static_decoded_buffer_bytes: 656,
|
||||
entities_total: 23,
|
||||
triangle_vertices_total: 29,
|
||||
triangles_total: 31,
|
||||
line_vertices_total: 37,
|
||||
line_segments_total: 41,
|
||||
}), []);
|
||||
|
||||
const result = report(packageBudgetIssues(totals, {
|
||||
static_decoded_buffer_bytes: 655,
|
||||
entities_total: 22,
|
||||
triangle_vertices_total: 28,
|
||||
triangles_total: 30,
|
||||
line_vertices_total: 36,
|
||||
line_segments_total: 40,
|
||||
}), "package");
|
||||
assert.deepEqual(
|
||||
result.firstError === null
|
||||
? null
|
||||
: [result.firstError.code, result.firstError.path, result.firstError.message],
|
||||
["resource_limit_exceeded", "", "total line GLB vertex count exceeds resource limit"],
|
||||
);
|
||||
});
|
||||
|
||||
test("embedded presentation must be canonical and resolve exactly against the scene", () => {
|
||||
const scene = validScene();
|
||||
const attached = attachPresentation(scene, artifactBlobs());
|
||||
const validResult = validateScenePackage(canonicalJsonBytes(scene), attached.blobs);
|
||||
assert.equal(validResult.valid, true, JSON.stringify(validResult.issues));
|
||||
|
||||
const noncanonical = Buffer.from(JSON.stringify(attached.presentation, null, 2));
|
||||
const blobs = replacePresentation(scene, attached.blobs, noncanonical);
|
||||
const result = validateScenePackage(canonicalJsonBytes(scene), blobs);
|
||||
assert.deepEqual(
|
||||
result.firstError === null ? null : [result.firstError.code, result.firstError.path],
|
||||
["noncanonical_json", ""],
|
||||
);
|
||||
});
|
||||
|
||||
test("embedded presentation IDs and scene references are contextual", async (context) => {
|
||||
const cases: [string, (presentation: JsonRecord) => void, string, string][] = [
|
||||
["presentation ID", (presentation) => { presentation.presentation_id = "other"; }, "source_matrix_invalid", "/presentation/presentation.json/presentation_id"],
|
||||
["source scene ID", (presentation) => { presentation.source_scene_id = "other"; }, "source_matrix_invalid", "/presentation/presentation.json/source_scene_id"],
|
||||
["node override", (presentation) => { asRecords(presentation.node_overrides)[0].node_id = "instance/missing"; }, "source_matrix_invalid", "/presentation/presentation.json/node_overrides/0/node_id"],
|
||||
["camera parent", (presentation) => { asRecords(presentation.cameras)[0].parent_node_id = "instance/missing"; }, "source_matrix_invalid", "/presentation/presentation.json/cameras/0/parent_node_id"],
|
||||
];
|
||||
for (const [name, mutate, code, path] of cases) await context.test(name, () => {
|
||||
const scene = validScene();
|
||||
const attached = attachPresentation(scene, artifactBlobs());
|
||||
mutate(attached.presentation);
|
||||
const blobs = replacePresentation(scene, attached.blobs, canonicalJsonBytes(attached.presentation));
|
||||
const result = validateScenePackage(canonicalJsonBytes(scene), blobs);
|
||||
assert.ok(result.issues.some((item) => item.code === code && item.path === path));
|
||||
});
|
||||
});
|
||||
|
||||
test("presentation appearance provenance and camera resolution are exact", () => {
|
||||
const scene = validScene();
|
||||
const attached = attachPresentation(scene, artifactBlobs());
|
||||
const sceneAppearances = asRecords(scene.appearances);
|
||||
const appearance = sceneAppearances.find((item) => item.source !== null && asRecord(item.source).kind === "presentation");
|
||||
assert.ok(appearance !== undefined);
|
||||
asRecord(appearance.source).appearance_name = "Other";
|
||||
const draft = { ...appearance };
|
||||
delete draft.appearance_id;
|
||||
const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(draft)).slice("sha256:".length)}`;
|
||||
appearance.appearance_id = appearanceId;
|
||||
asRecords(scene.nodes)[0].appearance_override_id = appearanceId;
|
||||
sceneAppearances.sort((left, right) => utf8Compare(left.appearance_id as string, right.appearance_id as string));
|
||||
scene.appearances = sceneAppearances;
|
||||
scene.cameras = [];
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
|
||||
const result = validateScenePackage(canonicalJsonBytes(scene), attached.blobs);
|
||||
const appearanceIndex = asRecords(scene.appearances).indexOf(appearance);
|
||||
assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === `/appearances/${appearanceIndex}/source/appearance_name`));
|
||||
assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/presentation/presentation.json/cameras/0"));
|
||||
});
|
||||
|
||||
test("product material provenance must match a Part definition root", () => {
|
||||
const scene = validScene();
|
||||
const appearance = asRecords(scene.appearances)[0];
|
||||
appearance.source = { kind: "product_material", material_id: "steel", root_id: "other" };
|
||||
const draft = { ...appearance };
|
||||
delete draft.appearance_id;
|
||||
const appearanceId = `appearance/evaluated/${sha256(canonicalJsonBytes(draft)).slice("sha256:".length)}`;
|
||||
appearance.appearance_id = appearanceId;
|
||||
asRecords(scene.definitions)[0].appearance_id = appearanceId;
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
|
||||
const result = validateSceneManifest(scene);
|
||||
assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/appearances/0/source/root_id"));
|
||||
});
|
||||
|
||||
test("one URI cannot declare contradictory hash, length, or media roles", () => {
|
||||
const scene = validScene();
|
||||
const edge = asRecords(scene.edge_assets)[0];
|
||||
const oldUri = edge.uri as string;
|
||||
edge.uri = asRecords(scene.geometry_assets)[0].uri;
|
||||
scene.revision = computeSceneRevision(scene);
|
||||
const blobs = artifactBlobs();
|
||||
blobs.delete(oldUri);
|
||||
|
||||
const result = validateScenePackage(canonicalJsonBytes(scene), blobs);
|
||||
assert.ok(result.issues.some((item) => item.code === "package_member_set_invalid"));
|
||||
assert.ok(result.issues.some((item) => item.code === "source_matrix_invalid" && item.path === "/edge_assets/0/uri"));
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { issue, parseStrictJson, report, validateRuleRegistry } from "../src/index.js";
|
||||
|
||||
const contractRoot = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../src/simplecadapi/scene/contracts",
|
||||
);
|
||||
|
||||
function registry(): Record<string, unknown> {
|
||||
return structuredClone(
|
||||
parseStrictJson(readFileSync(resolve(contractRoot, "rules/scene-1.0-rules.json"))),
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function rules(value: Record<string, unknown>): Record<string, unknown>[] {
|
||||
assert.ok(Array.isArray(value.rules));
|
||||
return value.rules as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
test("rule registry rejects malformed shape, duplicates, and wrong ordering", () => {
|
||||
const valid = registry();
|
||||
assert.equal(validateRuleRegistry(valid).size, rules(valid).length);
|
||||
|
||||
const missingPrecedence = registry();
|
||||
delete rules(missingPrecedence)[0].precedence;
|
||||
assert.throws(() => validateRuleRegistry(missingPrecedence), /precedence/);
|
||||
|
||||
const duplicateId = registry();
|
||||
rules(duplicateId)[1].id = rules(duplicateId)[0].id;
|
||||
assert.throws(() => validateRuleRegistry(duplicateId), /duplicate rule ID/);
|
||||
|
||||
const duplicatePrecedence = registry();
|
||||
rules(duplicatePrecedence)[1].precedence = 0;
|
||||
assert.throws(() => validateRuleRegistry(duplicatePrecedence), /duplicate precedence/);
|
||||
|
||||
const wrongOrder = registry();
|
||||
[rules(wrongOrder)[0], rules(wrongOrder)[1]] = [rules(wrongOrder)[1], rules(wrongOrder)[0]];
|
||||
assert.throws(() => validateRuleRegistry(wrongOrder), /ordered by phase and precedence/);
|
||||
|
||||
const wrongIdOrder = registry();
|
||||
[rules(wrongIdOrder)[0].id, rules(wrongIdOrder)[1].id] = [rules(wrongIdOrder)[1].id, rules(wrongIdOrder)[0].id];
|
||||
assert.throws(() => validateRuleRegistry(wrongIdOrder), /unsigned UTF-8 rule-ID order/);
|
||||
});
|
||||
|
||||
test("registered precedence orders artifact reports", () => {
|
||||
const result = report([
|
||||
issue("transform_invalid", "/z", "later registered rule"),
|
||||
issue("bounds_invalid", "/z", "z message"),
|
||||
issue("bounds_invalid", "/z", "a message"),
|
||||
issue("bounds_invalid", "/a", "earlier pointer"),
|
||||
], "scene");
|
||||
assert.deepEqual(result.issues.map(({ code, path, message }) => [code, path, message]), [
|
||||
["bounds_invalid", "/a", "earlier pointer"],
|
||||
["bounds_invalid", "/z", "a message"],
|
||||
["bounds_invalid", "/z", "z message"],
|
||||
["transform_invalid", "/z", "later registered rule"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("artifact reports reject unregistered, wrong-phase, and wrong-artifact issues", () => {
|
||||
assert.throws(
|
||||
() => report([issue("not_registered", "", "no authority")], "scene"),
|
||||
/unregistered/,
|
||||
);
|
||||
assert.throws(
|
||||
() => report([issue("bounds_invalid", "", "wrong phase", "package")], "scene"),
|
||||
/registered phase/,
|
||||
);
|
||||
assert.throws(
|
||||
() => report([issue("presentation_reference_invalid", "", "wrong artifact")], "scene"),
|
||||
/does not apply/,
|
||||
);
|
||||
assert.throws(
|
||||
() => report([issue("duplicate_json_key", "/value", "wrong pointer", "parse")], "scene"),
|
||||
/root pointer/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { BASE_LIMITS, preflightZipBytes } from "../src/index.js";
|
||||
|
||||
function declaredDeflateZip(
|
||||
compressed: Uint8Array,
|
||||
uncompressedSize: number,
|
||||
localUncompressedSize = uncompressedSize,
|
||||
): Buffer {
|
||||
const name = Buffer.from("scene.json", "ascii");
|
||||
const local = Buffer.alloc(30);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(0x0800, 6);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt16LE(0, 10);
|
||||
local.writeUInt16LE(0x0021, 12);
|
||||
local.writeUInt32LE(0, 14);
|
||||
local.writeUInt32LE(compressed.byteLength, 18);
|
||||
local.writeUInt32LE(localUncompressedSize, 22);
|
||||
local.writeUInt16LE(name.length, 26);
|
||||
local.writeUInt16LE(0, 28);
|
||||
|
||||
const centralOffset = local.length + name.length + compressed.byteLength;
|
||||
const central = Buffer.alloc(46);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(0x0314, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(0x0800, 8);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt16LE(0, 12);
|
||||
central.writeUInt16LE(0x0021, 14);
|
||||
central.writeUInt32LE(0, 16);
|
||||
central.writeUInt32LE(compressed.byteLength, 20);
|
||||
central.writeUInt32LE(uncompressedSize, 24);
|
||||
central.writeUInt16LE(name.length, 28);
|
||||
central.writeUInt16LE(0, 30);
|
||||
central.writeUInt16LE(0, 32);
|
||||
central.writeUInt16LE(0, 34);
|
||||
central.writeUInt16LE(0, 36);
|
||||
central.writeUInt32LE(0x81a40000, 38);
|
||||
central.writeUInt32LE(0, 42);
|
||||
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4);
|
||||
eocd.writeUInt16LE(0, 6);
|
||||
eocd.writeUInt16LE(1, 8);
|
||||
eocd.writeUInt16LE(1, 10);
|
||||
eocd.writeUInt32LE(central.length + name.length, 12);
|
||||
eocd.writeUInt32LE(centralOffset, 16);
|
||||
eocd.writeUInt16LE(0, 20);
|
||||
return Buffer.concat([local, name, compressed, central, name, eocd]);
|
||||
}
|
||||
|
||||
function assertError(archive: Uint8Array, expected: string): void {
|
||||
assert.throws(() => preflightZipBytes(archive), (error) => {
|
||||
assert.equal((error as Error).message, expected);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("aggregate ratio uses cross-checked declarations before inflate", () => {
|
||||
const compressed = Buffer.from([0]);
|
||||
const template = declaredDeflateZip(compressed, 0);
|
||||
const exactSize = template.length * BASE_LIMITS.compression_ratio;
|
||||
|
||||
assertError(
|
||||
declaredDeflateZip(compressed, exactSize),
|
||||
'member compression ratio exceeds limit: "scene.json"',
|
||||
);
|
||||
assertError(
|
||||
declaredDeflateZip(compressed, exactSize + 1),
|
||||
"aggregate compression ratio exceeds limit",
|
||||
);
|
||||
assertError(
|
||||
declaredDeflateZip(compressed, exactSize + 1, exactSize),
|
||||
"ZIP central/local header mismatch",
|
||||
);
|
||||
});
|
||||
|
||||
test("zero compressed size uses one as the member ratio denominator", () => {
|
||||
assertError(
|
||||
declaredDeflateZip(Buffer.alloc(0), BASE_LIMITS.compression_ratio),
|
||||
'invalid deflate stream: "scene.json"',
|
||||
);
|
||||
assertError(
|
||||
declaredDeflateZip(Buffer.alloc(0), BASE_LIMITS.compression_ratio + 1),
|
||||
'member compression ratio exceeds limit: "scene.json"',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user