在已落地的节点注册表上加两层节点:展示层(Markdown / SVG / HTML / 全景)与 接入生成链的加工层(对比 / 图表 / 调色)。全部是第一方内置节点,不引入远程插件 加载,因此不涉及沙箱信任模型,也不需要把 CanvasNodeType 放开成任意字符串。 刻意保留穷尽性检查而不是改成运行时注册。全库只有 4 张按类型建的穷眉表 (NODE_DEFAULT_SIZE / NODE_SPECS / 注册表定义 / nodeContentRenderers), 让编译器强制每个新节点填满它们,比少改几个文件更值 —— 加第一个枚举成员时 编译器点出了 5 处,其中 pages/canvas/shared.tsx 是原先没编目的那一处; 换成运行时注册,那里会静默回落成「未知节点」。 两处前置基建: - CanvasNodeGraphContext:渲染器只拿到 node、没有 connections,而加工层三个节点 都要读上游。仿已有的 canvas-node-action-context(同一个问题:节点经 CanvasProjectWorldLayers 渲染不便逐个透传),取上游复用 canvas-resource-references 的 getContextResourceNodes,不另写一份。 Provider 值必须 memo —— 每帧新对象会让所有节点跟着重渲染。 - ToolbarHandlers.onAddExtensionNode(type):一个泛用入口,不给每种扩展节点单开 一个 onAddXxx。菜单另开「展示与加工」分区,不挤占上方调好的四列创作网格。 SVG 与 HTML 统一走 sandbox iframe:依赖里没有 DOMPurify,手写 SVG 消毒不可靠 (script / on* / foreignObject)。SandboxedFrame 只暴露 allowScripts 布尔而不是 sandbox 取值,让「同时给 allow-same-origin」这条没有入口。 全景节点三个取舍:点击才激活 WebGL(浏览器对同时存在的上下文有硬上限,画布可以有 任意多个全景节点,自动激活必然在某个数量后整片黑);用原生 three 而非 fiber (需要精确 dispose,forceContextLoss 才真正释放上下文);性能模式下只给静态预览。 调色是唯一碰生成路径的节点,照 Drawing 的先例:拖滑杆只做本地 CSS filter 预览, 落地成资源要等到生成消费它时才走 uploadResourceFile。预览与导出共用同一个 filter 字符串,两处各写一份就会「看到的和用的悄悄不一致」;未调色时直接透传源图, 省掉一次渲染与上传。ctx.filter 不受支持时明确报错而不是导出一张没调色的图。 resourceKind 与 inputKind 分开两个字段:空图片节点不是素材(不进 @ 引用), 但照样计入 imageCount。调色节点的 resourceKind 无条件返回 image —— 它的图来自 上游,按「有没有 content」判断会让它永远进不了生成输入,这是不报错的静默失效。 测试:新增 test/canvas-extension-nodes.test.ts(12 用例 / 64 断言),覆盖 7 个节点的 判据矩阵、调色参数、以及图表解析。图表解析函数刻意不抛 —— 数据来自模型输出, 非法 JSON 是常态而不是异常,抛出来会连带整个画布白屏,有 7 条断言钉这个。 测试列表是显式枚举,已把新文件加进 package.json,不加进去不会跑。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
5.2 KiB
TypeScript
113 lines
5.2 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
||
|
||
import { parseChartSource } from "../src/components/canvas/nodes/chart-node";
|
||
import { colorGradeCssFilter, DEFAULT_COLOR_GRADE, isNeutralColorGrade } from "../src/lib/canvas/canvas-color-grade";
|
||
import { NODE_DEFAULT_SIZE, NODE_SPECS } from "../src/constant/canvas";
|
||
import { getNodeGenerationMode, getNodeInputKind, getNodeLabel, getNodeResourceKind } from "../src/lib/canvas/node-registry";
|
||
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
|
||
|
||
const EXTENSION_TYPES = [
|
||
CanvasNodeType.Markdown,
|
||
CanvasNodeType.Svg,
|
||
CanvasNodeType.Html,
|
||
CanvasNodeType.Panorama,
|
||
CanvasNodeType.Compare,
|
||
CanvasNodeType.Chart,
|
||
CanvasNodeType.ColorGrade,
|
||
] as const;
|
||
|
||
function node(type: CanvasNodeType, metadata: CanvasNodeData["metadata"] = {}): CanvasNodeData {
|
||
return { id: `${type}-1`, type, title: "", position: { x: 0, y: 0 }, width: 100, height: 100, metadata };
|
||
}
|
||
|
||
describe("扩展节点注册完整性", () => {
|
||
test("7 个扩展节点在四张表里都有登记", () => {
|
||
EXTENSION_TYPES.forEach((type) => {
|
||
expect(getNodeLabel(type), `${type} 缺少注册表定义`).toBeTruthy();
|
||
expect(NODE_DEFAULT_SIZE[type], `${type} 缺少默认尺寸`).toBeTruthy();
|
||
expect(NODE_SPECS[type], `${type} 缺少节点规格`).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
test("展示节点不参与生成,自身不发起生成行为", () => {
|
||
EXTENSION_TYPES.forEach((type) => {
|
||
expect(getNodeGenerationMode(node(type)), `${type} 不应有生成模式`).toBeNull();
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("扩展节点的素材与输入判据", () => {
|
||
test("文本类扩展节点有内容才算素材", () => {
|
||
[CanvasNodeType.Markdown, CanvasNodeType.Svg, CanvasNodeType.Html].forEach((type) => {
|
||
expect(getNodeResourceKind(node(type, { content: "x" }))).toBe("text");
|
||
expect(getNodeResourceKind(node(type, {}))).toBeNull();
|
||
expect(getNodeInputKind(type)).toBe("text");
|
||
});
|
||
});
|
||
|
||
test("查看器类节点消费图片但自身不是素材", () => {
|
||
[CanvasNodeType.Panorama, CanvasNodeType.Compare].forEach((type) => {
|
||
expect(getNodeResourceKind(node(type, { content: "x" })), `${type} 不应作为素材`).toBeNull();
|
||
expect(getNodeInputKind(type)).toBe("image");
|
||
});
|
||
});
|
||
|
||
test("图表节点吃文本但不产出素材", () => {
|
||
expect(getNodeResourceKind(node(CanvasNodeType.Chart, { content: "[]" }))).toBeNull();
|
||
expect(getNodeInputKind(CanvasNodeType.Chart)).toBe("text");
|
||
});
|
||
|
||
test("调色节点即使自身没有 content 也必须算图片素材", () => {
|
||
// 它的图来自上游。若按「有没有 content」判断,它会永远进不了生成输入——
|
||
// 这是个不报错的静默失效,所以单独钉住。
|
||
expect(getNodeResourceKind(node(CanvasNodeType.ColorGrade, {}))).toBe("image");
|
||
expect(getNodeInputKind(CanvasNodeType.ColorGrade)).toBe("image");
|
||
});
|
||
});
|
||
|
||
describe("调色参数", () => {
|
||
test("缺省参数视为未调色", () => {
|
||
expect(isNeutralColorGrade(DEFAULT_COLOR_GRADE)).toBe(true);
|
||
expect(isNeutralColorGrade({ ...DEFAULT_COLOR_GRADE, brightness: 120 })).toBe(false);
|
||
expect(isNeutralColorGrade({ ...DEFAULT_COLOR_GRADE, hueRotate: -10 })).toBe(false);
|
||
});
|
||
|
||
test("预览与导出共用同一个 filter 字符串", () => {
|
||
expect(colorGradeCssFilter({ brightness: 110, contrast: 90, saturate: 120, hueRotate: -15 }))
|
||
.toBe("brightness(110%) contrast(90%) saturate(120%) hue-rotate(-15deg)");
|
||
});
|
||
});
|
||
|
||
describe("图表数据解析", () => {
|
||
test("解析 JSON 数组,数值列作为系列", () => {
|
||
const parsed = parseChartSource('[{"月份":"1月","销量":120,"退货":8},{"月份":"2月","销量":150,"退货":5}]');
|
||
expect(parsed?.xKey).toBe("月份");
|
||
expect(parsed?.series).toEqual(["销量", "退货"]);
|
||
expect(parsed?.rows.length).toBe(2);
|
||
});
|
||
|
||
test("解析 CSV,并把数字字符串转成数值", () => {
|
||
const parsed = parseChartSource("城市,人口\n北京,2189\n上海,2487");
|
||
expect(parsed?.xKey).toBe("城市");
|
||
expect(parsed?.series).toEqual(["人口"]);
|
||
expect(parsed?.rows[0].人口).toBe(2189);
|
||
});
|
||
|
||
test("全是数值列时回落行号作横轴", () => {
|
||
const parsed = parseChartSource("[{\"a\":1,\"b\":2},{\"a\":3,\"b\":4}]");
|
||
expect(parsed?.xKey).toBe("__index");
|
||
expect(parsed?.rows[0].__index).toBe(1);
|
||
});
|
||
|
||
// 数据来自模型输出,非法输入是常态而不是异常;抛出来会连带整个画布白屏。
|
||
test("非法或无系列的输入返回 null 而不抛", () => {
|
||
expect(parseChartSource("")).toBeNull();
|
||
expect(parseChartSource("{ 这不是 JSON")).toBeNull();
|
||
expect(parseChartSource("just a sentence")).toBeNull();
|
||
expect(parseChartSource("[]")).toBeNull();
|
||
expect(parseChartSource('{"a":1}')).toBeNull();
|
||
expect(parseChartSource('[{"名称":"甲"},{"名称":"乙"}]')).toBeNull();
|
||
expect(parseChartSource("只有一行")).toBeNull();
|
||
});
|
||
});
|