import assert from "node:assert/strict"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { test } from "node:test"; import { Ledger, type LedgerRecord } from "../src/ledger/index.ts"; import { hash, type JevJudgment } from "../src/vocab/index.ts"; import { callStart, closeStage, exited, freshLedger, implementation, notStarted, openStage, planRun, proposal, range, tempDir, } from "./ledger-helpers.ts"; // 预期值照 docs/结构.md 第四部分"账本记录"、方法.md 第七部分"什么时候扣次数"和第九部分的文字写: // 整张表禁改禁删;按定义只发生一次的记录重复就报错,其余照写;事务要么全写要么全不写; // 关掉再打开读出不变;表结构版本不对打开报错。 function countRows(path: string): number { const db = new DatabaseSync(path); const n = Number((db.prepare("SELECT count(*) AS n FROM records").get() as { n: number }).n); db.close(); return n; } test("触发器:直接用 SQL 执行 UPDATE、DELETE 都被拒绝,记录不变", (t) => { const { ledger, path } = freshLedger(t); ledger.append("config", { n: 2 }); const before = ledger.records(); const db = new DatabaseSync(path); t.after(() => db.close()); assert.throws(() => db.exec("UPDATE records SET content = '{\"n\":3}'"), /禁止修改/); assert.throws(() => db.exec("UPDATE records SET hash = 'x' WHERE seq = 1"), /禁止修改/); assert.throws(() => db.exec("DELETE FROM records"), /禁止删除/); assert.throws(() => db.exec("DELETE FROM records WHERE seq = 1"), /禁止删除/); assert.deepEqual(ledger.records(), before); }); // 唯一约束(schema.ts):有自己编号、按定义只发生一次的记录,同一个编号写第二条就报错,内容完全相同也报错, // 不多写一条;别的编号照常写。 test("唯一约束:同一个阶段开两次报错", (t) => { const { ledger, path } = freshLedger(t); openStage(ledger, "s1", "proposal", 1); assert.throws(() => openStage(ledger, "s1", "proposal", 1), /stage_open_once/); openStage(ledger, "s2", "proposal", 1); assert.equal(countRows(path), 2); }); test("唯一约束:同一个阶段关两次报错", (t) => { const { ledger, path } = freshLedger(t); openStage(ledger, "s1", "proposal", 1); closeStage(ledger, "s1"); assert.throws(() => closeStage(ledger, "s1"), /stage_close_once/); assert.equal(countRows(path), 2); }); test("唯一约束:同一次运行登记两次报错", (t) => { const { ledger, path } = freshLedger(t); planRun(ledger, "run1", "forward", 1); assert.throws(() => planRun(ledger, "run1", "forward", 1), /run_planned_once/); planRun(ledger, "run2", "forward", 1); assert.equal(countRows(path), 2); }); test("唯一约束:同一次运行确认启动两次报错", (t) => { const { ledger, path } = freshLedger(t); planRun(ledger, "run1", "forward", 1); ledger.append("runStarted", { run: "run1" }); assert.throws(() => ledger.append("runStarted", { run: "run1" }), /run_started_or_not_started_once/); assert.equal(countRows(path), 2); }); test("唯一约束:确认启动和\"结果是没启动\"算同一条,一次运行只能有一样,两种先后都报错", (t) => { // 登记→确认启动→结果没启动、登记→结果没启动→确认启动,第三步都报错:一次运行不能既扣次又退还(方法.md 第七部分)。 const { ledger, path } = freshLedger(t); planRun(ledger, "run1", "forward", 1); ledger.append("runStarted", { run: "run1" }); assert.throws(() => ledger.append("runResult", { run: "run1", process: notStarted, judged: null }), /run_started_or_not_started_once/); planRun(ledger, "run2", "forward", 1); ledger.append("runResult", { run: "run2", process: notStarted, judged: null }); assert.throws(() => ledger.append("runStarted", { run: "run2" }), /run_started_or_not_started_once/); // 正常情况:确认启动后写启动过的结果。 ledger.append("runResult", { run: "run1", process: exited, judged: null }); assert.equal(countRows(path), 5); // 两条登记、一条确认启动、两条结果 }); test("唯一约束:同一次运行写两个结果报错", (t) => { const { ledger, path } = freshLedger(t); planRun(ledger, "run1", "forward", 1); ledger.append("runStarted", { run: "run1" }); ledger.append("runResult", { run: "run1", process: exited, judged: null }); assert.throws(() => ledger.append("runResult", { run: "run1", process: exited, judged: null }), /run_result_once/); assert.equal(countRows(path), 3); }); test("唯一约束:同一次模型调用开始两次报错", (t) => { const { ledger, path } = freshLedger(t); ledger.append("callStarted", callStart("k1")); assert.throws(() => ledger.append("callStarted", callStart("k1")), /call_started_once/); ledger.append("callStarted", callStart("k2")); assert.equal(countRows(path), 2); }); test("唯一约束:同一次模型调用结束两次报错", (t) => { const { ledger, path } = freshLedger(t); ledger.append("callStarted", callStart("k1")); ledger.append("callEnded", { call: "k1", wallSeconds: { status: "measured", value: 12.5 }, status: "submitted" }); assert.throws( () => ledger.append("callEnded", { call: "k1", wallSeconds: { status: "measured", value: 3 }, status: "failed", reason: "又报了一次" }), /call_ended_once/, ); assert.equal(countRows(path), 2); }); test("唯一约束:同一轮结算两次报错,不同轮各一次照写;结算不带消融编号", (t) => { // 每轮最多采纳一个改法,所以最多一次消融(docs/结构.md 第四部分):结算按轮次只发生一次。 const { ledger, path } = freshLedger(t); ledger.append("settlement", { round: 1, result: { cells: { p1: "valid" } } }); assert.throws(() => ledger.append("settlement", { round: 1, result: { cells: { p1: "insufficient" } } }), /settlement_once/); ledger.append("settlement", { round: 2, result: { cells: { p1: "insufficient" } } }); ledger.append("settlement", { round: 3, result: { cells: { p1: "valid" } } }); assert.throws(() => ledger.append("settlement", { ablation: "a1", result: {} } as never), /settlement/); assert.throws(() => ledger.append("settlement", { round: 4, ablation: "a4", result: {} } as never), /settlement/); assert.equal(countRows(path), 3); }); test("私人笔记:同一人写两条都存下,以后写的(序号大的)为准;笔记不带版本号", (t) => { // 笔记不属于任何阶段,以最后写入的一条为准,序号就是先后(docs/结构.md 第四部分)。 // 后写的正文按字典序排在前面,中间夹着别人的笔记:取"序号最大的那条"才得到后写的。 const { ledger, path } = freshLedger(t); ledger.append("note", { author: "r1", body: "z 先写的" }); ledger.append("note", { author: "r2", body: "别人的笔记" }); ledger.append("note", { author: "r1", body: "a 后写的" }); const mine = ledger .records() .filter((r): r is LedgerRecord<"note"> => r.kind === "note") .filter((r) => r.content.author === "r1"); assert.deepEqual(mine.map((r) => [r.seq, r.content.body]), [[1, "z 先写的"], [3, "a 后写的"]]); assert.equal(mine.at(-1)!.content.body, "a 后写的"); assert.throws(() => ledger.append("note", { author: "r1", version: 2, body: "带版本号" } as never), /note/); assert.equal(countRows(path), 3); }); test("没有唯一约束的记录照写:同样的事件、决定写两次就是两条", (t) => { const { ledger, path } = freshLedger(t); const e = { event: "untested", reason: "剩余次数不够", objects: ["cell3"] }; const first = ledger.append("event", e); const second = ledger.append("event", e); assert.equal(second.seq, first.seq + 1); const d = { decision: "batch", round: 1, rule: "bestExpected", ruleVersion: 1, params: {}, seed: null, input: { x: 1 }, output: ["c1"] }; ledger.append("decision", d); ledger.append("decision", d); assert.equal(countRows(path), 4); }); test("内容不符合定义时不写入:多出的字段、提交内容不合格、提交编号 item 和提交种类对不上", (t) => { const { ledger, path } = freshLedger(t); openStage(ledger, "s1", "wrapUp", 1); assert.throws(() => ledger.append("runStarted", { run: "r1", extra: 1 } as never), /runStarted/); assert.throws( () => ledger.append("submission", { stageId: "s1", author: "r1", target: null, item: null, version: 1, jev: null, kind: "wrapUp", content: { body: "" } }), /wrapUp/, ); // 提案、挑刺一人能交好几份,必须带编号;实现一人对一个候选只交一份,编号必须为空, // 否则另起一个编号就能绕开"实现不能替换"。 openStage(ledger, "prop", "proposal", 1); assert.throws( () => ledger.append("submission", { stageId: "prop", author: "r1", target: null, item: null as never, version: 1, jev: null, kind: "proposal", content: proposal(range(1, 2, 3)) }), /proposal/, ); openStage(ledger, "impl", "implementation", 1, ["e1"]); assert.throws( () => ledger.append("submission", { stageId: "impl", author: "e1", target: "c1", item: "x" as never, version: 1, jev: null, kind: "implementation", content: implementation() }), /implementation/, ); assert.equal(countRows(path), 3); // 三条开阶段 }); test("写提案:作者自评拆成一条封存,提案记录里不带它", (t) => { const { ledger } = freshLedger(t); openStage(ledger, "s1", "proposal", 1); const record = ledger.append("submission", { stageId: "s1", author: "r1", target: null, item: "r1-1", version: 1, kind: "proposal", content: proposal(range(1, 2, 3)), jev: null, }); const all = ledger.records(); const stored = all.find((r) => r.kind === "submission")!; assert.equal(JSON.stringify(stored.content).includes("authorEstimate"), false); assert.equal(JSON.stringify(record.content).includes("authorEstimate"), false); const { authorEstimate: _, ...withoutEstimate } = proposal(range(1, 2, 3)); assert.deepEqual(stored.content.content, withoutEstimate); const seal = all.find((r) => r.kind === "seal")!; assert.deepEqual(seal.content, { proposal: record.seq, range: range(1, 2, 3) }); assert.equal(seal.seq, record.seq + 1); }); // Jev 判断的样子照 src/vocab/jev.ts 的 JevJudgment 手写:问题名、问题全文哈希(64 位十六进制)、阈值、原始概率、结论、请求编号。 const judgment = (question: string, probability: number, threshold: number, requestId: string): JevJudgment => ({ question, questionHash: "ab".repeat(32), threshold, probability, yes: probability > threshold, requestId, }); test("退回:被退回的内容原样存;程序检查退回的 jev 为 null,Jev 退回的原样存它用到的几条判断", (t) => { const path = join(tempDir(t), "ledger.db"); const ledger = Ledger.open(path); const malformed = { factors: "不是数组" }; // 冻结条款一次问三问,退回时三条判断都存,包括判"否"的两条。 const clauses = [ judgment("changes_measurement", 0.97, 0.5, "req-1"), judgment("self_contradictory", 0.02, 0.6, "req-2"), judgment("too_vague", 0.1, 0.7, "req-3"), ]; ledger.append("rejection", { stageId: "s1", author: "r1", kind: "proposal", submitted: malformed, reason: "因子清单格式不对", jev: null }); ledger.append("rejection", { stageId: "s6", author: "r2", kind: "response", submitted: { body: "改了评测" }, reason: "冻结条款改动了评测", jev: clauses, }); ledger.close(); const reopened = Ledger.open(path); t.after(() => reopened.close()); const rejections = reopened.records().filter((r) => r.kind === "rejection"); assert.deepEqual(rejections.map((r) => r.content.submitted), [malformed, { body: "改了评测" }]); assert.equal(rejections[0]!.content.jev, null); assert.deepEqual(rejections[1]!.content.jev, [ { question: "changes_measurement", questionHash: "ab".repeat(32), threshold: 0.5, probability: 0.97, yes: true, requestId: "req-1" }, { question: "self_contradictory", questionHash: "ab".repeat(32), threshold: 0.6, probability: 0.02, yes: false, requestId: "req-2" }, { question: "too_vague", questionHash: "ab".repeat(32), threshold: 0.7, probability: 0.1, yes: false, requestId: "req-3" }, ]); }); test("退回:jev 不合 JevJudgment 的拒收,一条都不写", (t) => { const { ledger, path } = freshLedger(t); const good = judgment("same_change", 0.9, 0.5, "req-1"); const { requestId: _, ...withoutRequest } = good; const bad: unknown[] = [ [], // 空数组:Jev 退回至少有一条判断,程序退回写 null { probabilities: { yes: 0.97, no: 0.03 }, conclusion: "changesEvaluation" }, // 旧写法:不是数组 good, // 一条判断也要放进数组 [{ ...good, probability: 1.2 }], // 概率超出 0 到 1 [{ ...good, threshold: -0.1 }], // 阈值超出 0 到 1 [withoutRequest], // 缺请求编号 [{ ...good, requestId: "" }], // 请求编号为空 [{ ...good, questionHash: "abc" }], // 哈希不是 64 位十六进制 [{ ...good, question: "Same Change" }], // 问题名格式不对 [{ ...good, yes: "true" }], // 结论不是布尔值 [{ ...good, note: "多写一项" }], // 不认识的字段 ]; for (const jev of bad) { assert.throws( () => ledger.append("rejection", { stageId: "s1", author: "r1", kind: "proposal", submitted: {}, reason: "判为同一个改法", jev } as never), /账本记录"rejection"的内容不符合定义/, JSON.stringify(jev), ); } assert.equal(countRows(path), 0); }); test("模型调用结束:四种结局各存一条,墙钟时间(秒)测到带值、测不到带原因,关掉再打开读出不变", (t) => { const path = join(tempDir(t), "ledger.db"); const ledger = Ledger.open(path); const endings = [ { call: "k1", wallSeconds: { status: "measured", value: 812.25 }, status: "submitted" }, { call: "k2", wallSeconds: { status: "measured", value: 95 }, status: "notSubmitted", reason: "模型自己停下了,一份都没交" }, { call: "k3", wallSeconds: { status: "unmeasured", reason: "进程中断" }, status: "failed", reason: "进程中断,恢复时补写" }, { call: "k4", wallSeconds: { status: "measured", value: 0 }, status: "stopped" }, ] as const; for (const e of endings) { ledger.append("callStarted", callStart(e.call)); ledger.append("callEnded", e); } ledger.close(); const reopened = Ledger.open(path); t.after(() => reopened.close()); const ended = reopened.records().filter((r) => r.kind === "callEnded").map((r) => r.content); assert.deepEqual(ended, [ { call: "k1", wallSeconds: { status: "measured", value: 812.25 }, status: "submitted" }, { call: "k2", wallSeconds: { status: "measured", value: 95 }, status: "notSubmitted", reason: "模型自己停下了,一份都没交" }, { call: "k3", wallSeconds: { status: "unmeasured", reason: "进程中断" }, status: "failed", reason: "进程中断,恢复时补写" }, { call: "k4", wallSeconds: { status: "measured", value: 0 }, status: "stopped" }, ]); assert.deepEqual(reopened.pending().calls, []); }); test("模型调用结束:结局和原因对不上、墙钟时间缺失或不合 Measured、测到的值为负,都拒收", (t) => { const { ledger, path } = freshLedger(t); const s3 = { status: "measured", value: 3 }; const bad: unknown[] = [ { call: "k1", wallSeconds: s3, status: "notSubmitted" }, // 没交要带原因 { call: "k1", wallSeconds: s3, status: "failed" }, // 出错要带原因 { call: "k1", wallSeconds: s3, status: "failed", reason: "" }, // 原因不能为空 { call: "k1", wallSeconds: s3, status: "submitted", reason: "交了" }, // 交了不带原因 { call: "k1", wallSeconds: s3, status: "stopped", reason: "用户停下" }, // 被停下不带原因 { call: "k1", wallSeconds: s3, status: "completed" }, // 旧写法 { call: "k1", status: "submitted" }, // 缺墙钟时间 { call: "k1", wallSeconds: 3, status: "submitted" }, // 裸数字,不是 Measured { call: "k1", wallSeconds: null, status: "submitted" }, // 测不到不能写 null { call: "k1", wallSeconds: { status: "measured", value: "3" }, status: "submitted" }, // 值不是数 { call: "k1", wallSeconds: { status: "measured" }, status: "submitted" }, // 测到却没带值 { call: "k1", wallSeconds: { status: "unmeasured" }, status: "failed", reason: "进程中断" }, // 测不到却没带原因 { call: "k1", wallSeconds: { status: "unmeasured", reason: "" }, status: "failed", reason: "进程中断" }, // 原因为空 { call: "k1", wallSeconds: { status: "unmeasured", reason: "进程中断", value: 0 }, status: "failed", reason: "进程中断" }, // 测不到又带值 ]; for (const content of bad) { assert.throws(() => ledger.append("callEnded", content as never), /账本记录"callEnded"的内容不符合定义/, JSON.stringify(content)); } assert.equal(countRows(path), 0); }); test("提交:jev 在外层与 content 并列,null 或 JevJudgment 数组都能存能读;提案拆封存不受影响", (t) => { const path = join(tempDir(t), "ledger.db"); const ledger = Ledger.open(path); openStage(ledger, "s1", "proposal", 1); openStage(ledger, "s9", "implementation", 1); // 代码改动五问都判"否"才收下,五条判断连同原始概率都存。 const checks = [ judgment("unseeded_randomness", 0.01, 0.5, "req-1"), judgment("reads_clock", 0.02, 0.5, "req-2"), judgment("touches_measurement", 0.2, 0.6, "req-3"), judgment("early_exit", 0.05, 0.5, "req-4"), judgment("zero_for_unmeasured", 0.3, 0.7, "req-5"), ]; const p = ledger.append("submission", { stageId: "s1", author: "r1", target: null, item: "r1-1", version: 1, kind: "proposal", content: proposal(range(1, 2, 3)), jev: null }); ledger.append("submission", { stageId: "s9", author: "e1", target: "c1", item: null, version: 1, kind: "implementation", content: implementation(), jev: checks }); ledger.close(); const reopened = Ledger.open(path); t.after(() => reopened.close()); const all = reopened.records(); const subs = all.filter((r) => r.kind === "submission"); assert.deepEqual(subs.map((r) => r.content.jev), [ null, [ { question: "unseeded_randomness", questionHash: "ab".repeat(32), threshold: 0.5, probability: 0.01, yes: false, requestId: "req-1" }, { question: "reads_clock", questionHash: "ab".repeat(32), threshold: 0.5, probability: 0.02, yes: false, requestId: "req-2" }, { question: "touches_measurement", questionHash: "ab".repeat(32), threshold: 0.6, probability: 0.2, yes: false, requestId: "req-3" }, { question: "early_exit", questionHash: "ab".repeat(32), threshold: 0.5, probability: 0.05, yes: false, requestId: "req-4" }, { question: "zero_for_unmeasured", questionHash: "ab".repeat(32), threshold: 0.7, probability: 0.3, yes: false, requestId: "req-5" }, ], ]); assert.equal("jev" in (subs[1]!.content.content as object), false); const seal = all.find((r) => r.kind === "seal")!; assert.deepEqual(seal.content, { proposal: p.seq, range: range(1, 2, 3) }); assert.equal(seal.seq, p.seq + 1); }); test("提交:缺 jev、jev 不合 JevJudgment 数组、jev 写进 content 里,都拒收", (t) => { const { ledger, path } = freshLedger(t); openStage(ledger, "s1", "wrapUp", 1); const good = judgment("same_change", 0.1, 0.5, "req-1"); const base = { stageId: "s1", author: "r1", target: null, item: null, version: 1, kind: "wrapUp", content: { body: "收尾" } }; const bad: unknown[] = [ base, // 缺 jev:没用到 Jev 要写 null,不靠省略 { ...base, jev: [] }, // 空数组 { ...base, jev: good }, // 一条也要放进数组 { ...base, jev: [{ ...good, probability: -0.1 }] }, // 概率超出 0 到 1 { ...base, jev: [{ ...good, extra: 1 }] }, // 不认识的字段 { ...base, jev: null, content: { body: "收尾", jev: null } }, // jev 不进 content ]; for (const content of bad) { assert.throws(() => ledger.append("submission", content as never), /账本记录"submission(提交种类 wrapUp)"的内容不符合定义/, JSON.stringify(content)); } assert.equal(countRows(path), 1); }); test("事务:中途出错,一条都没写进去;中途被拒收,前面写的也撤销;正常结束则全部写进去", (t) => { const { ledger, path } = freshLedger(t); assert.throws( () => ledger.transaction(() => { planRun(ledger, "run1", "forward", 1); ledger.append("runStarted", { run: "run1" }); throw new Error("算到一半出错"); }), /算到一半出错/, ); assert.equal(countRows(path), 0); assert.throws( () => ledger.transaction(() => { planRun(ledger, "run9", "forward", 1); ledger.append("runResult", { run: "run9", process: exited, judged: null }); }), /确认启动/, ); assert.equal(countRows(path), 0); ledger.transaction(() => { planRun(ledger, "run1", "forward", 1); ledger.append("runStarted", { run: "run1" }); }); assert.equal(countRows(path), 2); }); test("事务:异步函数被类型检查挡住", () => { // 只给 npm run check 看,不执行:await 之后的写入发生在事务提交之后,回滚管不到。 const typeOnly = (ledger: Ledger) => // @ts-expect-error 事务函数的返回值类型排除了 Promise。 ledger.transaction(async () => { ledger.append("config", { n: 1 }); }); assert.equal(typeof typeOnly, "function"); }); test("关掉再打开:读出的记录不变,序号接着往下排", (t) => { const path = join(tempDir(t), "ledger.db"); const ledger = Ledger.open(path); ledger.append("config", { researchers: ["r1", "r2"] }); openStage(ledger, "s1", "proposal", 1); ledger.append("submission", { stageId: "s1", author: "r1", target: null, item: "r1-1", version: 1, jev: null, kind: "proposal", content: proposal(range(1, 2, 3)) }); ledger.append("note", { author: "r2", body: "笔记" }); closeStage(ledger, "s1"); planRun(ledger, "run1", "forward", 1); ledger.append("callStarted", callStart("k1")); const before = ledger.records(); ledger.close(); const reopened = Ledger.open(path); t.after(() => reopened.close()); assert.deepEqual(reopened.records(), before); assert.equal(before.length, 8); // 配置、开阶段、提案、封存、笔记、关阶段、登记运行、调用开始 assert.deepEqual(before.map((r) => r.hash), before.map((r) => hash(r.content))); assert.equal(reopened.append("runStarted", { run: "run1" }).seq, 9); }); test("打开:表结构版本不对报错;不是空文件也不是账本的库报错", (t) => { const dir = tempDir(t); const wrongVersion = join(dir, "v99.db"); const db = new DatabaseSync(wrongVersion); db.exec("PRAGMA user_version = 99"); db.close(); assert.throws(() => Ledger.open(wrongVersion), /版本是 99/); // 版本 2 是"模型调用结束"还没有墙钟时间、退回还没有 Jev 判断数组时的账本,不迁移,打开报错。 const previous = join(dir, "v2.db"); const v2 = new DatabaseSync(previous); v2.exec("PRAGMA user_version = 2"); v2.close(); assert.throws(() => Ledger.open(previous), /版本是 2.*只认版本 3/s); const foreign = join(dir, "foreign.db"); const other = new DatabaseSync(foreign); other.exec("CREATE TABLE t (x INTEGER)"); other.close(); assert.throws(() => Ledger.open(foreign), /版本是 0/); }); test("打开:不是账本的库报错后,那个库的 journal_mode 不变", (t) => { const foreign = join(tempDir(t), "foreign.db"); const other = new DatabaseSync(foreign); other.exec("CREATE TABLE t (x INTEGER)"); const modeOf = (db: DatabaseSync) => (db.prepare("PRAGMA journal_mode").get() as { journal_mode: string }).journal_mode; const before = modeOf(other); other.close(); assert.notEqual(before, "wal"); assert.throws(() => Ledger.open(foreign), /版本是 0/); const reopened = new DatabaseSync(foreign); t.after(() => reopened.close()); assert.equal(modeOf(reopened), before); }); test("读出时按种类检查内容:绕过 ledger 插进去的坏记录,读取时报错", (t) => { const { ledger, path } = freshLedger(t); const db = new DatabaseSync(path); t.after(() => db.close()); db.exec(`INSERT INTO records (kind, content, hash) VALUES ('runStarted', '{"run":"run1","x":1}', 'h')`); assert.throws(() => ledger.records(), /runStarted/); }); test("查询:阶段关了没有、某份提交的最新一版(同一人对同一候选的另一份挑刺不算这一份的版本)", (t) => { const { ledger } = freshLedger(t); openStage(ledger, "s1", "critique", 1); assert.equal(ledger.stageClosed("s1"), false); const slot = { stageId: "s1", author: "r1", kind: "critique" as const, target: "c1", item: "k1" }; assert.equal(ledger.latestSubmission(slot), undefined); ledger.append("submission", { ...slot, version: 1, jev: null, content: { target: "whole", body: "第一版" } }); const v2 = ledger.append("submission", { ...slot, version: 2, jev: null, content: { target: "whole", body: "第二版" } }); ledger.append("submission", { ...slot, item: "k2", version: 1, jev: null, content: { target: "p1", body: "另一份" } }); assert.deepEqual(ledger.latestSubmission(slot), v2); closeStage(ledger, "s1"); assert.equal(ledger.stageClosed("s1"), true); });