Files
ttstd-infinite-canvas/web/test/canvas-resource-mention-editor.test.ts
QAyong a07539d458 feat(canvas): Agent 面板 - 支持回车发送并让技能菜单接受中文顿号
画布 Agent 面板的输入框此前有两个中文用户的体验问题:Enter 不发送(只换行),
且技能候选菜单只认 "/" 触发,中文标点状态下按 "/" 打出的是 "、",必须先切回英文。

回车发送:

- canvas-resource-mention-textarea.tsx:sendOnEnter 由 boolean 扩展为 boolean | "both",
  新增 shouldSubmitOnEnter() 统一判定;原来 contentEditable 与 textarea 两处各写一份表达式。
  false 仅 ⌘/Ctrl+Enter 提交(原行为),"both" 两者都提交,Shift+Enter 始终换行。默认仍为 true,
  首页智能创作、批量创作表、短剧工作台三个调用方键位不变。
- canvas-cloud-agent-chat-ui.tsx:改传 sendOnEnter={canSubmit ? "both" : false},
  运行中 Enter 保持换行、不误发;提示文案改为「Enter 发送 · Shift+Enter 换行」,发送按钮 tooltip 同步。
- canvas-cloud-agent.css:快捷键提示原本在 @container (max-width: 520px) 下整条 display: none,
  而默认面板宽度 448px 正落在该区间,等于开箱即用时看不到任何发送键说明;
  改为 .agent-composer-send-hint-full / -compact 两段,窄态降级为短文案「Enter 发送」。

中文顿号触发技能菜单:

- canvas-cloud-agent-chat-ui.tsx:handlePromptChange 的检测正则由 /(^|\s)\/([^\s/]*)$/
  改为 /(^|\s)[/、]([^\s/、]*)$/。保留「行首或紧跟空白」前置条件,
  中文正文里的顿号(如「苹果、香蕉」)不会误触发;applySlashSkill 的替换长度仍为 1,两个触发符都是单字符。
- canvas-cloud-agent-panel.tsx:placeholder 改为「用 / 或 、 引用 Skills」,让两种触发方式可发现。
- canvas-resource-mention-editor.test.ts:同步源码断言(键位、两段提示类名、触发正则、placeholder)。

验证:

- cd web && bun run typecheck 通过。
- bun run lint(改动文件)通过;改动文件按 prettier 校验通过(其余四个文件基线即未格式化,CI 会跳过)。
- bun test test/canvas-resource-mention-editor.test.ts:10 通过 / 1 失败。失败项是同文件既有断言
  expect(css).not.toContain(".agent-tool-row:hover"),而 main 上 canvas-cloud-agent.css 本就含该选择器,
  属于基线既有失败,与本次改动无关(未在本 PR 内顺手修改,避免扩大范围)。
- 触发正则边界用独立脚本实测:、夜景 / 、夜景(前置空白)/ abc /夜 / a\n、x / " @节点 /夜景" 均正确给出
  start 与 query;苹果、香蕉 / abc、夜景 / 、a、 不触发。
- 未启动开发服务,浏览器键位与实际菜单行为待人工验收。
2026-09-19 03:31:41 +08:00

