Load a versioned Wikisource jing plus project-authored plain drafts so results can show labeled original and vernacular texts without unauthorized modern translations. Co-authored-by: Cursor <cursoragent@cursor.com>
156 lines
5.7 KiB
JavaScript
156 lines
5.7 KiB
JavaScript
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
contentDigest,
|
|
kingWenEntries,
|
|
validateContentPackage,
|
|
} from "./content-contract.mjs";
|
|
import { repositoryRoot } from "./repository-files.mjs";
|
|
|
|
const schemaPath = path.join(repositoryRoot, "content", "schema", "hexagram-content.schema.json");
|
|
const schema = JSON.parse(await readFile(schemaPath, "utf8"));
|
|
if (schema.$schema !== "https://json-schema.org/draft/2020-12/schema") {
|
|
throw new Error("Content JSON Schema must use draft 2020-12");
|
|
}
|
|
|
|
const kotlinCatalogPath = path.join(
|
|
repositoryRoot,
|
|
"app",
|
|
"src",
|
|
"main",
|
|
"java",
|
|
"net",
|
|
"opcapp",
|
|
"flash",
|
|
"domain",
|
|
"casting",
|
|
"HexagramCatalog.kt",
|
|
);
|
|
const kotlinCatalog = await readFile(kotlinCatalogPath, "utf8");
|
|
const kotlinPairPattern = /TrigramPair\(Trigram\.(\w+), Trigram\.(\w+)\) to (\d+)/gu;
|
|
const kotlinPairs = new Map(
|
|
[...kotlinCatalog.matchAll(kotlinPairPattern)].map((match) => [
|
|
`${match[1]}/${match[2]}`,
|
|
Number(match[3]),
|
|
]),
|
|
);
|
|
const contractEntries = kingWenEntries();
|
|
if (kotlinPairs.size !== 64 || contractEntries.some((entry) =>
|
|
kotlinPairs.get(`${entry.upperTrigram}/${entry.lowerTrigram}`) !== entry.kingWenNumber)) {
|
|
throw new Error("Kotlin King Wen catalog and content-contract lookup must match exactly");
|
|
}
|
|
|
|
function validFixture() {
|
|
return {
|
|
schemaVersion: 1,
|
|
contentVersion: "fixture-only-v1",
|
|
specialUsageTexts: { qian: false, kun: false },
|
|
sources: [{
|
|
id: "fixture-source",
|
|
title: "Automated test fixture",
|
|
edition: "not publishable",
|
|
license: "test data only",
|
|
url: "https://example.invalid/fixture",
|
|
}],
|
|
hexagrams: contractEntries.map((entry) => ({
|
|
...entry,
|
|
name: `fixture-${entry.kingWenNumber}`,
|
|
symbol: `fixture-symbol-${entry.kingWenNumber}`,
|
|
judgmentOriginal: "fixture original text",
|
|
judgmentPlain: "fixture plain text",
|
|
lineTextsBottomUp: Array.from({ length: 6 }, (_, index) => `fixture original line ${index + 1}`),
|
|
linePlainBottomUp: Array.from({ length: 6 }, (_, index) => `fixture plain line ${index + 1}`),
|
|
specialUsageText: null,
|
|
sourceRefs: ["fixture-source"],
|
|
})),
|
|
};
|
|
}
|
|
|
|
function clone(value) {
|
|
return structuredClone(value);
|
|
}
|
|
|
|
const fixture = validFixture();
|
|
const validErrors = validateContentPackage(fixture);
|
|
if (validErrors.length > 0) {
|
|
throw new Error(`Valid content fixture was rejected:\n${validErrors.join("\n")}`);
|
|
}
|
|
|
|
const digest = contentDigest(fixture);
|
|
if (!/^[a-f0-9]{64}$/u.test(digest) || digest !== contentDigest(clone(fixture))) {
|
|
throw new Error("Content digest must be a stable SHA-256 value");
|
|
}
|
|
|
|
const negativeCases = [
|
|
["unsupported schema", (value) => { value.schemaVersion = 2; }, "schemaVersion"],
|
|
["duplicate id", (value) => { value.hexagrams[1].kingWenNumber = 1; }, "unique"],
|
|
["wrong bottom-up pattern", (value) => { value.hexagrams[0].patternBottomUp[0] = "YIN"; }, "bottom-up"],
|
|
["five line texts", (value) => { value.hexagrams[0].lineTextsBottomUp.pop(); }, "exactly six"],
|
|
["unknown source", (value) => { value.hexagrams[0].sourceRefs = ["missing"]; }, "unknown source"],
|
|
["blank license", (value) => { value.sources[0].license = " "; }, "non-blank"],
|
|
["script content", (value) => { value.hexagrams[0].judgmentPlain = "<script>alert(1)</script>"; }, "script-like"],
|
|
["special declaration mismatch", (value) => { value.specialUsageTexts.qian = true; }, "specialUsageTexts.qian"],
|
|
];
|
|
|
|
for (const [name, mutate, expected] of negativeCases) {
|
|
const candidate = clone(fixture);
|
|
mutate(candidate);
|
|
const errors = validateContentPackage(candidate);
|
|
if (!errors.some((error) => error.includes(expected))) {
|
|
throw new Error(`${name} fixture did not fail with '${expected}':\n${errors.join("\n")}`);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`Content contract verification passed (64 entries cross-checked with Kotlin, ${negativeCases.length} negative fixtures, digest ${digest.slice(0, 12)}…).`,
|
|
);
|
|
|
|
const packagePath = path.join(repositoryRoot, "content", "packages", "hexagram-content.json");
|
|
const namesPath = path.join(
|
|
repositoryRoot,
|
|
"app",
|
|
"src",
|
|
"main",
|
|
"java",
|
|
"net",
|
|
"opcapp",
|
|
"flash",
|
|
"core",
|
|
"model",
|
|
"HexagramNames.kt",
|
|
);
|
|
const namesSource = await readFile(namesPath, "utf8");
|
|
const namesBlock = namesSource.match(/private val names = listOf\(([\s\S]*?)\)/u);
|
|
if (!namesBlock) {
|
|
throw new Error("HexagramNames.kt name table could not be parsed");
|
|
}
|
|
const catalogNames = [...namesBlock[1].matchAll(/"([^"]+)"/gu)].map((match) => match[1]);
|
|
if (catalogNames.length !== 64) {
|
|
throw new Error("HexagramNames.kt must contain 64 names");
|
|
}
|
|
|
|
const published = JSON.parse(await readFile(packagePath, "utf8"));
|
|
const publishedErrors = validateContentPackage(published);
|
|
if (publishedErrors.length > 0) {
|
|
throw new Error(`Published content package failed validation:\n${publishedErrors.join("\n")}`);
|
|
}
|
|
published.hexagrams.forEach((hexagram) => {
|
|
const expectedName = catalogNames[hexagram.kingWenNumber - 1];
|
|
const expectedSymbol = String.fromCodePoint(0x4DC0 + hexagram.kingWenNumber - 1);
|
|
if (hexagram.name !== expectedName) {
|
|
throw new Error(`Published package name for ${hexagram.kingWenNumber} must be ${expectedName}`);
|
|
}
|
|
if (hexagram.symbol !== expectedSymbol) {
|
|
throw new Error(`Published package symbol for ${hexagram.kingWenNumber} must be ${expectedSymbol}`);
|
|
}
|
|
});
|
|
if (published.contentVersion !== "zh-Hans-2026.1") {
|
|
throw new Error("Published contentVersion must be zh-Hans-2026.1");
|
|
}
|
|
if (!published.specialUsageTexts.qian || !published.specialUsageTexts.kun) {
|
|
throw new Error("Published package must include Qian 用九 and Kun 用六");
|
|
}
|
|
console.log(
|
|
`Published content package ${published.contentVersion} passed (digest ${contentDigest(published).slice(0, 12)}…).`,
|
|
);
|