fix: 大小写转换组件选项值1/2不生效(选转换方式返回原文), CSV读写空路径给结构化报错, 补数据处理与文件族冒烟回归11项

This commit is contained in:
yu 2026-11-03 15:51:45 +08:00
parent cb349ec979
commit 58aae7c92f
2 changed files with 296 additions and 8 deletions

View File

@ -0,0 +1,286 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { readFileSync } from "node:fs";
import { afterAll, describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import "./data";
import "./data-extended";
import "./files";
import "./control";
const ctx = {
signal: new AbortController().signal,
log: () => {},
vars: {} as Record<string, unknown>,
evidenceDir: "D:/office-test2",
runId: "smoke-run",
flow: { id: "smoke-flow", name: "冒烟", description: "", enabled: true, trigger: null, meta: { appVersion: "dev", createdAt: "", updatedAt: "" }, nodes: [] }
} as unknown as RunContext;
const node = { id: "smoke-data", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} } as unknown as FlowNode;
async function run(componentId: string, args: Record<string, unknown>, context?: RunContext): Promise<unknown> {
const handler = getHandler(componentId);
if (!handler) throw new Error(`组件 ${componentId} 未注册运行时`);
return handler(context ?? ctx, { ...node, componentId } as unknown as FlowNode, args, {} as CatalogComponent);
}
const sandbox = mkdtempSync(path.join(tmpdir(), "rpa-data-smoke-"));
afterAll(() => { rmSync(sandbox, { recursive: true, force: true }); });
describe("数据处理/文件/编排 冒烟", () => {
it("字符串族 StrUtil", async () => {
expect(await run("StrUtil_Len", { string: "你好world" })).toBe(7);
expect(await run("StrUtil_InStr", { string: "hello", search: "ll" })).toBe(2);
expect(await run("StrUtil_Replace", { string: "a-b-c", search: "-", replace: "+" })).toBe("a+b+c");
expect(await run("StrUtil_LowerCase", { string: "AbC" })).toBe("abc");
expect(await run("StrUtil_UpperCase", { string: "AbC" })).toBe("ABC");
expect(await run("StrUtil_CaseChange", { string: "abc", case: "首字母大写" })).toBe("Abc");
expect(await run("StrUtil_CaseChange", { string: "abc", case: "1" })).toBe("ABC");
expect(await run("StrUtil_CaseChange", { string: "ABC", case: "2" })).toBe("abc");
expect(await run("StrUtil_Trim", { string: " x " })).toBe("x");
expect(await run("StrUtil_Left", { string: "hello", length: 2 })).toBe("he");
expect(await run("StrUtil_Right", { string: "hello", length: 2 })).toBe("lo");
expect(await run("StrUtil_Mid", { string: "hello", start: 1, length: 3 })).toBe("ell");
expect(await run("StrUtil_CutOutStrV1", { string: "a[内容]b", start_cut_pos: "a[", end: "]" })).toContain("内容");
expect(await run("StrUtil_Split", { string: "a,b,c", spliter: "," })).toEqual(["a", "b", "c"]);
expect(await run("StrUtil_SplitStr", { string: "a; b ;c", diy_symbol: ";" })).toEqual(["a", "b", "c"]);
expect(await run("StrUtil_AddToStr", { string: "尾", add_str: "头", add_type: "添加到前面", symbol: "-" })).toBe("头-尾");
expect(await run("StrUtil_ReverseStr", { string: "ab cd", letter_reverse: true })).toBe("dc ba");
expect(await run("StrUtil_ListToString", { str_list: ["a", "b"], connectors_custom: "|" })).toBe("a|b");
expect(await run("StrUtil_CompleteStr", { string: "7", sum_length: 3, com_string: "0", com_type: "左边补" })).toBe("007");
expect(await run("StrUtil_CharCode", { code: 65 })).toBe("A");
expect(await run("StrUtil_CharAsc", { char: "A" })).toBe(65);
expect(await run("StrUtil_CreateRandomStr", { string_length: 12 })).toHaveLength(12);
console.log("[字符串族] 20 项通过");
});
it("列表与集合族 List/Collection", async () => {
expect(await run("List_CreateList", { data: JSON.stringify([1, 2]) })).toEqual([1, 2]);
expect(await run("List_ListLength", { arrayRet: [1, 2, 3] })).toBe(3);
expect(await run("List_InsertList", { arrayRet: ["a", "c"], data: "b", insertType: "指定位置", position: 1 })).toEqual(["a", "b", "c"]);
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], data: "b" })).toEqual(["a", "c"]);
expect(await run("List_DeleteList", { arrayRet: ["a", "b", "c"], deleteType: "按位置", position: 0 })).toEqual(["b", "c"]);
expect(await run("List_GetListItem", { arrayRet: [10, 20], position: 1 })).toBe(20);
expect(await run("List_SetListItem", { arrayRet: [1, 2], data: 9, position: 0 })).toEqual([9, 2]);
expect(await run("List_GetIndex", { arrayRet: ["x", "y"], data: "y" })).toBe(1);
expect(await run("List_ListMerge", { arrayRet1: [1], arrayRet2: [2, 3] })).toEqual([1, 2, 3]);
expect(await run("List_ListDuplication", { arrayRet: [1, 1, 2] })).toEqual([1, 2]);
expect(await run("List_ListSorted", { arrayRet: [3, 1, 2] })).toEqual([1, 2, 3]);
expect(await run("List_ListIntersection", { arrayRet1: [1, 2, 3], arrayRet2: [2, 3, 4] })).toEqual([2, 3]);
expect(await run("List_ClearList", {})).toEqual([]);
expect(await run("Collection_Create", {})).toEqual([]);
const withItem = await run("Collection_Add", { collection: [], value: "a" }) as unknown[];
expect(withItem).toEqual(["a"]);
expect(await run("Collection_HasItem", { collection: ["a"], item: "a" })).toBe(true);
expect(await run("Collection_HasItem", { collection: ["a"], item: "z" })).toBe(false);
expect(await run("Collection_Count", { collection: ["a", "b"] })).toBe(2);
expect(await run("Collection_Remove", { collection: ["a", "b"], value: "a" })).toEqual(["b"]);
console.log("[列表/集合族] 18 项通过");
});
it("字典族 Dict", async () => {
expect(await run("Dict_Create", { value: JSON.stringify({ a: 1 }) })).toEqual({ a: 1 });
expect(await run("Dict_SetItem", { dictObj: { a: 1 }, key: "b", value: 2 })).toEqual({ a: 1, b: 2 });
expect(await run("Dict_GetItem", { dictObj: { a: 1 }, key: "a" })).toBe(1);
expect(await run("Dict_GetItem", { dictObj: {}, key: "x", default_value: "dft" })).toBe("dft");
expect(await run("Dict_Keys", { dictObj: { a: 1, b: 2 } })).toEqual(["a", "b"]);
expect(await run("Dict_Values", { dictObj: { a: 1, b: 2 } })).toEqual([1, 2]);
expect(await run("Dict_Items", { dictObj: { a: 1 } })).toEqual([["a", 1]]);
expect(await run("Dict_Delete", { dictObj: { a: 1, b: 2 }, key: "a" })).toEqual({ b: 2 });
expect(await run("Dict_Update", { dictObj: { a: 1 }, targetDict: { b: 2 } })).toEqual({ a: 1, b: 2 });
expect(await run("Dict_Clear", {})).toEqual({});
console.log("[字典族] 10 项通过");
});
it("数学族 Math 与正则族 Regex", async () => {
expect(await run("Math_Abs", { data: -3.5 })).toBe(3.5);
expect(await run("Math_Round", { data: 3.14159, digit: 2 })).toBe(3.14);
expect(await run("Math_Int", { data: 3.2, position: "向上取整" })).toBe(4);
expect(await run("Math_Sqrt", { data: 16 })).toBe(4);
expect(await run("Math_Pow", { x: 2, y: 10 })).toBe(1024);
expect(await run("Math_Sgn", { data: -9 })).toBe(-1);
expect(await run("Math_Exp", { data: 0 })).toBe(1);
expect(await run("Math_Hex", { num: 255 })).toBe("FF");
expect(await run("Math_Oct", { num: 8 })).toBe("10");
expect(await run("Math_Scale", { sourceNum: "1010", sourceNumScale: 2, targetNumScale: 10 })).toBe("10");
expect(await run("Math_Sin", { data: 90, unit: "角度" })).toBeCloseTo(1, 5);
expect(await run("Regex_Test", { text: "abc123", pattern: "\\d+" })).toBe(true);
expect(await run("Regex_TestNew", { text: "abc", pattern: "\\d" })).toBe(false);
expect(await run("Regex_FindAll", { text: "a1b22c333", pattern: "\\d+" })).toEqual(["1", "22", "333"]);
expect(await run("Regex_FindStr", { text: "订单A-100,订单B-200", pattern: "订单[AB]-(\\d+)", index: 1 })).toBe("100");
expect(await run("Regex_FindAllNew", { text: "2026-11-03", pattern: "\\d+" })).toEqual(["2026", "11", "03"]);
console.log("[数学/正则族] 16 项通过");
});
it("时间族 Time", async () => {
expect(String(await run("Time_Now", {}))).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
expect(String(await run("Time_Date", {}))).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(String(await run("Time_Time", {}))).toMatch(/^\d{2}:\d{2}:\d{2}$/);
const ts = await run("Time_TimeStamp", {}) as number;
expect(ts).toBeGreaterThan(1_700_000_000);
expect(await run("Time_Format", { timestamp: 1767000000, format: "YYYY-MM-DD" })).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(await run("Time_DatetimeToTimestamp", { datetime: "2026-01-01 00:00:00" })).toBe(typeof 1_767_225_600 === "number" ? Math.floor(new Date("2026-01-01 00:00:00").getTime() / 1000) : 0);
expect(await run("Time_Year", { datetime: "2026-11-03 10:20:30" })).toBe(2026);
expect(await run("Time_Month", { datetime: "2026-11-03 10:20:30" })).toBe(11);
expect(await run("Time_Day", { datetime: "2026-11-03 10:20:30" })).toBe(3);
expect(await run("Time_Hour", { datetime: "2026-11-03 10:20:30" })).toBe(10);
expect(await run("Time_Minute", { datetime: "2026-11-03 10:20:30" })).toBe(20);
expect(await run("Time_Second", { datetime: "2026-11-03 10:20:30" })).toBe(30);
expect(typeof (await run("Time_WeekDay", { datetime: "2026-11-03" }))).toBe("number");
expect(await run("Time_DateOffset_V1", { date_input: "2026-11-01", offset_unit_num: 2, offset_status: "加", get_type: "YYYY-MM-DD" })).toBe("2026-11-03");
expect(await run("Time_CalDate", { date_start: "2026-11-01", date_end: "2026-11-03", time_type: "天" })).toBeCloseTo(2, 5);
expect(await run("Time_TimeTypeConversion", { datetime: "2026-11-03 08:00:00", format: "YYYY/MM/DD" })).toBe("2026/11/03");
console.log("[时间族] 15 项通过");
});
it("JSON 与 CSV 读写回环", async () => {
expect(await run("JSON_Stringify", { string: { a: 1 } })).toBe('{"a":1}');
expect(await run("JSON_Parse", { string: '{"a":1}' })).toEqual({ a: 1 });
expect(await run("JSON_Parse", { string: "bad" })).toBeNull();
const csvPath = path.join(sandbox, "t.csv");
await run("CSV_WriteNew", { path: csvPath, table: [["名", "值"], ["a,b", "含\"引号\""], ["c", "d"]] });
const back = await run("CSV_ReadNew", { path: csvPath }) as string[][];
expect(back[0]).toEqual(["名", "值"]);
expect(back[1]).toEqual(["a,b", '含"引号"']);
console.log("[JSON/CSV] 通过(含引号逗号转义回环)");
});
it("Ini 族完整生命周期", async () => {
const iniPath = path.join(sandbox, "config.ini");
await run("Ini_Write", { file: iniPath, section: "server", key: "host", value: "127.0.0.1" });
await run("Ini_Write", { file: iniPath, section: "server", key: "port", value: "8080" });
await run("Ini_Write", { file: iniPath, section: "log", key: "level", value: "info" });
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "host" })).toBe("127.0.0.1");
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server", "log"]);
expect(await run("Ini_ListKey", { file: iniPath, section: "server" })).toEqual(["host", "port"]);
expect(await run("Ini_DeleteKey", { file: iniPath, section: "server", key: "port" })).toBe(true);
expect(await run("Ini_Read", { file: iniPath, section: "server", key: "port" })).toBe("");
expect(await run("Ini_DeleteSection", { file: iniPath, section: "log" })).toBe(true);
expect(await run("Ini_ListSection", { file: iniPath })).toEqual(["server"]);
console.log("[Ini族] 写读列举删除全链通过");
});
it("文件族全生命周期(沙盒目录)", async () => {
const dir = path.join(sandbox, "files");
const created = await run("File_CreateFile", { folder_path: dir, name: "a.txt" }) as string;
expect(created).toContain("a.txt");
await run("File_Write", { path: created, content: "第一行" });
await run("File_Write", { path: created, content: "第二行", append: true });
expect(await run("File_Read", { path: created })).toBe("第一行第二行");
expect(await run("File_FileExists", { path: created })).toBe(true);
expect(await run("File_PathExists", { path: path.join(dir, "nope.txt") })).toBe(false);
expect(await run("File_PathSize", { path: created })).toBeGreaterThan(0);
const info = await run("File_GetFileInfo", { file_path: created }) as { name: string; size: number };
expect(info.name).toBe("a.txt");
expect(info.size).toBeGreaterThan(0);
const copy = path.join(dir, "b.txt");
await run("File_Copy", { pathSrc: created, pathDest: copy });
expect(await run("File_Read", { path: copy })).toBe("第一行第二行");
const sub = path.join(dir, "sub");
await run("File_CreateFolder", { path: sub });
await run("File_Copy", { pathSrc: copy, pathDest: path.join(sub, "c.txt") });
const flat = await run("File_GetFileList", { folder_path: dir }) as string[];
expect(flat.length).toBe(2);
const all = await run("File_GetFileList", { folder_path: dir, recursive: true }) as string[];
expect(all.length).toBe(3);
const kw = await run("File_GetFileList", { folder_path: dir, key_word: "a." }) as string[];
expect(kw.length).toBe(1);
const moved = await run("File_Move", { pathSrc: copy, pathDest: path.join(dir, "moved.txt") });
expect(String(moved)).toContain("moved.txt");
expect(await run("File_FileExists", { path: copy })).toBe(false);
const renamed = await run("File_Rename", { pathSrc: moved, name: "renamed.txt" });
expect(String(renamed)).toContain("renamed.txt");
const zip = await run("File_CompressFile", { pathSrc: created, pathDest: path.join(sandbox, "pack.zip") });
expect(String(zip)).toContain("pack.zip");
const unzipDir = path.join(sandbox, "unpacked");
await run("File_DecompressFile", { path: path.join(sandbox, "pack.zip"), target: unzipDir });
expect(await run("File_FileExists", { path: path.join(unzipDir, "a.txt") })).toBe(true);
expect(await run("File_WaitFile", { file_path: created, time_out: 1 })).toBe(true);
await expect(run("File_WaitFile", { file_path: path.join(dir, "ghost.txt"), time_out: 1 })).rejects.toThrow(/超时/);
await run("File_Delete", { file_path: renamed });
expect(await run("File_FileExists", { path: renamed })).toBe(false);
console.log("[文件族] 建/写/读/拷/列举/移/改名/压缩解压/等待/删除 全链通过");
}, 60000);
it("数据表族 Bivartable", async () => {
const table = [["部门", "姓名", "工资"], ["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
const bodyOnly = [["技术", "张三", 100], ["销售", "李四", 80], ["技术", "王五", 90], ["销售", "李四", 80]];
const withHeader = await run("Bivartable_DoCreate", { data: JSON.stringify(bodyOnly), columnList: JSON.stringify(["部门", "姓名", "工资"]) }) as unknown[][];
expect(withHeader[0]).toEqual(["部门", "姓名", "工资"]);
expect(withHeader.length).toBe(5);
expect(await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]) })).toBe(350);
expect(await run("Bivartable_GetMaxValue", { df: table, maxvaluecols: JSON.stringify(["工资"]) })).toBe(100);
expect(await run("Bivartable_GetMinValue", { df: table, minvaluecols: JSON.stringify(["工资"]) })).toBe(80);
expect(await run("Bivartable_GetAvgValue", { df: table, avgvaluecols: JSON.stringify(["工资"]) })).toBe(87.5);
const grouped = await run("Bivartable_GetSumValue", { df: table, sumvaluecols: JSON.stringify(["工资"]), groupby: JSON.stringify(["部门"]) }) as unknown[][];
expect(grouped[0]).toEqual(["部门", "工资"]);
expect(grouped).toContainEqual(["技术", 190]);
expect(grouped).toContainEqual(["销售", 160]);
const filtered = await run("Bivartable_DoFilter", { df: table, col_name: "部门", choice: "1", criteria: "技术" }) as unknown[][];
expect(filtered.length).toBe(3);
const expr = await run("Bivartable_DoFilter", { df: table, rule: '["工资"] > 85' }) as unknown[][];
expect(expr.length).toBe(3);
const dedup = await run("Bivartable_DeleteDuplicateRow", { df: table }) as unknown[][];
expect(dedup.length).toBe(4);
console.log("[数据表族] 创建/聚合(含分组)/过滤(简单+表达式)/去重 通过");
});
it("编排控制与日志", async () => {
const varsCtx = { ...ctx, vars: {} } as unknown as RunContext;
expect(await run("logic_control_var", { var_name: "k1", var_value: "v1" }, varsCtx)).toBe("v1");
expect((varsCtx as unknown as { vars: Record<string, unknown> }).vars.k1).toBe("v1");
await expect(run("logic_control_var", {})).rejects.toThrow(/变量名称/);
await expect(run("logic_control_break_for", {})).rejects.toBeTruthy();
await expect(run("logic_control_next_for", {})).rejects.toBeTruthy();
await expect(run("logic_control_stop", {})).rejects.toBeTruthy();
await expect(run("logic_control_stop_return", {})).rejects.toBeTruthy();
expect(await run("Trigger.Manual", {})).toBeNull();
expect(await run("Trigger.Schedule", {})).toBeNull();
expect(await run("Trigger.FileArrival", {})).toBeNull();
const logs: Array<{ m: string; lv: string }> = [];
const logCtx = { ...ctx, log: (m: string, lv: string) => logs.push({ m, lv }) } as unknown as RunContext;
expect(await run("Log_Info", { msg: "提示信息" }, logCtx)).toBe("提示信息");
expect(await run("Log_Error", { msg: "错误信息" }, logCtx)).toBe("错误信息");
expect(logs.some((l) => l.lv === "error")).toBe(true);
await expect(run("ExceptionUtil_Throws", { msg: "自定义异常" })).rejects.toThrow(/自定义异常/);
console.log("[编排/日志] 变量/循环控制信号/触发器/日志/自定义异常 通过");
});
it("全量健康检查: 已注册组件空参调用不产生非结构化崩溃", async () => {
const ids: string[] = JSON.parse(
readFileSync("D:/wordkb/2026-10-28-10-36-47/PRA-only-demo/live-ids.json", "utf8")
) as string[];
const skip = new Set([
"FTP_Connect", "FTP_Upload", "FTP_Download", "FTP_Delete", "FTP_Rename", "FTP_Cwd", "FTP_GetFileList", "FTP_CreateDir", "FTP_Exist", "FTP_Disconnect",
"Database_DBConnect", "Database_SQLExecute", "Database_SingleSQLQuery", "Database_AllSQLQuery_V1", "Database_BatchSQLExecute", "Database_BatchInsertExecute", "Database_CloseDBConnect",
"File_OpenFile", "File_StartBusiness", "File_UploadBusinessResults", "File_UploadBusinessResults_v1", "File_Collectupload",
"File_Delete", "File_DeleteFile", "File_EmptyFolder", "File_WaitFile",
"logic_control_break_for", "logic_control_next_for", "logic_control_stop", "logic_control_stop_return",
"child_modules_v1", "concurrent_child_process_v1", "Element_exception_monitor_v2", "logic_control_if_ai_vision"
]);
const crashes: Array<{ id: string; error: string }> = [];
let checked = 0;
for (const id of ids) {
const handler = getHandler(id);
if (!handler || skip.has(id)) continue;
checked += 1;
try {
await handler(ctx, { ...node, componentId: id } as unknown as FlowNode, {}, {} as CatalogComponent);
} catch (error) {
if (!(error instanceof RuntimeError)) {
crashes.push({ id, error: `${(error as Error).name}: ${(error as Error).message}` });
}
}
}
console.log(`[全量健康检查] 空参调用 ${checked} 个已注册组件, 结构化报错全部正常, 非结构化崩溃 ${crashes.length} 个`);
if (crashes.length) console.log(crashes.map((c) => `${c.id}: ${c.error}`).join("\n"));
expect(crashes).toEqual([]);
}, 120000);
});

