fix: E2E mock 统一 + CSV 引号字段解析

E2E:
- createIpcMock 支持 overrides 参数,保持 default: throw 行为
- search-clean.spec.ts 删除 30 行内联 mock,改用 createIpcMock(overrides)

Rust:
- 新增 parse_csv_line: 支持引号包裹字段 + 双引号转义 (RFC 4180 子集)
- import_csv 改用 parse_csv_line 替代 split(',')
- 与 TS 端 parseCsvLine 逻辑对称

测试: Rust 48 → 52 (+4), Frontend 100

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 23:51:28 +08:00
parent 461ef231e4
commit bce2dc8641
3 changed files with 77 additions and 28 deletions
+66 -5
View File
@@ -64,6 +64,39 @@ fn import_json(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
Ok((sanitize_paths(data.system), sanitize_paths(data.user))) Ok((sanitize_paths(data.system), sanitize_paths(data.user)))
} }
/// 解析 CSV 行,支持引号包裹的字段(RFC 4180 子集)
/// 与 TS 端 src/core/import-export.ts parseCsvLine 逻辑一致
fn parse_csv_line(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if in_quotes {
if ch == '"' {
if chars.peek() == Some(&'"') {
current.push('"');
chars.next(); // 跳过转义引号
} else {
in_quotes = false;
}
} else {
current.push(ch);
}
} else if ch == '"' {
in_quotes = true;
} else if ch == ',' {
fields.push(current);
current = String::new();
} else {
current.push(ch);
}
}
fields.push(current);
fields
}
fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> { fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
let mut sys = Vec::new(); let mut sys = Vec::new();
let mut usr = Vec::new(); let mut usr = Vec::new();
@@ -81,17 +114,17 @@ fn import_csv(content: &str) -> Result<(Vec<String>, Vec<String>), String> {
trimmed = stripped; trimmed = stripped;
} }
// 跳过 header 行 "type,path" // 跳过 header 行 "type,path"
let fields: Vec<&str> = trimmed.split(',').collect(); let header_fields = parse_csv_line(trimmed);
if fields.len() >= 2 { if header_fields.len() >= 2 {
let c0 = fields[0].trim().to_lowercase(); let c0 = header_fields[0].trim().to_lowercase();
let c1 = fields[1].trim().to_lowercase(); let c1 = header_fields[1].trim().to_lowercase();
if c0 == "type" && c1 == "path" { if c0 == "type" && c1 == "path" {
continue; continue;
} }
} }
} }
let fields: Vec<&str> = trimmed.split(',').collect(); let fields = parse_csv_line(trimmed);
if fields.len() >= 2 { if fields.len() >= 2 {
match fields[0].trim().to_lowercase().as_str() { match fields[0].trim().to_lowercase().as_str() {
"system" | "sys" => sys.push(fields[1].trim().to_string()), "system" | "sys" => sys.push(fields[1].trim().to_string()),
@@ -303,4 +336,32 @@ mod tests {
let result = sanitize_paths(vec![" ".into(), "C:\\ok".into()]); let result = sanitize_paths(vec![" ".into(), "C:\\ok".into()]);
assert_eq!(result, vec!["C:\\ok"]); assert_eq!(result, vec!["C:\\ok"]);
} }
#[test]
fn parse_csv_line_basic() {
assert_eq!(parse_csv_line("a,b,c"), vec!["a", "b", "c"]);
}
#[test]
fn parse_csv_line_quoted_comma() {
assert_eq!(
parse_csv_line(r#"system,"C:\Program Files, Inc\bin""#),
vec!["system", r#"C:\Program Files, Inc\bin"#]
);
}
#[test]
fn parse_csv_line_escaped_quotes() {
assert_eq!(
parse_csv_line(r#"system,"He said ""hello""""#),
vec!["system", r#"He said "hello""#]
);
}
#[test]
fn import_csv_quoted_comma_path() {
let csv = "type,path\nsystem,\"C:\\Program Files, Inc\\bin\"\n";
let (sys, _) = import_csv(csv).unwrap();
assert_eq!(sys, vec!["C:\\Program Files, Inc\\bin"]);
}
} }
+5 -1
View File
@@ -1,7 +1,11 @@
export function createIpcMock() { export type IpcOverrides = Partial<Record<string, unknown>>;
export function createIpcMock(overrides: IpcOverrides = {}) {
return ` return `
window.__TAURI_INTERNALS__ = { window.__TAURI_INTERNALS__ = {
invoke: async (cmd, args) => { invoke: async (cmd, args) => {
const overrides = ${JSON.stringify(overrides)};
if (cmd in overrides) return overrides[cmd];
switch (cmd) { switch (cmd) {
case 'check_admin': return true; case 'check_admin': return true;
case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files']; case 'load_system_paths': return ['C:\\\\Windows', 'C:\\\\Program Files'];
+6 -22
View File
@@ -1,28 +1,12 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { createIpcMock } from '../mocks/ipc';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.addInitScript(() => { await page.addInitScript(createIpcMock({
window.__TAURI_INTERNALS__ = { load_system_paths: ['C:\\Windows', 'invalid_path', 'C:\\Temp'],
invoke: async (cmd, _args) => { load_user_paths: [],
switch (cmd) { validate_path: false,
case 'check_admin': return true; }));
case 'load_system_paths': return ['C:\\\\Windows', 'invalid_path', 'C:\\\\Temp'];
case 'load_user_paths': return [];
case 'load_disabled_state': return { system: [], user: [] };
case 'save_system_paths': return undefined;
case 'save_user_paths': return undefined;
case 'save_disabled_state': return undefined;
case 'backup_registry': return '';
case 'broadcast_env_change': return undefined;
case 'validate_path': return false;
case 'expand_env_vars': return '';
case 'read_text_file': return '';
case 'get_appdata_dir': return '';
default: return undefined;
}
}
};
});
await page.goto('/'); await page.goto('/');
}); });