fix: 补齐 CaseRun UART 权威观测

This commit is contained in:
root
2026-07-16 19:49:17 +02:00
parent af4962e138
commit a9570ceedf
3 changed files with 81 additions and 16 deletions
@@ -25,6 +25,21 @@ const DOCUMENT = {
}
};
function executePythonNodePlan(plan: unknown) {
const pythonScript = `
import importlib.util, json, logging, sys
spec = importlib.util.spec_from_file_location("hwlab_node", "tools/hwlab-node.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plan = json.loads(sys.argv[1])
executor = module.NodeOpsExecutor({"nodeId": plan["nodeId"]}, logging.getLogger("test"))
print(json.dumps(executor.execute(plan)))
`;
const python = spawnSync("python3", ["-c", pythonScript, JSON.stringify(plan)], { cwd: process.cwd(), encoding: "utf8" });
assert.equal(python.status, 0, python.stderr);
return JSON.parse(python.stdout);
}
test("CaseRun derives compile-only and runtime validation plans from case definition", () => {
const compile = caseValidationPlanFromDefinition({});
assert.equal(compile.mode, "compile-only");
@@ -64,18 +79,7 @@ test("HWPOD node owns ioProbe command output normalization before CaseRun projec
document.spec.ioProbe.endpoints.main41.command = `${process.execPath} ${fakeBoardComm}`;
const plan = compileHwpodNodeOpsPlan({ document, intent: "io.probe.read", args: { probeId: "main41.ai0.current", count: 3 } });
const nodeResult = await executeHwpodNodeOpsPlan(plan, { now: () => "2026-07-16T00:00:00.000Z" });
const pythonScript = `
import importlib.util, json, logging, sys
spec = importlib.util.spec_from_file_location("hwlab_node", "tools/hwlab-node.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
plan = json.loads(sys.argv[1])
executor = module.NodeOpsExecutor({"nodeId": plan["nodeId"]}, logging.getLogger("test"))
print(json.dumps(executor.execute(plan)))
`;
const python = spawnSync("python3", ["-c", pythonScript, JSON.stringify(plan)], { cwd: process.cwd(), encoding: "utf8" });
assert.equal(python.status, 0, python.stderr);
const pythonResult = JSON.parse(python.stdout);
const pythonResult = executePythonNodePlan(plan);
assert.equal(nodeResult.ok, true);
assert.equal(nodeResult.results[0].output.observation.probeId, "main41.ai0.current");
@@ -96,6 +100,46 @@ print(json.dumps(executor.execute(plan)))
}
});
test("HWPOD TS and Python nodes project identical UART observations before CaseRun normalization", async () => {
const workspacePath = await mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-uart-observation-"));
try {
const serialMonitor = path.join(workspacePath, "serial-monitor-fixture.mjs");
await writeFile(serialMonitor, `
const args = process.argv.slice(2).join(" ");
if (args === "monitor status") console.log(JSON.stringify({ success: true, data: { isMonitoring: true, port: "COM9", baudRate: 115200 } }));
else if (args.startsWith("fetch")) console.log(JSON.stringify({ success: true, data: [{ timestamp: "2026-07-16T00:00:00Z", port: "COM9", data: "UART READY" }], count: 1, totalCount: 1, hasMore: false, truncated: false, sourceFile: "artifacts/uart/session.jsonl" }));
else { console.error("unsupported " + args); process.exit(2); }
`, "utf8");
const document = structuredClone(DOCUMENT);
document.spec.workspace.path = workspacePath;
document.spec.ioProbe.uart = { id: "uart1", port: "COM9", baudrate: 115200 };
document.spec.tooling = { serialMonitorDir: workspacePath, serialMonitorCommand: `${process.execPath} ${serialMonitor}` };
const plan = compileHwpodNodeOpsPlan({ document, intent: "io.uart.read", args: { port: "uart1", maxBytes: 128 } });
const nodeResult = await executeHwpodNodeOpsPlan(plan, { now: () => "2026-07-16T00:00:00.000Z" });
const pythonResult = executePythonNodePlan(plan);
assert.equal(nodeResult.ok, true);
assert.equal(pythonResult.ok, true);
assert.deepEqual(pythonResult.results[0].output.observation, nodeResult.results[0].output.observation);
assert.equal(nodeResult.results[0].output.observation.text, "UART READY");
assert.equal(nodeResult.results[0].output.observation.rawArtifactRef, "artifacts/uart/session.jsonl");
const validationPlan = caseValidationPlanFromDefinition({ validation: { mode: "download-uart", uart: { port: "uart1" } } });
const normalized = normalizeValidationStepResult({ step: validationPlan.steps[2], document, exitCode: 0, payload: { body: nodeResult } });
assert.equal(normalized.blocker, undefined);
assert.equal(normalized.observation.evidenceLevel, "serial-log");
assert.equal(normalized.observation.rawArtifactRef, "artifacts/uart/session.jsonl");
const stdoutOnly = structuredClone(nodeResult);
stdoutOnly.results[0].output = { workspacePath, stdout: JSON.stringify(nodeResult.results[0].output.observation) };
const rejected = normalizeValidationStepResult({ step: validationPlan.steps[2], document, exitCode: 0, payload: { body: stdoutOnly } });
assert.equal(rejected.observation, undefined);
assert.equal(rejected.blocker.code, "hwpod_validation_observation_missing");
} finally {
await rm(workspacePath, { recursive: true, force: true });
}
});
test("CaseRun normalizes authoritative ioProbe statistics and replay relationships", () => {
const plan = caseValidationPlanFromDefinition({ validation: { mode: "io-probe", ioProbe: { probeId: "main41.ai0.current", quantity: "current", unit: "mA" } } });
const step = plan.steps[2];
+8 -1
View File
@@ -803,7 +803,14 @@ class NodeOpsExecutor:
rows = fetch_body.get("data") if isinstance(fetch_body.get("data"), list) else []
text = "\n".join(str(row.get("data")) for row in rows if isinstance(row, dict) and row.get("data"))
max_bytes = safe_int(args.get("maxBytes"), 16384)
return {"ok": True, "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": active_port or port or None, "baudRate": baud or active_baud, "command": [*command_base, *fetch_args], "data": rows, "count": fetch_body.get("count", len(rows)), "totalCount": fetch_body.get("totalCount"), "hasMore": fetch_body.get("hasMore", False), "text": text[:max_bytes], "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "monitorStarted": not matches, "monitorStart": monitor_start, "sourceFile": fetch_body.get("sourceFile")}
truncated_text = text[:max_bytes]
resolved_port = active_port or port or None
resolved_baud = baud or active_baud
source_file = str(fetch_body.get("sourceFile") or "")
observation = {"observationId": f"uart:{resolved_port or 'unknown'}:{source_file or 'session'}", "port": resolved_port, "baudRate": resolved_baud, "data": rows, "text": truncated_text, "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True}
if source_file:
observation["rawArtifactRef"] = source_file
return {"ok": True, "workspacePath": str(self._workspace_root(args)), "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": resolved_port, "baudRate": resolved_baud, "command": [*command_base, *fetch_args], "data": rows, "count": fetch_body.get("count", len(rows)), "totalCount": fetch_body.get("totalCount"), "hasMore": fetch_body.get("hasMore", False), "text": truncated_text, "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "monitorStarted": not matches, "monitorStart": monitor_start, "sourceFile": source_file or None, "observation": observation}
def _parse_json(self, text: object) -> dict:
try:
+17 -3
View File
@@ -1408,13 +1408,26 @@ async function uartRead(args: any) {
const rows = Array.isArray(fetchBody.data) ? fetchBody.data : [];
const joined = rows.map((row: any) => text(row?.data)).filter(Boolean).join("\n");
const truncatedText = joined.length > maxBytes ? joined.slice(0, maxBytes) : joined;
const resolvedPort = text(statusData.port) || targetPort;
const baudRate = numberValue(statusData.baudRate) || targetBaudRate || activeBaudRate || null;
const sourceFile = text(fetchBody.sourceFile);
const observation = {
observationId: `uart:${resolvedPort || "unknown"}:${sourceFile || "session"}`,
port: resolvedPort,
baudRate,
data: rows,
text: truncatedText,
truncated: joined.length > maxBytes || fetchBody.truncated === true,
...(sourceFile ? { rawArtifactRef: sourceFile } : {})
};
return {
ok: true,
workspacePath: workspaceRoot(args),
bindingSource: "serial-monitor-cli",
serialMonitorDir,
requestedPort: diagnostics.requestedPort,
resolvedPort: text(statusData.port) || targetPort,
baudRate: numberValue(statusData.baudRate) || targetBaudRate || activeBaudRate || null,
resolvedPort,
baudRate,
command: [...commandBase, ...fetchArgs],
data: rows,
count: fetchBody.count ?? rows.length,
@@ -1425,7 +1438,8 @@ async function uartRead(args: any) {
monitorStatus: statusBody ?? monitorStatus,
monitorStarted: !monitoringMatches,
monitorStart,
sourceFile: fetchBody.sourceFile ?? null
sourceFile: sourceFile || null,
observation
};
}