import fs from "node:fs"; import path from "node:path"; import request from "supertest"; import { describe, expect, it } from "vitest"; import { buildApp } from "../src/app.js"; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../"); const xml = fs.readFileSync(path.join(ROOT, "data/cases/base-case.xml"), "utf-8"); const defaultGoldenHash = fs .readFileSync(path.join(ROOT, "data/golden/default.solve.sha256"), "utf-8") .trim(); describe("solver-api", () => { it("solves using default base case endpoint", async () => { const app = buildApp(); const response = await request(app).get("/solve/default"); expect(response.status).toBe(200); expect(response.body.runMetadata.source).toBe("base-case.xml"); expect(response.body.solver.pointCount).toBe(200); expect(response.body.solver.profiles).toBeNull(); expect(response.body.solver.diagnostics).toBeNull(); expect(response.body.fieldTraceability?.schemaVersion).toBe(2); expect(Array.isArray(response.body.fieldTraceability?.fields)).toBe(true); }); it("returns fea prototype result and comparison payload", async () => { const app = buildApp(); const response = await request(app).get("/solve/default?solverModel=fea"); expect(response.status).toBe(200); expect(response.body.runMetadata.solverModel).toBe("fea"); expect(response.body.solver.pointCount).toBe(200); expect(response.body.solvers.fdm.pointCount).toBe(200); expect(response.body.solvers.fea.pointCount).toBe(200); expect(response.body.comparison).toBeTruthy(); expect(response.body.comparison.schemaVersion).toBe(2); expect(response.body.comparison.peakLoadDeltas).toBeTruthy(); expect(response.body.comparison.residualSummary.points).toBeGreaterThan(0); expect(Array.isArray(response.body.comparison.pointwiseResiduals.series)).toBe(true); expect(response.body.comparison.fourier).toBeNull(); }); it("returns both model outputs when requested", async () => { const app = buildApp(); const response = await request(app).post("/solve").send({ xml, solverModel: "both" }); expect(response.status).toBe(200); expect(response.body.runMetadata.solverModel).toBe("both"); expect(response.body.solvers.fdm.pointCount).toBe(200); expect(response.body.solvers.fea.pointCount).toBe(200); }); it("supports diagnostic workflow from measured polished-rod data", async () => { const app = buildApp(); const predictive = await request(app).get("/solve/default?solverModel=fdm"); expect(predictive.status).toBe(200); const card = predictive.body.solver.card.slice(0, 120); const surfaceCard = { position: card.map((p) => p.position), load: card.map((p) => p.polishedLoad) }; const response = await request(app) .post("/solve") .send({ xml, solverModel: "fdm", workflow: "diagnostic", surfaceCard }); expect(response.status).toBe(200); expect(response.body.runMetadata.workflow).toBe("diagnostic"); expect(response.body.pumpMovement.stroke).toBeTypeOf("number"); expect(response.body.verbose.references.length).toBeGreaterThan(0); expect(response.body.verbose.rodString.hasTaper).toBe(true); expect(response.body.verbose.trajectoryCoupling.frictionMultiplier).toBeGreaterThan(1); }); it("returns parsed case and solver output", async () => { const app = buildApp(); const response = await request(app).post("/solve").send({ xml }); expect(response.status).toBe(200); expect(response.body.solver.pointCount).toBe(200); expect(response.body.parsed.model.wellName).toBe("PLACEHOLDER-WELL"); expect(Array.isArray(response.body.parsed.unsupportedFields)).toBe(true); }); it("rejects /solve without xml and returns schema metadata", async () => { const app = buildApp(); const response = await request(app).post("/solve").send({}); expect(response.status).toBe(400); expect(response.body.schemaVersion).toBe(2); expect(response.body.code).toBe("missing_xml"); }); it("rejects diagnostic workflow without surface card", async () => { const app = buildApp(); const response = await request(app) .post("/solve") .send({ xml, solverModel: "fdm", workflow: "diagnostic" }); expect(response.status).toBe(400); expect(response.body.code).toBe("missing_surface_card"); }); it("is deterministic for the same input", async () => { const app = buildApp(); const a = await request(app).post("/solve").send({ xml }); const b = await request(app).post("/solve").send({ xml }); expect(a.status).toBe(200); expect(b.status).toBe(200); expect(a.body.solver.card).toEqual(b.body.solver.card); expect(a.body.solver.maxPolishedLoad).toBe(b.body.solver.maxPolishedLoad); }); it("parses an uploaded XML via POST /case/parse (no solve)", async () => { const app = buildApp(); const defaultResp = await request(app).get("/case/default"); expect(defaultResp.status).toBe(200); const response = await request(app).post("/case/parse").send({ xml }); expect(response.status).toBe(200); expect(response.body.schemaVersion).toBe(2); expect(response.body.model.wellName).toBe(defaultResp.body.model.wellName); expect(response.body.model.pumpDepth).toBe(defaultResp.body.model.pumpDepth); expect(response.body.rawFields.WellName).toBe(defaultResp.body.rawFields.WellName); expect(response.body.unsupportedFields.sort()).toEqual( defaultResp.body.unsupportedFields.sort() ); expect(response.body.fieldTraceability?.fields?.length).toBeGreaterThan(0); }); it("rejects empty body on POST /case/parse", async () => { const app = buildApp(); const response = await request(app).post("/case/parse").send({}); expect(response.status).toBe(400); expect(response.body.error).toMatch(/xml/i); expect(response.body.schemaVersion).toBe(2); }); it("matches golden fingerprint for default solve", async () => { const app = buildApp(); const response = await request(app).get("/solve/default?solverModel=fdm"); expect(response.status).toBe(200); expect(response.body.fingerprint).toBe(defaultGoldenHash); }); it("returns extended physics payload when options are enabled", async () => { const app = buildApp(); const response = await request(app).post("/solve").send({ xml, solverModel: "both", options: { enableProfiles: true, enableDiagnosticsDetail: true, enableFourierBaseline: true, fourierHarmonics: 10 } }); expect(response.status).toBe(200); expect(response.body.solver.profiles.nodeCount).toBeGreaterThan(0); expect(response.body.solver.diagnostics.valveStates.length).toBe(response.body.solver.pointCount); expect(response.body.comparison.fourier).toBeTruthy(); expect(response.body.comparison.fourier.harmonics).toBe(10); }); });