import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { assertNoMissingModels, buildRelease, checkArtifacts, prepareArtifacts, publicBaseUrl, validateSource, } from "./catalog.mjs"; const directory = path.dirname(new URL(import.meta.url).pathname); const source = JSON.parse( fs.readFileSync(path.join(directory, "catalog.source.json"), "utf8"), ); function fixture(t) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "easyai-git-catalog-")); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); const data = structuredClone(source); data.baseModels = data.baseModels.slice(0, 2); const save = () => fs.writeFileSync( path.join(dir, "catalog.source.json"), JSON.stringify(data, null, 2) + "\n", ); save(); return { dir, data, save }; } test("唯一维护源通过完整 Schema 和规范格式校验", () => { const data = validateSource(); assert.ok(data.baseModels.length >= 220); assert.equal(data.providers.length, 27); }); test("Nano Banana 2.1 保留唯一原身份和图像能力", () => { const rows = source.baseModels.filter( (model) => model.defaultRealModelName === "gemini-nano-banana-2.1", ); assert.equal(rows.length, 1); assert.equal( rows[0].catalogKey, "base:runtime:google-gemini:Nano Banana 2.1:gemini-nano-banana-2.1", ); assert.equal(rows[0].alias, "Nano Banana 2.1"); assert.equal(rows[0].status, "active"); assert.deepEqual(rows[0].modelTypes, ["image_generate", "image_edit"]); assert.deepEqual(rows[0].capabilities.image_generate.output_resolutions, [ "1K", "2K", "4K", ]); assert.equal(rows[0].capabilities.image_edit.input_max_images_count, 14); }); test("重复模型身份拒绝保存", (t) => { const { dir, data, save } = fixture(t); data.baseModels.push(structuredClone(data.baseModels[0])); save(); assert.throws( () => validateSource(path.join(dir, "catalog.source.json")), /重复/, ); }); test("能力字段拼写和类型受 Schema 约束", (t) => { const { dir, data, save } = fixture(t); const type = data.baseModels[0].modelTypes[0]; data.baseModels[0].capabilities[type].misspelledCapability = true; save(); assert.throws( () => validateSource(path.join(dir, "catalog.source.json")), /additional properties/, ); }); test("准备版本时同步生成公开指针和校验和匹配的快照", (t) => { const { dir } = fixture(t); const { pointer } = prepareArtifacts(dir, { version: "test-v1" }); assert.equal( pointer.catalogUrl, publicBaseUrl + "/releases/test-v1/catalog.json", ); assert.equal(checkArtifacts(dir).version, "test-v1"); }); test("只改维护源而未重新生成快照会被检查拒绝", (t) => { const { dir, data, save } = fixture(t); prepareArtifacts(dir, { version: "test-v1" }); data.baseModels[0].displayName = "修改后的名称"; save(); assert.throws(() => checkArtifacts(dir), /不一致/); }); test("篡改版本快照会被拒绝", (t) => { const { dir } = fixture(t); prepareArtifacts(dir, { version: "test-v1" }); const file = path.join(dir, "releases/test-v1/catalog.json"); const release = JSON.parse(fs.readFileSync(file)); release.baseModels[0].displayName = "篡改"; fs.writeFileSync(file, JSON.stringify(release)); assert.throws(() => checkArtifacts(dir), /不一致/); }); test("遗漏已发布模型默认拒绝,正常下线保留原身份", (t) => { const { data } = fixture(t); const previous = buildRelease(data, { version: "test-v1" }); const reduced = structuredClone(data); reduced.baseModels.pop(); assert.throws( () => assertNoMissingModels(previous, reduced), /遗漏已发布模型/, ); data.baseModels[0].status = "offline"; assert.deepEqual(assertNoMissingModels(previous, data), []); }); test("物理移除必须同时声明允许和原因,并记录在新版本中", (t) => { const { dir, data, save } = fixture(t); const { release: previous } = prepareArtifacts(dir, { version: "test-v1" }); data.baseModels.pop(); save(); assert.throws( () => prepareArtifacts(dir, { previous, allowRemovals: true }), /遗漏/, ); assert.throws( () => prepareArtifacts(dir, { previous, removalReason: "迁移" }), /遗漏/, ); const { release } = prepareArtifacts(dir, { previous, version: "test-v2", allowRemovals: true, removalReason: "明确移除", }); assert.equal(release.removals.models.length, 1); assert.equal(release.removals.reason, "明确移除"); assert.equal(checkArtifacts(dir).version, "test-v2"); }); test("历史快照不能覆盖且版本不能包含目录穿越", (t) => { const { dir } = fixture(t); prepareArtifacts(dir, { version: "test-v1" }); assert.throws( () => prepareArtifacts(dir, { version: "test-v1" }), /不能覆盖/, ); assert.throws( () => prepareArtifacts(dir, { version: "../outside" }), /版本格式/, ); }); test("公开指针不能重新指向原 CDN 或其他仓库", (t) => { const { dir } = fixture(t); prepareArtifacts(dir, { version: "test-v1" }); const file = path.join(dir, "latest.json"); const pointer = JSON.parse(fs.readFileSync(file)); pointer.catalogUrl = "https://aliyuncdn.51easyai.com/model-catalog/official/v1/catalog.json"; fs.writeFileSync(file, JSON.stringify(pointer)); assert.throws(() => checkArtifacts(dir), /easyai 仓库公开地址/); }); test("恢复 Git 历史定义后生成新回退版本,保留全部快照", (t) => { const { dir, data, save } = fixture(t); const original = structuredClone(data); const first = prepareArtifacts(dir, { version: "test-v1" }); data.baseModels[0].displayName = "新版"; save(); const second = prepareArtifacts(dir, { previous: first.release, version: "test-v2", }); fs.writeFileSync( path.join(dir, "catalog.source.json"), JSON.stringify(original, null, 2) + "\n", ); const third = prepareArtifacts(dir, { previous: second.release, version: "test-v3", note: "恢复 test-v1 的定义", }); assert.equal(third.release.previousVersion, "test-v2"); assert.equal(third.release.checksum, first.release.checksum); assert.equal(checkArtifacts(dir).version, "test-v3"); assert.deepEqual(fs.readdirSync(path.join(dir, "releases")).sort(), [ "test-v1", "test-v2", "test-v3", ]); });