表单字段逐项报错并可交给陶泥儿处理
Project CI / Backend tests (pull_request) Failing after 22s
Project CI / Native shell tests (pull_request) Failing after 4m6s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m30s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m17s
Project CI / AI game creator shell web tests (pull_request) Failing after 35s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m2s
Project CI / Backend tests (pull_request) Failing after 22s
Project CI / Native shell tests (pull_request) Failing after 4m6s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m30s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m17s
Project CI / AI game creator shell web tests (pull_request) Failing after 35s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m2s
- xhsMinitoolFields.ts 的 validateXhsMinitoolForm 一次返回全部出错字段 - xhsMinitoolInstruction.ts 新增字段红字指令,示意里的字数上限改用 XHS_MINITOOL_TEXT_MAX_CHARS - useXhsMinitoolExport.ts 的 fieldError 改为 fieldErrors,新增 handFieldErrorsToAgent:交接后清红字与 dirty,让自动重读能采纳 agent 的修正 - FormCard.tsx 每个出错字段各挂红框与红字,标题下新增「交给陶泥儿」入口 - ArtifactsPane.tsx 接上新字段与新动作 - 补 xhsMinitoolFields / xhsMinitoolExport 测试覆盖多字段报错与交接
This commit is contained in:
+39
-22
@@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
buildXhsMinitoolAdaptationInstruction,
|
||||
buildXhsMinitoolErrorFixInstruction,
|
||||
buildXhsMinitoolFormErrorInstruction,
|
||||
buildXhsMinitoolFormFillInstruction,
|
||||
} from './xhsMinitoolInstruction';
|
||||
|
||||
@@ -86,9 +87,7 @@ export function useXhsMinitoolExport({
|
||||
}) {
|
||||
const [form, setForm] = useState<XHSMiniToolExportForm | null>(null);
|
||||
const [hasScript, setHasScript] = useState(false);
|
||||
const [fieldError, setFieldError] = useState<XhsMinitoolFieldError | null>(
|
||||
null,
|
||||
);
|
||||
const [fieldErrors, setFieldErrors] = useState<XhsMinitoolFieldError[]>([]);
|
||||
const [conflict, setConflict] = useState<SaveConflict | null>(null);
|
||||
const [failure, setFailure] = useState<XhsMinitoolCardFailure | null>(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<void> => {
|
||||
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,
|
||||
};
|
||||
|
||||
+10
-10
@@ -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(
|
||||
|
||||
+41
-2
@@ -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)}
|
||||
`;
|
||||
}
|
||||
|
||||
+2
-1
@@ -135,11 +135,12 @@ export function ArtifactsPane({
|
||||
formSlot = (
|
||||
<FormCard
|
||||
form={form}
|
||||
fieldError={minitool.fieldError}
|
||||
fieldErrors={minitool.fieldErrors}
|
||||
projectPath={projectPath}
|
||||
onChange={minitool.updateField}
|
||||
onDownloadIcon={downloadIcon}
|
||||
onAskAgentToFill={() => void minitool.fillForm()}
|
||||
onAskAgentAboutErrors={minitool.handFieldErrorsToAgent}
|
||||
/>
|
||||
);
|
||||
} else if (!minitool.readSettled) {
|
||||
|
||||
+30
-7
@@ -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 (
|
||||
<section
|
||||
@@ -64,10 +69,28 @@ export function FormCard({
|
||||
<span>帮我填</span>
|
||||
</PaneButton>
|
||||
</div>
|
||||
{fieldErrors.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-(--platform-warm-border) bg-(--platform-warm-bg) px-3 py-2">
|
||||
<p className="text-xs leading-relaxed text-(--platform-text-base)">
|
||||
有 {fieldErrors.length} 个字段不合平台限制。
|
||||
</p>
|
||||
<PaneButton
|
||||
variant="primary"
|
||||
ariaLabel="交给陶泥儿处理这些字段"
|
||||
title="把这些字段的问题与当前值原样发给陶泥儿,让它直接改注册表"
|
||||
disabled={!projectPath}
|
||||
onClick={onAskAgentAboutErrors}
|
||||
>
|
||||
<Sparkles size={13} aria-hidden="true" />
|
||||
<span>交给陶泥儿</span>
|
||||
</PaneButton>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-4">
|
||||
{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 (
|
||||
<IconField
|
||||
@@ -126,8 +149,8 @@ function FieldRow({
|
||||
onChange: (value: string) => 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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user