146 lines
9.0 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function source(path: string) {
return readFileSync(resolve(import.meta.dir, path), "utf8");
}
describe("canvas resource mention editor", () => {
test("refreshes async reference previews even when prompt text has not changed", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
const unchangedTextBranch = component.match(/if \(currentValue === value && lastRenderedValueRef.current === value\) \{([^}]+)\}/)?.[1] || "";
expect(unchangedTextBranch).toContain("syncInlineMentionPreviews(editor, activeReferences)");
const sync = component.slice(component.indexOf("function syncInlineMentionPreviews("), component.indexOf("function MentionMenu("));
expect(sync).toContain('byId.get(chip.dataset.mentionReferenceId || "")');
expect(sync).toContain('preview.getAttribute("src") !== src');
expect(sync).toContain("preview.replaceWith(createInlinePreview(reference))");
expect(sync).not.toContain("replaceChildren");
expect(sync).not.toContain("onChange(");
});
test("uses stable component classes for inline media references", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
expect(component).toContain("chip.className = `canvas-resource-inline-mention");
expect(component).toContain("canvas-resource-inline-preview is-${reference.kind}");
expect(component).not.toContain("size-[1.18em]");
});
test("clamps native media dimensions so video previews cannot cover prompt text", () => {
const css = source("../src/styles/globals.css");
const previewRule = css.match(/\.canvas-resource-inline-preview \{[^}]+}/)?.[0] || "";
expect(previewRule).toContain("width: var(--canvas-mention-chip-preview-size)");
expect(previewRule).toContain("min-width: var(--canvas-mention-chip-preview-size)");
expect(previewRule).toContain("max-width: var(--canvas-mention-chip-preview-size)");
expect(previewRule).toContain("height: var(--canvas-mention-chip-preview-size)");
expect(previewRule).toContain("flex: 0 0 var(--canvas-mention-chip-preview-size)");
expect(previewRule).toContain("object-fit: cover");
});
test("exposes inline image references as replacement drop targets", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
const css = source("../src/styles/globals.css");
expect(component).toContain("activeDropReferenceId?: string | null");
expect(component).toContain("onReferenceFilesDrop?:");
expect(component).toContain("chip.dataset.mentionReferenceId = reference.id");
expect(component).toContain('chip.classList.toggle("is-replace-target"');
expect(component).toContain("onReferenceFilesDrop(reference, files)");
expect(css).toContain(".canvas-resource-inline-mention.is-replace-target");
expect(css).toContain('content: "替换"');
});
test("resolves storage-backed previews and renders a visible loading spinner", () => {
const editor = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
const panel = source("../src/components/canvas/canvas-node-prompt-panel.tsx");
const configComposer = source("../src/components/canvas/canvas-config-composer.tsx");
const project = source("../src/pages/canvas/project.tsx");
expect(editor).toContain("useResolvedCanvasResourceReferences");
expect(panel).toContain("<LoaderCircle className=");
expect(panel).toContain("animate-spin motion-reduce:animate-none");
expect(panel).not.toContain("isRunning ? theme.accent.danger");
expect(configComposer).toContain("wrapper.dataset.referenceToken");
expect(configComposer).not.toContain("result += `@[node:");
expect(project).not.toContain("removeCanvasResourceMention");
});
test("keeps editable mention prompts separate from normalized generation prompts", () => {
const imageExecutor = source("../src/pages/canvas/canvas-image-generation-executor.ts");
const mediaExecutors = source("../src/pages/canvas/canvas-media-generation-executors.ts");
const textExecutor = source("../src/pages/canvas/canvas-text-generation-executor.ts");
const generationExecutor = source("../src/pages/canvas/use-canvas-generation-executor.ts");
expect(imageExecutor.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBeGreaterThanOrEqual(3);
expect(imageExecutor).toContain("imageBatchExpanded: count > 1 ? true : undefined");
expect(imageExecutor).toContain("imageGenerationReferenceConnections");
expect(imageExecutor).toContain("retireImageBatchChildren");
expect(mediaExecutors.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBe(2);
expect(textExecutor.match(/canvasGenerationPromptMetadata\(prompt, effectivePrompt\)/g)?.length).toBe(2);
expect(generationExecutor).toContain("composerContent: prompt");
expect(generationExecutor).toContain("canvasGenerationPromptMetadata(prompt, statusPrompt)");
});
test("anchors the mention menu to the caret instead of the textarea edge", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
expect(component).toContain("cursorOffset={mention.end}");
expect(component).toContain("mentionCaretRect(anchor, cursorOffset)");
expect(component).toContain("textareaCaretRect(anchor, cursorOffset)");
expect(component).toContain('transform: position.showAbove ? "translateY(-100%)" : undefined');
expect(component).toContain('window.addEventListener("scroll", updatePosition, true)');
});
test("renders skill references as descriptive workflow rows", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
const css = source("../src/styles/globals.css");
expect(component).toContain('reference.kind === "skill" ? "is-skill" : ""');
expect(component).toContain("reference.skill?.description");
expect(component).toContain("reference.skill?.fileCount");
expect(component).toContain("<Workflow aria-hidden />");
expect(css).toContain(".canvas-resource-mention-item.is-skill");
expect(css).toContain(".canvas-resource-mention-meta");
});
test("agent composer attachments stay large, previewable, and mentionable", () => {
const component = source("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx");
expect(component).toContain("w-20 shrink-0");
expect(component).toContain("insertAttachmentMention");
expect(component).toContain("@[attachment:");
expect(component).toContain("AgentImagePreview");
expect(component).toContain("composerReferences");
expect(component).toContain("点击放大预览");
});
test("agent composer renders slash skill references as stable skill chips and consumes the typed slash query", () => {
const component = source("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx");
expect(component).toContain("const token = `@[skill:${skill.skillId}] `");
expect(component).toContain("slash.start + 1 + slash.query.length");
expect(component).toContain("buildSkillMentionReferences(availableSlashSkills)");
expect(component).toContain("[/、]([^\\s/、]*)$");
expect(source("../src/components/canvas/canvas-cloud-agent-panel.tsx")).toContain("用 / 或 、 引用 Skills");
});
test("skill chips use one colored icon instead of exposing the serialized token", () => {
const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx");
const chat = source("../src/components/canvas/canvas-cloud-agent-chat-ui.tsx");
const css = source("../src/components/canvas/canvas-cloud-agent.css");
expect(component).toContain('chip.className = `canvas-resource-inline-mention ${reference.kind === "skill" ? "is-skill" : ""}`');
expect(component).toContain('prefix.textContent = reference.kind === "skill" ? "✦" : "@"');
expect(component).toContain('if (reference.kind !== "skill") chip.appendChild(createInlinePreview(reference));');
expect(component).toContain('chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference))');
expect(chat).toContain('sendOnEnter={canSubmit ? "both" : false}');
expect(chat).toContain("agent-composer-resize-handle");
expect(chat).toContain("Enter 发送 · Shift+Enter 换行");
expect(css).toContain(".agent-composer-send-hint-full");
expect(css).toContain(".agent-composer-send-hint-compact");
expect(css).toContain(".agent-composer-prompt-scroll");
expect(css).not.toContain(".agent-tool-row:hover");
});
});