Files
InfoRSI/tests/workspace.test.ts
T
iomgaa 852600df02 feat: 诊断(任务书 18 第一块):诊断数学、四种提交、一次运行跑多个实验、账本与材料、flow 诊断各步
消融默认关,关掉时解释阶段一并跳过;选诊断实验用信息价值减成本。
eval/diagnosis/research.json 为第三块配置。vocab 字段说明的出口文字随提示词一起改,见下一次提交的 prompts/ 与同文件改动(本次含在内)。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 10:36:20 -04:00

283 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test, type TestContext } from "node:test";
import { checkoutVersion, compareVersions, createRepository, saveVersion, type FileChange } from "../src/workspace/index.ts";
// workspace 的测试(任务书 06 第五部分),用真实 git,仓库建在每个测试自己的临时目录里。
// 行号、目录树编号等预期值都是手算或按 git 公开的对象格式独立算的,不调实现。
function tempDir(t: TestContext, name: string): string {
const dir = mkdtempSync(join(tmpdir(), `inforsi-workspace-${name}-`));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
function newRepo(t: TestContext): string {
const repo = join(tempDir(t, "repo"), "versions.git");
createRepository(repo);
return repo;
}
/** 按 { 相对路径: 内容 } 建一个目录;路径以 * 结尾的文件设成可执行。 */
function makeDir(t: TestContext, files: Record<string, string | Buffer>): string {
const dir = tempDir(t, "code");
for (const [name, content] of Object.entries(files)) {
const path = join(dir, name.replace(/\*$/, ""));
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
chmodSync(path, name.endsWith("*") ? 0o755 : 0o644);
}
return dir;
}
function save(repo: string, dir: string, excluded: string[]): string {
const outcome = saveVersion(repo, dir, excluded);
assert.ok(outcome.saved, JSON.stringify(outcome));
return outcome.version;
}
function checkout(t: TestContext, repo: string, version: string): string {
const dir = tempDir(t, "out");
checkoutVersion(repo, version, dir);
return dir;
}
/** 测试自己调 git 查仓库,同样不读用户配置。 */
function gitIn(repo: string, args: string[], input: string): { status: number | null; stdout: string; stderr: string } {
return spawnSync("git", args, {
encoding: "utf8",
input,
env: { ...process.env, GIT_DIR: repo, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" },
});
}
function sha1(data: Buffer): Buffer {
return createHash("sha1").update(data).digest();
}
/** 按 git 公开的对象格式独立算一个只含文件、不含子目录的目录树编号。 */
function flatTreeId(files: Array<[name: string, mode: string, content: string]>): string {
const entries = [...files]
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([name, mode, content]) => {
const blob = Buffer.from(content);
const blobId = sha1(Buffer.concat([Buffer.from(`blob ${blob.length}\0`), blob]));
return Buffer.concat([Buffer.from(`${mode} ${name}\0`), blobId]);
});
const body = Buffer.concat(entries);
return sha1(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).toString("hex");
}
// ---------------------------------------------------------------------------
// 存版本与取出
// ---------------------------------------------------------------------------
test("同内容同号:存两次、复制一份改了文件时间再存,都是同一个号,且等于独立算出的目录树编号", (t) => {
const repo = newRepo(t);
const dir = makeDir(t, { "run.sh*": "#!/bin/sh\necho hi\n", "main.py": "print(1)\n" });
const first = save(repo, dir, []);
assert.equal(save(repo, dir, []), first);
const copy = join(tempDir(t, "copy"), "code");
cpSync(dir, copy, { recursive: true });
utimesSync(join(copy, "main.py"), new Date("2001-01-01"), new Date("2001-01-01"));
assert.equal(save(repo, copy, []), first);
assert.equal(first, flatTreeId([["main.py", "100644", "print(1)\n"], ["run.sh", "100755", "#!/bin/sh\necho hi\n"]]));
});
test("取出再存回来,号不变:内容逐字节、可执行位都还原,取出的目录里没有 .git", (t) => {
const repo = newRepo(t);
const binary = Buffer.from([0, 1, 2, 255, 13, 10]);
const dir = makeDir(t, { "a.py": "x = 1\r\ny = 2\n", "lib/b.py*": "#!/usr/bin/env python\n", "lib/deep/data.bin": binary, "empty.txt": "" });
const version = save(repo, dir, []);
const out = checkout(t, repo, version);
assert.deepEqual(readFileSync(join(out, "a.py")), Buffer.from("x = 1\r\ny = 2\n"));
assert.deepEqual(readFileSync(join(out, "lib/deep/data.bin")), binary);
assert.equal(readFileSync(join(out, "empty.txt"), "utf8"), "");
assert.equal(statSync(join(out, "lib/b.py")).mode & 0o100, 0o100);
assert.equal(statSync(join(out, "a.py")).mode & 0o100, 0);
assert.equal(existsSync(join(out, ".git")), false);
assert.equal(save(repo, out, []), version);
});
test(".gitignore 不起作用:写了 *.json,cfg.json 照样存进去", (t) => {
const repo = newRepo(t);
const dir = makeDir(t, { ".gitignore": "*.json\n", "cfg.json": "{}\n", "main.py": "pass\n" });
const out = checkout(t, repo, save(repo, dir, []));
assert.equal(readFileSync(join(out, "cfg.json"), "utf8"), "{}\n");
assert.equal(readFileSync(join(out, ".gitignore"), "utf8"), "*.json\n");
});
test("树里的 .gitattributes 不起作用:取出时不丢文件、不改换行符", (t) => {
const repo = newRepo(t);
const dir = makeDir(t, { ".gitattributes": "*.py export-ignore\n*.txt text eol=crlf\n", "a.py": "pass\n", "b.txt": "x\n" });
const out = checkout(t, repo, save(repo, dir, []));
assert.equal(readFileSync(join(out, "a.py"), "utf8"), "pass\n");
assert.equal(readFileSync(join(out, "b.txt"), "utf8"), "x\n");
});
test("不读用户配置:HOME 里有改行为的 git 配置和 attributes,存、取、比较的结果都不变", (t) => {
const files = { "a.py": "x = 1\r\ny = 2\n", "run.sh*": "#!/bin/sh\n", "数据.py": "1\n" };
const changed = { "a.py": "x = 1\r\ny = 3\n", "run.sh*": "#!/bin/sh\n", "数据.py": "2\n" };
const repo = newRepo(t);
const cleanFrom = save(repo, makeDir(t, files), []);
const cleanTo = save(repo, makeDir(t, changed), []);
const clean = compareVersions(repo, cleanFrom, cleanTo);
// 只在这个测试里把 HOME 换成一份"有敌意"的配置;git 子进程继承 process.env。
const home = tempDir(t, "home");
writeFileSync(
join(home, ".gitconfig"),
"[core]\n\tautocrlf = true\n\tfileMode = false\n\tabbrev = 20\n\tquotePath = false\n[diff]\n\tnoprefix = true\n\talgorithm = histogram\n\tcontext = 10\n[color]\n\tui = always\n",
);
mkdirSync(join(home, ".config", "git"), { recursive: true });
writeFileSync(join(home, ".config", "git", "attributes"), "*.py binary\n* text eol=crlf\n");
const saved = { HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME };
process.env.HOME = home;
delete process.env.XDG_CONFIG_HOME;
t.after(() => {
process.env.HOME = saved.HOME;
if (saved.XDG_CONFIG_HOME !== undefined) process.env.XDG_CONFIG_HOME = saved.XDG_CONFIG_HOME;
});
// 先确认这份配置对不加保护的 git 确实起作用,测试才有意义。
assert.equal(spawnSync("git", ["config", "--global", "core.autocrlf"], { encoding: "utf8" }).stdout, "true\n");
const hostileRepo = newRepo(t);
const from = save(hostileRepo, makeDir(t, files), []);
const to = save(hostileRepo, makeDir(t, changed), []);
assert.equal(from, cleanFrom);
assert.equal(to, cleanTo);
const out = checkout(t, hostileRepo, from);
assert.deepEqual(readFileSync(join(out, "a.py")), Buffer.from("x = 1\r\ny = 2\n"));
const hostile = compareVersions(hostileRepo, from, to);
assert.deepEqual(hostile, clean);
assert.match(hostile.diff, /^diff --git a\/a\.py b\/a\.py$/m);
assert.doesNotMatch(hostile.diff, /\x1b/);
// 编号缩写取 git 的默认长度,不是配置里的 20 位;非 ASCII 路径照默认加引号转义。
assert.match(hostile.diff, /^index [0-9a-f]{7}\.\.[0-9a-f]{7} 100644$/m);
assert.match(hostile.diff, /^diff --git "a\/\\346/m);
});
test("不存清单:清单里的名字在顶层和子目录里都跳过", (t) => {
const repo = newRepo(t);
const dir = makeDir(t, {
"main.py": "pass\n",
"solution.json": "{}",
"__pycache__/main.cpython-313.pyc": "x",
".git/HEAD": "ref: refs/heads/main\n",
"lib/util.py": "pass\n",
"lib/__pycache__/util.cpython-313.pyc": "x",
"lib/metrics.json": "{}",
});
const excluded = ["__pycache__", ".git", "solution.json", "metrics.json"];
const listed = gitIn(repo, ["ls-tree", "-r", "--name-only", save(repo, dir, excluded)], "");
assert.equal(listed.stdout, "lib/util.py\nmain.py\n");
});
test("存不了:符号链接、命名管道交回存不了和那个路径", (t) => {
const repo = newRepo(t);
const linked = makeDir(t, { "main.py": "pass\n", "lib/real.py": "pass\n" });
symlinkSync("real.py", join(linked, "lib", "link.py"));
const outcome = saveVersion(repo, linked, []);
assert.equal(outcome.saved, false);
assert.ok(!outcome.saved);
assert.equal(outcome.path, "lib/link.py");
assert.match(outcome.reason, /符号链接/);
const piped = makeDir(t, { "main.py": "pass\n" });
assert.equal(spawnSync("mkfifo", [join(piped, "pipe")]).status, 0);
assert.deepEqual(saveVersion(repo, piped, []), { saved: false, path: "pipe", reason: "既不是普通文件也不是目录,代码版本只存普通文件和目录" });
// 在清单里的名字不看类型,直接跳过。
assert.ok(saveVersion(repo, piped, ["pipe"]).saved);
});
test("引用防清理:git gc --prune=now 之后版本照样能取出,没挂引用的对象被清掉", (t) => {
const repo = newRepo(t);
const version = save(repo, makeDir(t, { "main.py": "keep me\n" }), []);
const orphan = gitIn(repo, ["hash-object", "-w", "--stdin"], "nobody points at me\n").stdout.trim();
assert.equal(gitIn(repo, ["cat-file", "-e", orphan], "").status, 0);
assert.equal(gitIn(repo, ["gc", "--prune=now", "--quiet"], "").status, 0);
assert.notEqual(gitIn(repo, ["cat-file", "-e", orphan], "").status, 0);
const out = checkout(t, repo, version);
assert.equal(readFileSync(join(out, "main.py"), "utf8"), "keep me\n");
});
// ---------------------------------------------------------------------------
// 比较两个版本
// ---------------------------------------------------------------------------
test("比较:新增、删除、修改、只改可执行位、二进制,各一个文件", (t) => {
const repo = newRepo(t);
const from = save(
repo,
makeDir(t, { "gone.py": "a\nb\n", "edit.py": "one\ntwo\n", "tool.sh": "#!/bin/sh\n", "data.bin": Buffer.from([0, 1, 2]), "same.py": "x\n" }),
[],
);
const to = save(
repo,
makeDir(t, { "new/added.py": "p\nq\n", "edit.py": "one\nTWO\n", "tool.sh*": "#!/bin/sh\n", "data.bin": Buffer.from([0, 1, 3]), "same.py": "x\n" }),
[],
);
const expected: FileChange[] = [
{ path: "data.bin", change: "modified", modeOnly: false, binary: true },
{ path: "edit.py", change: "modified", modeOnly: false, binary: false, hunks: [{ from: { start: 2, end: 2 }, to: { start: 2, end: 2 } }] },
{ path: "gone.py", change: "deleted", modeOnly: false, binary: false, hunks: [{ from: { start: 1, end: 2 }, to: { after: 0 } }] },
{ path: "new/added.py", change: "added", modeOnly: false, binary: false, hunks: [{ from: { after: 0 }, to: { start: 1, end: 2 } }] },
{ path: "tool.sh", change: "modified", modeOnly: true, binary: false, hunks: [] },
];
const result = compareVersions(repo, from, to);
assert.deepEqual(result.files, expected);
assert.match(result.diff, /^diff --git a\/tool\.sh b\/tool\.sh\nold mode 100644\nnew mode 100755$/m);
assert.match(result.diff, /^Binary files a\/data\.bin and b\/data\.bin differ$/m);
assert.deepEqual(compareVersions(repo, to, to), { files: [], diff: "" });
});
test("比较的行号:改一行、加几行、只删不加,新旧两边的范围都对(预期值手算)", (t) => {
const repo = newRepo(t);
// 旧:a b c d e f g h i j(第 1–10 行)。
// 新:第 2 行 b 改成 B;e(旧第 5 行)后面加 X Y Z;删掉 h(旧第 8 行)。
// 新文件是 a B c d e X Y Z f g i j:B 在新第 2 行;X Y Z 在新第 6–8 行;
// h 删掉后,原来在它前面的 g 是新第 10 行,所以写成"在新第 10 行之后"。
const from = save(repo, makeDir(t, { "f.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n" }), []);
const to = save(repo, makeDir(t, { "f.txt": "a\nB\nc\nd\ne\nX\nY\nZ\nf\ng\ni\nj\n" }), []);
const result = compareVersions(repo, from, to);
assert.deepEqual(result.files, [
{
path: "f.txt",
change: "modified",
modeOnly: false,
binary: false,
hunks: [
{ from: { start: 2, end: 2 }, to: { start: 2, end: 2 } },
{ from: { after: 5 }, to: { start: 6, end: 8 } },
{ from: { start: 8, end: 8 }, to: { after: 10 } },
],
},
]);
// 给模型看的差异原文带 3 行上下文:三处改动相隔不到 6 行,合成一块。
assert.match(result.diff, /^@@ -1,10 \+1,12 @@\n a\n-b\n\+B\n c\n d\n e\n\+X\n\+Y\n\+Z\n f\n g\n-h\n i\n j\n$/m);
});
test("诊断程序(任务书 18):基线代码取出后放进只读的原始产物、改成诊断程序再存,产物被跳过,比较只看程序的改动,取出再存号不变", (t) => {
const repo = newRepo(t);
const baseline = save(repo, makeDir(t, { "initial.py": "a\nb\n", "evaluate.py": "e\n" }), []);
const workDir = checkout(t, repo, baseline);
// 原始产物放进实验员工作目录,设成只读(0444),与 tasks 的 placeBaselineArtifact 一样。
mkdirSync(join(workDir, "baseline-artifact"));
writeFileSync(join(workDir, "baseline-artifact", "solution.json"), "{}");
chmodSync(join(workDir, "baseline-artifact", "solution.json"), 0o444);
writeFileSync(join(workDir, "initial.py"), "a\nb\nexperiment\n");
const excluded = ["__pycache__", "solution.json", "baseline-artifact"];
const program = save(repo, workDir, excluded);
assert.notEqual(program, baseline);
const comparison = compareVersions(repo, baseline, program);
assert.deepEqual(comparison.files.map((f) => f.path), ["initial.py"]);
assert.deepEqual((comparison.files[0] as Extract<FileChange, { binary: false }>).hunks, [{ from: { after: 2 }, to: { start: 3, end: 3 } }]);
const again = checkout(t, repo, program);
assert.equal(existsSync(join(again, "baseline-artifact")), false);
assert.equal(save(repo, again, excluded), program);
});