test: validate capability property schemas and options

This commit is contained in:
yu 2026-11-02 15:38:21 +08:00
parent 541908961b
commit f8859fed77
4 changed files with 12569 additions and 800 deletions

View File

@ -6,13 +6,34 @@
"defaultValue": "number",
"description": "选择要判断的目标类型。历史默认值 1 兼容数值类型。",
"options": [
{ "label": "数值", "value": "number" },
{ "label": "整数", "value": "int" },
{ "label": "空值", "value": "null" },
{ "label": "字典", "value": "dict" },
{ "label": "数组", "value": "list" },
{ "label": "文本", "value": "string" },
{ "label": "布尔", "value": "bool" }
{
"label": "数值",
"value": "number"
},
{
"label": "整数",
"value": "int"
},
{
"label": "空值",
"value": "null"
},
{
"label": "字典",
"value": "dict"
},
{
"label": "数组",
"value": "list"
},
{
"label": "文本",
"value": "string"
},
{
"label": "布尔",
"value": "bool"
}
]
}
}
@ -24,10 +45,22 @@
"defaultValue": "number",
"description": "选择目标类型。历史默认值 1 兼容数值;其他数字别名 2=整数、3=布尔、4=文本。",
"options": [
{ "label": "数值", "value": "number" },
{ "label": "整数", "value": "int" },
{ "label": "布尔", "value": "bool" },
{ "label": "文本", "value": "string" }
{
"label": "数值",
"value": "number"
},
{
"label": "整数",
"value": "int"
},
{
"label": "布尔",
"value": "bool"
},
{
"label": "文本",
"value": "string"
}
]
}
}
@ -36,8 +69,14 @@
"fields": {
"close_type": {
"options": [
{ "label": "按进程 PID", "value": "id" },
{ "label": "按进程名称", "value": "name" }
{
"label": "按进程 PID",
"value": "id"
},
{
"label": "按进程名称",
"value": "name"
}
]
}
}
@ -47,9 +86,18 @@
"command_type": {
"label": "命令类型",
"options": [
{ "label": "DOS 命令", "value": "1" },
{ "label": "BAT 文件", "value": "2" },
{ "label": "PowerShell 命令", "value": "3" }
{
"label": "DOS 命令",
"value": "1"
},
{
"label": "BAT 文件",
"value": "2"
},
{
"label": "PowerShell 命令",
"value": "3"
}
]
}
}
@ -57,12 +105,70 @@
"System_ScreenSaverShowV1": {
"description": "开启 Windows 已配置的系统屏保。当前组件不支持自定义屏保文字、透明度或解锁密码;登录保护遵循 Windows 系统设置。",
"messageTemplate": "开启 Windows 系统屏保",
"removeFields": ["text", "effect", "status", "set_password", "password"]
"removeFields": [
"text",
"effect",
"status",
"set_password",
"password"
]
},
"ScreenControl_ScreenUnlock": {
"name": "唤醒屏幕(非登录解锁)",
"description": "仅尝试唤醒屏幕,不支持通过账号密码自动完成 Windows 登录解锁。锁屏后仍需本人手动登录。",
"messageTemplate": "尝试唤醒屏幕;Windows 登录锁屏需人工解锁",
"removeFields": ["username", "pwd"]
"removeFields": [
"username",
"pwd"
]
},
"Mail_DoGetMailMessage_v2": {
"fields": {
"attachment": {
"defaultValue": "2"
}
}
},
"Mail_DoMoveMail_v1": {
"fields": {
"email_folder": {
"defaultValue": "INBOX",
"options": [
{
"label": "收件箱",
"value": "INBOX"
},
{
"label": "已发送",
"value": "Sent"
},
{
"label": "其他文件夹",
"value": "Other"
}
]
}
}
},
"Mail_FindMail_v2": {
"fields": {
"attachment": {
"defaultValue": "2",
"options": [
{
"label": "包含附件",
"value": "1"
},
{
"label": "不包含附件",
"value": "0"
},
{
"label": "不限",
"value": "2"
}
]
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -136,6 +136,20 @@ function groupFor(component) {
return "data";
}
function normalizeOptions(value) {
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === "string") return { label: item, value: item };
if (typeof item !== "object" || item === null) return null;
const label = String(item.label ?? item.text ?? item.value ?? "");
const rawValue = item.value ?? item.label ?? "";
return { label, value: cleanDefault(rawValue) };
}).filter((item) => item.label || item.value);
}
if (Array.isArray(value?.options)) return normalizeOptions(value.options);
return [];
}
function normalizeVendorField(field) {
const widget = widgetFor(field.displayType, field.widget);
const valueTypes = Array.isArray(field.valueTypes) ? [...field.valueTypes] : [];

View File

@ -0,0 +1,89 @@
import type { Catalog, CatalogComponent, CatalogField } from "../../shared/types";
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { hasCapability } from "../engine/registry";
import "./index";
const widgets = new Set(["text","textarea","number","select","radio","boolean","file","folder","element","code","secret","region","hidden"]);
const runtimeContainers = new Set([
"logic_control_if_else","logic_control_count_for","logic_control_array_for_v3","logic_control_dictionary_for_v1",
"logic_control_while","infinite_loop","logic_control_try_catch"
]);
// 这些属性在运行时由上游对象 / 外部系统动态提供,组件库中退化为表达式输入。
const dynamicSelects = new Set([
"Database_BatchSQLExecute|connectionID","Database_BatchInsertExecute|connectionID","Database_SQLExecute|connectionID",
"DataService_Review|reviewTemplate","DataService_ChatBot|chatbot","DataService_ChatBot|botChannel",
"DataService_KeyWord|keyWordTemplate","DataService_ZBrain|z_barin","Element_Sibling_V2|position",
"Element_SetCheck|isChecked","Element_ElementAarray|browser_type","Excel_Cs_Interop|obj","Excel_Cs_Interop|property",
"Mail_DoReplyMail_v1|account_obj","Mail_FindMail_v1|email_num","Mail_DoGetMailMessage_v2|account_obj",
"Mail_DoGetMailMessage_v2|email_num","Mail_DoGetMailMessage_v1|account_obj","Mail_DoGetMailMessage_v1|email_num",
"Mail_FindMail_v2|email_num","Mail_DoMoveMail_v1|account_obj","Mail_DoSendMail_v1|account_obj",
"OCR_MergeCardOCR|category","OCR_MergeOtherOCR|category","OCR_MergePaperOCR|category","OCR_MergeCarOCR|category",
"OCR_MergeEducationOCR|category","WebBrowser_GetTAB|type","WebBrowser_GetTAB|isActive","WebBrowser_GetTAB|isRemote",
"WebBrowser_BatchDataCrawl|browserType"
]);
describe("catalog capability properties", () => {
it("exposes complete metadata for every canonical capability", async () => {
const catalog: Catalog = JSON.parse(await readFile("resources/catalog.json", "utf8"));
const excluded = JSON.parse(await readFile("resources/excluded.json", "utf8"));
const duplicates = JSON.parse(await readFile("resources/capability-duplicates.json", "utf8"));
const excludedIds = new Set(excluded.ids);
const hidden = new Set((duplicates.decisions as Array<{ id: string; status: string; canonical?: string }>).filter((item) => item.status === "hide").map((item) => item.id));
const visible: CatalogComponent[] = catalog.components.filter((component) => !excludedIds.has(component.id) && !hidden.has(component.id));
expect(visible).toHaveLength(658);
const ids = visible.map((component) => component.id);
expect(new Set(ids).size).toBe(ids.length);
for (const component of visible) {
expect(component.id, component.id).toBeTruthy();
expect(component.name, component.id).toBeTruthy();
expect(component.group, component.id).toBeTruthy();
expect(component.kind, component.id).toBeTruthy();
expect(component.icon, component.id).toBeTruthy();
expect(["low","high"], component.id).toContain(component.risk);
expect(Array.isArray(component.fields), component.id).toBe(true);
expect(Array.isArray(component.slots), component.id).toBe(true);
expect(component.output, component.id).toBeTruthy();
const fieldKeys = component.fields.map((field: CatalogField) => field.key);
expect(new Set(fieldKeys).size, component.id).toBe(fieldKeys.length);
for (const field of component.fields as CatalogField[]) {
expect(field.key, component.id).toBeTruthy();
expect(field.label, component.id + ":" + field.key).toBeTruthy();
expect(widgets.has(field.widget), component.id + ":" + field.key + ":" + field.widget).toBe(true);
expect(["string","number","boolean","object"], component.id + ":" + field.key).toContain(field.type);
if ((field.widget === "select" || field.widget === "radio") && !field.options?.length) {
expect(dynamicSelects.has(component.id + "|" + field.key), component.id + ":" + field.key).toBe(true);
}
if (Array.isArray(field.options) && field.options.length) {
const values = field.options.map((option: string | { label: string; value: string }) => typeof option === "string" ? option : option.value);
expect(values.every((value) => typeof value === "string"), component.id + ":" + field.key).toBe(true);
expect(new Set(values).size, component.id + ":" + field.key).toBe(values.length);
if (field.defaultValue) expect(values, component.id + ":" + field.key).toContain(field.defaultValue);
}
if (field.widget === "number" && field.defaultValue) expect(Number.isFinite(Number(field.defaultValue)), component.id + ":" + field.key).toBe(true);
if (field.widget === "boolean" && field.defaultValue) {
expect(["true","false","1","0"], component.id + ":" + field.key).toContain(String(field.defaultValue).toLowerCase());
}
}
const outputKeys = component.output.fields.map((field: { key: string }) => field.key);
expect(new Set(outputKeys).size).toBe(outputKeys.length);
}
});
it("binds every canonical capability to a runtime handler or container", async () => {
const catalog: Catalog = JSON.parse(await readFile("resources/catalog.json", "utf8"));
const excluded = JSON.parse(await readFile("resources/excluded.json", "utf8"));
const duplicates = JSON.parse(await readFile("resources/capability-duplicates.json", "utf8"));
const excludedIds = new Set(excluded.ids);
const hidden = new Set((duplicates.decisions as Array<{ id: string; status: string; canonical?: string }>).filter((item) => item.status === "hide").map((item) => item.id));
const visible: CatalogComponent[] = catalog.components.filter((component) => !excludedIds.has(component.id) && !hidden.has(component.id));
const missing = visible.filter((component) =>
!runtimeContainers.has(component.id) && !hasCapability(component.id) && !hasCapability(component.functionName)
);
expect(missing.map((component) => component.id)).toEqual([]);
});
});