From 157b4eb861b48e9c37b222f9544f900334eba8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 6 Oct 2026 22:56:51 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=AD=97=E6=AE=B5=E9=80=90?= =?UTF-8?q?=E9=A1=B9=E6=8A=A5=E9=94=99=E5=B9=B6=E5=8F=AF=E4=BA=A4=E7=BB=99?= =?UTF-8?q?=E9=99=B6=E6=B3=A5=E5=84=BF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xhsMinitoolFields.ts 的 validateXhsMinitoolForm 一次返回全部出错字段 - xhsMinitoolInstruction.ts 新增字段红字指令,示意里的字数上限改用 XHS_MINITOOL_TEXT_MAX_CHARS - useXhsMinitoolExport.ts 的 fieldError 改为 fieldErrors,新增 handFieldErrorsToAgent:交接后清红字与 dirty,让自动重读能采纳 agent 的修正 - FormCard.tsx 每个出错字段各挂红框与红字,标题下新增「交给陶泥儿」入口 - ArtifactsPane.tsx 接上新字段与新动作 - 补 xhsMinitoolFields / xhsMinitoolExport 测试覆盖多字段报错与交接 --- .../export/state/useXhsMinitoolExport.ts | 61 +++++++----- .../export/state/xhsMinitoolFields.ts | 20 ++-- .../export/state/xhsMinitoolInstruction.ts | 43 +++++++- .../export/tabs/xiaohongshu/ArtifactsPane.tsx | 3 +- .../export/tabs/xiaohongshu/FormCard.tsx | 37 +++++-- .../tests/xhsMinitoolExport.test.tsx | 97 ++++++++++++++++++- .../tests/xhsMinitoolFields.test.ts | 51 ++++++---- 7 files changed, 249 insertions(+), 63 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts index 322eda813..54af46afe 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/useXhsMinitoolExport.ts @@ -45,6 +45,7 @@ import { import { buildXhsMinitoolAdaptationInstruction, buildXhsMinitoolErrorFixInstruction, + buildXhsMinitoolFormErrorInstruction, buildXhsMinitoolFormFillInstruction, } from './xhsMinitoolInstruction'; @@ -86,9 +87,7 @@ export function useXhsMinitoolExport({ }) { const [form, setForm] = useState(null); const [hasScript, setHasScript] = useState(false); - const [fieldError, setFieldError] = useState( - null, - ); + const [fieldErrors, setFieldErrors] = useState([]); const [conflict, setConflict] = useState(null); const [failure, setFailure] = useState(null); /** @@ -153,8 +152,8 @@ export function useXhsMinitoolExport({ }, [form]); useEffect(() => { - adoptableRef.current = !dirty && !conflict && !fieldError; - }, [dirty, conflict, fieldError]); + adoptableRef.current = !dirty && !conflict && fieldErrors.length === 0; + }, [dirty, conflict, fieldErrors]); /** * 换项目时把整套状态与本地判据清回初值。 @@ -169,7 +168,7 @@ export function useXhsMinitoolExport({ projectPathRef.current = projectPath; setForm(null); setHasScript(false); - setFieldError(null); + setFieldErrors([]); setConflict(null); setFailure(null); setDismissedFailure(''); @@ -262,7 +261,7 @@ export function useXhsMinitoolExport({ return content.hasScript; } setForm(content.form); - setFieldError(null); + setFieldErrors([]); setConflict(null); setFailure(null); try { @@ -337,11 +336,11 @@ export function useXhsMinitoolExport({ if (!projectPath) return false; const invoke = resolveTauriInvoke(); if (!invoke) return false; - // 首个字段错误即返回:红字挂到对应输入框,等用户改完下一次输入再存。`dirty` 归零,同一份 - // 不合法的输入不会反复排队。 + // 一次把**所有**出错字段都挂出来:用户能同时看到每个字段各自的问题,不必改一个才知道下一个。 + // `dirty` 归零,同一份不合法的输入不会反复排队。 const invalid = validateXhsMinitoolForm(next); - if (invalid) { - setFieldError(invalid); + if (invalid.length > 0) { + setFieldErrors(invalid); setDirty(false); return false; } @@ -357,7 +356,7 @@ export function useXhsMinitoolExport({ // 保存命令的返回值就是当前表单的新指纹:这份表单的基线已经配套,不必再补读。 baselineRef.current = nextBaseline; hashPendingRef.current = false; - setFieldError(null); + setFieldErrors([]); setConflict(null); setFailure(null); return true; @@ -389,12 +388,14 @@ export function useXhsMinitoolExport({ ); useEffect(() => { - if (!enabled || !form || !dirty || conflict || fieldError) return; + if (!enabled || !form || !dirty || conflict || fieldErrors.length > 0) { + return; + } const timer = setTimeout(() => { void persist(form, baselineRef.current); }, AUTOSAVE_DELAY_MS); return () => clearTimeout(timer); - }, [enabled, form, dirty, conflict, fieldError, persist]); + }, [enabled, form, dirty, conflict, fieldErrors, persist]); /** 改一个字段。改哪个字段就清哪个字段的旧报错——别让已经改好的红字继续挂着。 */ const updateField = useCallback( @@ -404,8 +405,8 @@ export function useXhsMinitoolExport({ setForm((previous) => previous ? { ...previous, [field]: value } : previous, ); - setFieldError((previous) => - previous && previous.field === field ? null : previous, + setFieldErrors((previous) => + previous.filter((error) => error.field !== field), ); setDirty(true); }, @@ -482,12 +483,12 @@ export function useXhsMinitoolExport({ return; } // 先 flush 自动保存:用户刚敲完就点打包时,注册表里可能还是上一份。 - if (dirty && form && !conflict && !fieldError) { + if (dirty && form && !conflict && fieldErrors.length === 0) { const flushed = await persist(form, baselineRef.current); if (!flushed) return; } // 没解决的冲突 / 字段红字:盘上的内容和用户看到的不一致,先别打包。 - if (conflict || fieldError) return; + if (conflict || fieldErrors.length > 0) return; await buildOnce(); } finally { // 旧项目那一次打包的收尾不解新项目的锁。 @@ -499,7 +500,7 @@ export function useXhsMinitoolExport({ dirty, form, conflict, - fieldError, + fieldErrors, persist, buildOnce, ]); @@ -542,11 +543,26 @@ export function useXhsMinitoolExport({ const fillForm = useCallback(async (): Promise => { if (!projectPath || !form) return; // 冲突或字段红字挂着时不重复撞一次保存(自动保存此时也是停的),直接把现场交给 agent。 - if (dirty && !conflict && !fieldError) { + if (dirty && !conflict && fieldErrors.length === 0) { await persist(form, baselineRef.current); } await sendToAgent(buildXhsMinitoolFormFillInstruction()); - }, [projectPath, form, dirty, conflict, fieldError, persist, sendToAgent]); + }, [projectPath, form, dirty, conflict, fieldErrors, persist, sendToAgent]); + + /** + * 把手上的字段限制问题交给 agent:指令带上界面本地校验给用户的那些原话,agent 直接改注册表把值改成 + * 合规的。 + * + * 交出去之后清掉本地红字与 `dirty`:红字留着会让自动重读一直不肯采纳 agent 写回来的新值 + * (`adoptableRef` 要求手上没有待处理现场),用户就会卡在「点了按钮却什么都没变」。用户那份不合法 + * 的输入随指令一起带过去,agent 不必回界面猜。 + */ + const handFieldErrorsToAgent = useCallback((): void => { + if (!projectPath || !form || fieldErrors.length === 0) return; + setFieldErrors([]); + setDirty(false); + void sendToAgent(buildXhsMinitoolFormErrorInstruction(form, fieldErrors)); + }, [projectPath, form, fieldErrors, sendToAgent]); /** * 一键适配(bootstrap):把首次适配指令发给同一个项目会话。发出即收起阻断提示,别让弹窗再拦住用户。 @@ -595,7 +611,7 @@ export function useXhsMinitoolExport({ return { form, hasScript, - fieldError, + fieldErrors, conflict, failure: visibleFailure, loading, @@ -609,6 +625,7 @@ export function useXhsMinitoolExport({ adapt, repair, fillForm, + handFieldErrorsToAgent, dismissAdaptPrompt, dismissFailure, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts index dfd06ee4e..bb2ee0ed8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolFields.ts @@ -9,7 +9,7 @@ import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; /** 表单里的三个字段,与 [`XHSMiniToolExportForm`] 的键一一对应。 */ export type XhsMinitoolField = 'name' | 'introduction' | 'iconPath'; -/** 字段红字:一次只挂一个出错字段,用户改完再报下一个。 */ +/** 字段红字:一个出错字段一条,界面按 `field` 挂到对应输入框下面。 */ export type XhsMinitoolFieldError = { field: XhsMinitoolField; reason: string; @@ -63,18 +63,18 @@ export const XHS_MINITOOL_FORM_FIELDS: readonly XhsMinitoolFieldMeta[] = [ * * 校验放在界面这一层是刻意的:用户最终面对的就是界面,这份表单也只是一份草稿,不值得让宿主再挡 * 一次。文件是不是真的存在、是不是符号链接、扩展名与大小都不在这里判——那些是「真的去读它」时 - * 才成立的事实,由预览 / 下载那条路各自的路径门禁负责。首个错误即返回,与表单的提交顺序一致。 + * 才成立的事实,由预览 / 下载那条路各自的路径门禁负责。 + * + * 返回**全部**出错字段(按表单顺序),让界面一次把每个字段的红字都挂上;调用方只需判空数组。 */ export function validateXhsMinitoolForm( form: XHSMiniToolExportForm, -): XhsMinitoolFieldError | null { - return ( - validateText('name', '名称', form.name) ?? - validateText('introduction', '简介', form.introduction) ?? - (form.iconPath.trim() - ? null - : { field: 'iconPath', reason: 'icon 不能为空' }) - ); +): XhsMinitoolFieldError[] { + return [ + validateText('name', '名称', form.name), + validateText('introduction', '简介', form.introduction), + form.iconPath.trim() ? null : { field: 'iconPath', reason: '图标不能为空' }, + ].filter((error): error is XhsMinitoolFieldError => error !== null); } function validateText( diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts index 2c377b688..e67c836c7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/export/state/xhsMinitoolInstruction.ts @@ -9,8 +9,14 @@ * `tests/xhsMinitoolContract.test.ts` 对着 Rust 权威 `src-tauri/src/export/draft/xhs_minitool/layout.rs` * 逐条钉住。 */ +import type { XHSMiniToolExportForm } from '../generated/XHSMiniToolExportForm'; import type { XHSMiniToolExportRegistry } from '../generated/XHSMiniToolExportRegistry'; import type { XhsMinitoolCardFailure } from './xhsMinitoolFailure'; +import { + XHS_MINITOOL_FORM_FIELDS, + XHS_MINITOOL_TEXT_MAX_CHARS, + type XhsMinitoolFieldError, +} from './xhsMinitoolFields'; /** 目标名,与 `model.ts` 的 `TARGET_META` 人话一致。 */ export const XHS_MINITOOL_TARGET_LABEL = '小红书小工具'; @@ -37,8 +43,8 @@ export const XHS_MINITOOL_PACKAGE_SKILL_NAME = 'vite-export-xhs-minitool'; */ const XHS_MINITOOL_REGISTRY_SHAPE_HINT: XHSMiniToolExportRegistry = { form: { - name: '最多 14 个字', - introduction: '最多 14 个字', + name: `最多 ${XHS_MINITOOL_TEXT_MAX_CHARS} 个字`, + introduction: `最多 ${XHS_MINITOOL_TEXT_MAX_CHARS} 个字`, iconPath: 'png、jpg、jpeg;大小不超过 5 M;推荐比例为1:1', }, }; @@ -94,3 +100,36 @@ ${JSON.stringify(XHS_MINITOOL_REGISTRY_SHAPE_HINT, null, 2)} * 对于图标示例图等字段可以看看目前是否已经有合适的, 没有的话可以(征询用户意见后)生图/留给用户自己选择 `; } + +/** + * 「字段不合限制」指令:界面本地校验挡下表单后,让 agent 直接改注册表把值改成合规的。 + * + * 与「帮我填」同一条链(落点都是那份文件、宿主自动重读带回界面),区别是这条把**界面上那些原话** + * 带上:用户已经被限制挡下,agent 不必回界面猜他卡在哪。当前值也照抄进去——不合法的值没落盘, + * agent 光读文件看不到。 + */ +export function buildXhsMinitoolFormErrorInstruction( + form: XHSMiniToolExportForm, + errors: readonly XhsMinitoolFieldError[], +): string { + const lines = errors + .map((error) => { + const label = + XHS_MINITOOL_FORM_FIELDS.find((meta) => meta.field === error.field) + ?.label ?? error.field; + return ` - ${label}(${error.field}):${error.reason};当前值 ${JSON.stringify( + form[error.field], + )}`; + }) + .join('\n'); + return ` +帮我处理用于发布 ${XHS_MINITOOL_TARGET_LABEL} 的表单 ${XHS_MINITOOL_REGISTRY_RELATIVE_PATH} 里不合限制的字段。 + +* 被限制挡下的字段: +${lines} + +* 请按限制改成合适的值, 直接修改文件, 改完不必重述. +* 结构和限制: +${JSON.stringify(XHS_MINITOOL_REGISTRY_SHAPE_HINT, null, 2)} + `; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx index 76e61a529..5399cb211 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane.tsx @@ -135,11 +135,12 @@ export function ArtifactsPane({ formSlot = ( void minitool.fillForm()} + onAskAgentAboutErrors={minitool.handFieldErrorsToAgent} /> ); } else if (!minitool.readSettled) { diff --git a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx index f8e13e4db..d09d415fd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/export/tabs/xiaohongshu/FormCard.tsx @@ -16,8 +16,11 @@ import { IconPreview } from './IconPreview'; * 小红书上传表单。名称与介绍可复制(它们是要粘贴到上传页的文字),图标只给「下载」——复制一条 * 项目内路径没有意义。 * - * 表单只收集值:校验在界面这一层(保存前,见 `validateXhsMinitoolForm`),红字是本地校验的 - * 结果;宿主那侧只按原样读写草稿。 + * 表单只收集值:校验在界面这一层(保存前,见 `validateXhsMinitoolForm`),**每个**出错字段各自挂 + * 红框与红字;宿主那侧只按原样读写草稿。 + * + * 有字段被限制挡下时,标题下多一行说明 +「交给陶泥儿」:用户自己改不动就让 agent 直接改注册表, + * 指令带上这些红字的原话。 * 文案一律说「上传到小红书要填什么」,不暴露脚本名、注册表与 zip 这些实现词。 * * 版式对齐发布面板(`GameDistributionPublishPanel`):标签在上、输入独占整行、字段之间留够呼吸, @@ -26,18 +29,20 @@ import { IconPreview } from './IconPreview'; */ export function FormCard({ form, - fieldError, + fieldErrors, projectPath, onChange, onDownloadIcon, onAskAgentToFill, + onAskAgentAboutErrors, }: { form: XHSMiniToolExportForm; - fieldError: XhsMinitoolFieldError | null; + fieldErrors: XhsMinitoolFieldError[]; projectPath: string | null; onChange: (field: keyof XHSMiniToolExportForm, value: string) => void; onDownloadIcon: () => void; onAskAgentToFill: () => void; + onAskAgentAboutErrors: () => void; }) { return (
帮我填 + {fieldErrors.length > 0 ? ( +
+

+ 有 {fieldErrors.length} 个字段不合平台限制。 +

+ + +
+ ) : null}
{XHS_MINITOOL_FORM_FIELDS.map((meta) => { const error = - fieldError?.field === meta.field ? fieldError.reason : null; + fieldErrors.find((item) => item.field === meta.field)?.reason ?? + null; if (meta.field === 'iconPath') { return ( void; }) { const id = useId(); - // 按码点计数,与宿主 `validate_text` 的 `chars().count()` 对齐:`String.length` 数的是 UTF-16 - // 码元,一个 emoji 会被这里算成 2、宿主算成 1,计数徽标会比宿主更早报超限。 + // 按码点计数:`String.length` 数的是 UTF-16 码元,一个 emoji 会被这里算成 2、平台算成 1, + // 计数徽标会比实际更早报超限。与 `validateXhsMinitoolForm` 用的是同一套计数。 const used = Array.from(value.trim()).length; const overLimit = maxChars !== undefined && used > maxChars; diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx index 6be4d79ad..69e554bc5 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolExport.test.tsx @@ -38,7 +38,9 @@ import { XhsMinitoolFailureRepairability, } from '../src/view/project-development/export/state/xhsMinitoolFailure'; import { + buildXhsMinitoolFormErrorInstruction, XHS_MINITOOL_ARTIFACT_RELATIVE_PATH, + XHS_MINITOOL_REGISTRY_RELATIVE_PATH, XHS_MINITOOL_SCRIPT_NAME, } from '../src/view/project-development/export/state/xhsMinitoolInstruction'; import { ArtifactsPane } from '../src/view/project-development/export/tabs/xiaohongshu/ArtifactsPane'; @@ -209,6 +211,24 @@ describe('交给 code agent 的指令', () => { // `saveConflict` 不在这张表里:它只出现在保存命令的失败表上,有专属的冲突面板;字段红字则 // 在界面这一层就被拦下,根本走不到保存命令。 }); + + it('字段红字交给 agent 的指令带上红字原话、当前值与落点', () => { + const text = buildXhsMinitoolFormErrorInstruction( + { name: '太长的名字', introduction: '', iconPath: '' }, + [ + { field: 'name', reason: '名称最多 14 个字,当前 16 个字' }, + { field: 'iconPath', reason: '图标不能为空' }, + ], + ); + + // 红字原话照抄:agent 不必回界面猜用户卡在哪。 + expect(text).toContain('名称最多 14 个字,当前 16 个字'); + expect(text).toContain('图标不能为空'); + // 不合法的值没落盘,必须写进指令,agent 光读文件看不到。 + expect(text).toContain('"太长的名字"'); + // 落点是那份注册表文件,不是聊天里的一段值。 + expect(text).toContain(XHS_MINITOOL_REGISTRY_RELATIVE_PATH); + }); }); describe('面板左半', () => { @@ -248,6 +268,31 @@ describe('面板左半', () => { screen.getByRole('button', { name: '下载代码包' }); }); + it('字段被限制挡下时给出「交给陶泥儿」,没有问题时这颗按钮不出现', () => { + const handFieldErrorsToAgent = vi.fn(); + renderPane( + fakeMinitool({ + fieldErrors: [{ field: 'name', reason: '名称最多 14 个字' }], + handFieldErrorsToAgent, + }), + ); + + screen.getByText('有 1 个字段不合平台限制。'); + // 红字挂在出错字段下面,用户与读屏都看得到。 + screen.getByText('名称最多 14 个字'); + fireEvent.click( + screen.getByRole('button', { name: '交给陶泥儿处理这些字段' }), + ); + expect(handFieldErrorsToAgent).toHaveBeenCalledTimes(1); + + cleanup(); + // 没有红字时不给这个入口:没有东西可交。 + renderPane(); + expect( + screen.queryByRole('button', { name: '交给陶泥儿处理这些字段' }), + ).toBeNull(); + }); + it('代码包只在打包成功后给出下载入口', () => { // 还没打过:只有「打包」,「下载代码包」不出现(不给一个下不了的按钮)。 renderPane(); @@ -1117,7 +1162,9 @@ describe('状态 hook', () => { await result.current.packageAll(); }); - expect(result.current.fieldError?.field).toBe('name'); + expect(result.current.fieldErrors.map((error) => error.field)).toEqual([ + 'name', + ]); expect(invoke).not.toHaveBeenCalledWith( 'save_xhs_minitool_export_form', expect.anything(), @@ -1264,10 +1311,53 @@ describe('状态 hook', () => { }); // 保存被界面校验拦下,红字留在表单上,但这条消息已经发出去了——拦住它只剩用户自己改。 - expect(result.current.fieldError?.field).toBe('name'); + expect(result.current.fieldErrors.map((error) => error.field)).toEqual([ + 'name', + ]); expect(submitTurn).toHaveBeenCalledTimes(1); }); + it('把字段红字原话交给 agent,并清掉红字让自动重读能采纳它的修正', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_xhs_minitool_export') { + return { form: FORM, hasScript: true }; + } + if (command === 'read_xhs_minitool_export_hash') return CONTENT_HASH; + return { outputTail: '', omittedCharacters: 0 }; + }); + installTauriRuntime({ core: { invoke } }); + const submitTurn = vi.fn(async () => true); + const { result } = renderHook(() => + useXhsMinitoolExport({ + enabled: true, + projectPath: PROJECT_PATH, + submitTurn, + }), + ); + await waitFor(() => expect(result.current.form).toEqual(FORM)); + + await act(async () => { + result.current.updateField('name', '这个名字已经超过十四个字了平台会拒'); + }); + await act(async () => { + await result.current.packageAll(); + }); + await waitFor(() => expect(result.current.fieldErrors).toHaveLength(1)); + + await act(async () => { + result.current.handFieldErrorsToAgent(); + }); + + const [content] = submitTurn.mock.calls[0] as unknown as [ + Array<{ type: string; text: string }>, + ]; + expect(content[0]?.type).toBe('input_text'); + // 指令必须带上界面给用户看的那句红字,agent 才知道用户卡在哪。 + expect(content[0]?.text).toContain('名称最多 14 个字'); + // 红字清掉、`dirty` 归零:否则自动重读会一直不肯采纳 agent 写回的新值。 + expect(result.current.fieldErrors).toEqual([]); + }); + it('适配把指令当成一条用户消息交给同一个会话', async () => { const submitTurn = vi.fn(async () => true); installTauriRuntime({ @@ -1492,7 +1582,7 @@ function fakeMinitool( return { form: FORM, hasScript: true, - fieldError: null, + fieldErrors: [], conflict: null, failure: null, loading: false, @@ -1506,6 +1596,7 @@ function fakeMinitool( adapt: vi.fn(), repair: vi.fn(), fillForm: vi.fn(async () => undefined), + handFieldErrorsToAgent: vi.fn(), dismissAdaptPrompt: vi.fn(), dismissFailure: vi.fn(), ...overrides, diff --git a/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts b/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts index 8ccb0dce8..760c1c0e3 100644 --- a/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts +++ b/apps/ai-game-creator-shell/tests/xhsMinitoolFields.test.ts @@ -1,5 +1,5 @@ /** - * 表单本地校验:必填、字数边界(按去空白后的码点数),以及「首个错误即返回」。 + * 表单本地校验:必填、字数边界(按去空白后的码点数),以及「一次返回全部出错字段」。 * * 宿主那侧不再校验表单,这份纯函数就是唯一的表单关;`xhsMinitoolExport.test.tsx` 只钉它接到 * `persist` 上的那一步,边界都在这里覆盖。 @@ -19,40 +19,55 @@ const VALID: XHSMiniToolExportForm = { }; describe('validateXhsMinitoolForm', () => { - it('三个字段都合法时通过', () => { - expect(validateXhsMinitoolForm(VALID)).toBeNull(); + it('三个字段都合法时返回空数组', () => { + expect(validateXhsMinitoolForm(VALID)).toEqual([]); }); it('按去空白后的码点数判字数:正好到上限通过,多一个码点报错', () => { const atLimit = '字'.repeat(XHS_MINITOOL_TEXT_MAX_CHARS); - expect(validateXhsMinitoolForm({ ...VALID, name: atLimit })).toBeNull(); + expect(validateXhsMinitoolForm({ ...VALID, name: atLimit })).toEqual([]); const over = `${atLimit}字`; - const error = validateXhsMinitoolForm({ ...VALID, name: over }); - expect(error?.field).toBe('name'); - expect(error?.reason).toContain(`${XHS_MINITOOL_TEXT_MAX_CHARS}`); + const errors = validateXhsMinitoolForm({ ...VALID, name: over }); + expect(errors).toHaveLength(1); + expect(errors[0]?.field).toBe('name'); + expect(errors[0]?.reason).toContain(`${XHS_MINITOOL_TEXT_MAX_CHARS}`); }); it('一个 emoji 也算一个字:`String.length` 数的是码元,这里按码点算', () => { const emojis = '🙂'.repeat(XHS_MINITOOL_TEXT_MAX_CHARS); - expect(validateXhsMinitoolForm({ ...VALID, name: emojis })).toBeNull(); - expect( - validateXhsMinitoolForm({ ...VALID, name: `${emojis}🙂` })?.field, - ).toBe('name'); + expect(validateXhsMinitoolForm({ ...VALID, name: emojis })).toEqual([]); + expect(validateXhsMinitoolForm({ ...VALID, name: `${emojis}🙂` })).toEqual([ + expect.objectContaining({ field: 'name' }), + ]); }); - it('只挡首个错误:先报名称,名称修好后才轮到简介', () => { - const empty = { name: ' ', introduction: ' ', iconPath: '' }; - expect(validateXhsMinitoolForm(empty)?.field).toBe('name'); - expect(validateXhsMinitoolForm({ ...empty, name: '有名字了' })?.field).toBe( + it('一次返回全部出错字段,顺序与表单一致', () => { + const errors = validateXhsMinitoolForm({ + name: ' ', + introduction: ' ', + iconPath: '', + }); + expect(errors.map((error) => error.field)).toEqual([ + 'name', 'introduction', - ); + 'iconPath', + ]); + }); + + it('只报没填的那几项:修好一个就少一条,不必等前一个通过才看得到后一个', () => { + const empty = { name: ' ', introduction: ' ', iconPath: '' }; + expect( + validateXhsMinitoolForm({ ...empty, name: '有名字了' }).map( + (error) => error.field, + ), + ).toEqual(['introduction', 'iconPath']); expect( validateXhsMinitoolForm({ ...empty, name: '有名字了', introduction: '有简介了', - })?.field, - ).toBe('iconPath'); + }).map((error) => error.field), + ).toEqual(['iconPath']); }); });