test(cli): remove Python parity dependency

This commit is contained in:
2026-08-06 15:58:47 +08:00
parent 258f4996eb
commit a5e91ac2b8
2 changed files with 92 additions and 78 deletions
+87 -78
View File
@@ -8,7 +8,59 @@ import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
const pythonCliCwd = resolve(dirname(fileURLToPath(import.meta.url)), "../../../TJWaterServerBinary/cli");
const visibleCommandPaths = [
"analysis age",
"analysis burst",
"analysis burst-detection detect",
"analysis burst-detection schemes get",
"analysis burst-detection schemes list",
"analysis contaminant",
"analysis flushing",
"analysis leakage identify",
"analysis leakage schemes get",
"analysis leakage schemes list",
"analysis sensor-placement kmeans",
"analysis valve",
"component option get",
"component option schema",
"data scada get",
"data scada list",
"data scheme get",
"data scheme list",
"data scheme schema",
"data timeseries composite",
"data timeseries composite pipeline-health",
"data timeseries realtime links",
"data timeseries realtime nodes",
"data timeseries realtime simulation-by-id-time",
"data timeseries realtime simulation-by-time-property",
"data timeseries scada query",
"data timeseries scheme links",
"data timeseries scheme node-field",
"data timeseries scheme simulation",
"network get-all-pipes-properties",
"network get-all-pumps-properties",
"network get-all-reservoirs-properties",
"network get-all-tanks-properties",
"network get-all-valves-properties",
"network get-junction-properties",
"network get-pipe-properties",
"network get-pump-properties",
"network get-reservoir-properties",
"network get-tank-properties",
"network get-valve-properties",
"simulation run",
];
const hiddenCommandPaths = [
"analysis burst-location locate",
"analysis burst-location schemes get",
"analysis burst-location schemes list",
"analysis risk network",
"analysis risk pipe-history",
"analysis risk pipe-now",
];
function runCommand(command, args, input, options = {}) {
return new Promise((resolveRun, reject) => {
@@ -35,10 +87,6 @@ function runCli(args, input) {
return runCommand(cliPath, args, input);
}
function runPythonCli(args, input) {
return runCommand("python", ["-m", "tjwater_cli", ...args], input, { cwd: pythonCliCwd });
}
function parseJsonResult(result) {
return JSON.parse(result.stdout);
}
@@ -118,74 +166,31 @@ test("emits structured JSON help compatible with tjwater-cli/v1", async () => {
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
});
test("matches Python CLI help discovery and hidden command behavior", async () => {
for (const args of [["help"], ["help", "analysis"]]) {
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
assert.equal(nodeResult.exitCode, pythonResult.exitCode);
assert.deepEqual(parseJsonResult(nodeResult), parseJsonResult(pythonResult));
test("discovers every visible command and keeps internal commands hidden", async () => {
const rootResult = await runCli(["help"]);
assert.equal(rootResult.exitCode, 0, rootResult.stderr);
assert.deepEqual(
parseJsonResult(rootResult).commands.map(({ command }) => command),
["analysis", "component", "data", "network", "simulation"],
);
for (const command of visibleCommandPaths) {
const result = await runCli(["help", ...command.split(" ")]);
assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
const payload = parseJsonResult(result);
assert.equal(payload.ok, true, command);
assert.equal(payload.command, command, command);
assert.equal(payload.schema_version, "tjwater-cli/v1", command);
assert.ok(payload.usage, `${command}: missing usage`);
assert.ok(payload.examples.length > 0, `${command}: missing examples`);
}
const [nodeLeaf, pythonLeaf] = await Promise.all([
runCli(["help", "simulation", "run"]),
runPythonCli(["help", "simulation", "run"]),
]);
assert.equal(nodeLeaf.exitCode, pythonLeaf.exitCode);
const nodePayload = parseJsonResult(nodeLeaf);
const pythonPayload = parseJsonResult(pythonLeaf);
assert.equal(nodePayload.ok, pythonPayload.ok);
assert.equal(nodePayload.schema_version, pythonPayload.schema_version);
assert.equal(nodePayload.command, pythonPayload.command);
assert.equal(nodePayload.summary, pythonPayload.summary);
assert.equal(nodePayload.usage, pythonPayload.usage);
assert.deepEqual(nodePayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })), pythonPayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })));
assert.deepEqual(nodePayload.examples, pythonPayload.examples);
assert.deepEqual(nodePayload.next_commands, pythonPayload.next_commands);
const [nodeHidden, pythonHidden] = await Promise.all([
runCli(["help", "analysis", "risk"]),
runPythonCli(["help", "analysis", "risk"]),
]);
assert.equal(nodeHidden.exitCode, pythonHidden.exitCode);
const nodeError = parseJsonResult(nodeHidden);
const pythonError = parseJsonResult(pythonHidden);
delete nodeError.metadata.generated_at;
delete pythonError.metadata.generated_at;
assert.deepEqual(nodeError, pythonError);
});
test("matches Python CLI leaf help for every visible command", async () => {
const listResult = await runCommand(
"python",
[
"-c",
"from tjwater_cli.registry import COMMAND_DOCS, is_hidden_path\nimport json\nprint(json.dumps([' '.join(path) for path in COMMAND_DOCS if not is_hidden_path(path)], ensure_ascii=False))",
],
undefined,
{ cwd: pythonCliCwd },
);
assert.equal(listResult.exitCode, 0, listResult.stderr);
const commands = JSON.parse(listResult.stdout);
for (const command of commands) {
const args = ["help", ...command.split(" ")];
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
assert.equal(nodeResult.exitCode, pythonResult.exitCode, command);
const nodePayload = parseJsonResult(nodeResult);
const pythonPayload = parseJsonResult(pythonResult);
const comparable = (payload) => ({
command: payload.command,
summary: payload.summary,
usage: payload.usage,
examples: payload.examples,
next_commands: payload.next_commands,
options: (payload.options ?? []).map(({ name, required, repeated }) => ({
name,
required,
repeated,
})),
});
assert.deepEqual(comparable(nodePayload), comparable(pythonPayload), command);
for (const command of hiddenCommandPaths) {
const result = await runCli(["help", ...command.split(" ")]);
assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
const payload = parseJsonResult(result);
assert.equal(payload.ok, false, command);
assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command);
}
});
@@ -282,7 +287,7 @@ test("maps CLI pipe and junction types to backend link and node types", async ()
}
});
test("matches Python CLI backend request shape for every command and key variants", async () => {
test("executes every command and key variant against the backend contract", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-"));
try {
const burstFile = join(tempDir, "burst.json");
@@ -359,12 +364,16 @@ test("matches Python CLI backend request shape for every command and key variant
];
for (const [name, args] of cases) {
const [nodeRun, pythonRun] = await Promise.all([
runAgainstServer(`${name} node`, runCli, args, auth),
runAgainstServer(`${name} python`, runPythonCli, args, auth),
]);
assert.equal(nodeRun.exitCode, pythonRun.exitCode, `${name}: exit\nnode=${nodeRun.stderr}\npython=${pythonRun.stderr}`);
assert.deepEqual(nodeRun.requests, pythonRun.requests, name);
const run = await runAgainstServer(name, runCli, args, auth);
assert.equal(run.exitCode, 0, `${name}: ${run.stderr}`);
assert.equal(run.payload.ok, true, name);
assert.equal(run.payload.schema_version, "tjwater-cli/v1", name);
assert.ok(run.requests.length > 0, `${name}: no backend request`);
for (const request of run.requests) {
assert.match(request.path, /^\/api\/v1\//, name);
assert.equal(request.headers.authorization, "Bearer token", name);
assert.equal(request.headers["x-project-id"], "project-1", name);
}
}
} finally {
await rm(tempDir, { force: true, recursive: true });