View File

@ -120,9 +120,10 @@ register(["StrUtil_UpperCase"], (_ctx, _node, args) => str(first(args, ["string"
register("StrUtil_CaseChange", (_ctx, _node, args) => {
const text = str(first(args, ["string"]));
const mode = str(first(args, ["case"]));
if (mode.includes("小写")) return text.toLowerCase();
if (mode.includes("大写")) return text.toUpperCase();
// 目录选项值为 "1"(大写)/"2"(小写),文字模式兼容 大写/小写/首字母大写
if (mode.includes("首")) return text ? text[0].toUpperCase() + text.slice(1) : "";
if (mode === "1" || mode.includes("大写")) return text.toUpperCase();
if (mode === "2" || mode.includes("小写")) return text.toLowerCase();
return text;
});
register("StrUtil_Trim", (_ctx, _node, args) => str(first(args, ["string"])).trim());
@ -463,18 +464,19 @@ function toCsv(rows: unknown[][]): string {
}
register(["CSV_Read", "CSV_ReadNew"], async (_ctx, node, args) => {
const filePath = path.resolve(str(first(args, ["path"])));
const filePath = str(first(args, ["path"])).trim();
if (!filePath) throw new RuntimeError("validation_error", "CSV 路径为空", { nodeId: node.id, componentId: node.componentId });
const text = await fs.readFile(filePath, "utf8");
const text = await fs.readFile(path.resolve(filePath), "utf8");
return parseCsv(text);
});
register(["CSV_Write", "CSV_WriteNew"], async (_ctx, node, args) => {
const filePath = path.resolve(str(first(args, ["path"])));
const filePath = str(first(args, ["path"])).trim();
if (!filePath) throw new RuntimeError("validation_error", "CSV 路径为空", { nodeId: node.id, componentId: node.componentId });
const table = asArray(first(args, ["table"])).map((row) => asArray(row));
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, toCsv(table), "utf8");
return filePath;
const resolved = path.resolve(filePath);
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.writeFile(resolved, toCsv(table), "utf8");
return resolved;
});
/* ---------------- Basic ---------------- */