leistungsbilanz-ts/tests/project-version-history.test.ts
Julian Appel 7222398628 Typecheck tests against current types
tsx runs tests without type checking and tsconfig.json covers only src,
so 71 type errors had accumulated unnoticed. tsconfig.tests.json now
checks every test and helper, and npm run typecheck includes it.

Fixtures gain the fields the DTOs have grown since they were written
(manualQuantity, circuit apparent power/cos phi, downstream board loads,
section totals, building, cover and board info fields). Repository
lookups that return "row ?? null" now declare the null in their type,
which their callers already handle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:17:45 +01:00

455 lines
13 KiB
TypeScript

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
import {
hasExistingProjectDeviceName,
isProjectDeviceNumberInRange,
ProjectDeviceModal,
} from "../src/frontend/components/project-device-modal.js";
import { ProjectSettingsModal } from "../src/frontend/components/project-settings-modal.js";
import type {
ProjectRevisionSummaryDto,
ProjectSnapshotMetadataDto,
} from "../src/frontend/types.js";
import {
createNamedProjectSnapshot,
getProjectHistory,
listProjectRevisions,
listProjectSnapshots,
redoProjectCommand,
restoreProjectSnapshot,
undoProjectCommand,
} from "../src/frontend/utils/api.js";
import {
getProjectRevisionDescription,
getProjectRevisionSourceLabel,
getProjectSnapshotKindLabel,
getProjectSnapshotRevisionDescription,
mergeProjectRevisionPages,
} from "../src/frontend/utils/project-version-history.js";
function revision(
revisionNumber: number,
overrides: Partial<ProjectRevisionSummaryDto> = {}
): ProjectRevisionSummaryDto {
return {
revisionId: `revision-${revisionNumber}`,
changeSetId: `change-${revisionNumber}`,
revisionNumber,
createdAtIso: "2026-07-25T12:00:00.000Z",
actorId: null,
source: "user",
description: null,
commandType: "circuit.update",
payloadSchemaVersion: 1,
...overrides,
};
}
function snapshot(
sourceRevision: number,
sourceRevisionMetadata: ProjectRevisionSummaryDto | null
): ProjectSnapshotMetadataDto {
return {
id: `snapshot-${sourceRevision}`,
projectId: "project-1",
sourceRevision,
sourceRevisionMetadata,
schemaVersion: 5,
kind: "automatic",
name: `Revision ${sourceRevision}`,
description: null,
payloadSha256: "checksum",
createdAtIso: "2026-07-25T12:00:00.000Z",
createdByActorId: null,
};
}
describe("project version history presentation", () => {
it("renders a compact collapsed German entry point", () => {
const markup = renderToStaticMarkup(
createElement(ProjectVersionHistory, {
projectId: "project-1",
currentRevision: 42,
onProjectStateChange: () => undefined,
})
);
assert.match(markup, /Versionen und Sicherungspunkte/);
assert.match(markup, /Aktuelle Revision 42/);
assert.match(markup, /Rückgängig/);
assert.match(markup, /Wiederholen/);
assert.match(markup, /Anzeigen/);
assert.doesNotMatch(markup, /Stand speichern/);
});
it("uses German source and command descriptions", () => {
assert.equal(getProjectRevisionSourceLabel("user"), "Bearbeitung");
assert.equal(getProjectRevisionSourceLabel("undo"), "Rückgängig");
assert.equal(getProjectRevisionSourceLabel("redo"), "Wiederholt");
assert.equal(
getProjectRevisionSourceLabel("restore"),
"Wiederherstellung"
);
assert.equal(getProjectRevisionSourceLabel("migration"), "Migration");
assert.equal(
getProjectRevisionDescription(revision(1)),
"Stromkreis bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(2, { description: " Eigene Beschreibung " })
),
"Eigene Beschreibung"
);
assert.equal(
getProjectRevisionDescription(
revision(3, { commandType: "future.command" })
),
"future.command"
);
assert.equal(
getProjectRevisionDescription(
revision(4, {
source: "undo",
description: "Undo project.update-settings",
commandType: "project.update-settings",
})
),
"Projekteinstellungen bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(5, {
commandType: "distribution-board-component.insert",
})
),
"Verteilergerät angelegt"
);
assert.equal(
getProjectRevisionDescription(
revision(6, {
commandType: "distribution-board-component.delete",
})
),
"Verteilergerät entfernt"
);
assert.equal(
getProjectRevisionDescription(
revision(7, {
commandType: "distribution-board-component.update",
})
),
"Verteilergerät bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(8, { commandType: "circuit-group.insert" })
),
"Stromkreisgruppe angelegt"
);
assert.equal(
getProjectRevisionDescription(
revision(9, { commandType: "circuit-group.delete" })
),
"Stromkreisgruppe entfernt"
);
assert.equal(
getProjectRevisionDescription(
revision(10, { commandType: "circuit-group.update" })
),
"Stromkreisgruppe bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(11, { commandType: "circuit-group.reorder" })
),
"Stromkreisgruppen sortiert"
);
assert.equal(
getProjectRevisionDescription(
revision(12, { commandType: "circuit-group.renumber" })
),
"Stromkreisgruppen neu nummeriert"
);
assert.equal(
getProjectRevisionDescription(
revision(13, { commandType: "circuit.move-group" })
),
"Stromkreis in andere Gruppe verschoben"
);
assert.equal(
getProjectRevisionDescription(
revision(14, {
commandType: "circuit-group.delete-subtree",
})
),
"Stromkreisgruppe vollständig entfernt"
);
assert.equal(
getProjectRevisionDescription(
revision(15, {
commandType: "circuit-group.restore-subtree",
})
),
"Stromkreisgruppe vollständig wiederhergestellt"
);
assert.equal(
getProjectRevisionDescription(
revision(16, { commandType: "circuit-protection.update" })
),
"Stromkreisschutz bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(17, { commandType: "project-floor.update" })
),
"Geschoss bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(18, { commandType: "project-room.update" })
),
"Raum bearbeitet"
);
assert.equal(
getProjectRevisionDescription(
revision(19, {
commandType: "external-object.update-row-assignment",
})
),
"Externe Objektzuordnung geändert"
);
assert.equal(getProjectSnapshotKindLabel("named"), "Benannt");
assert.equal(
getProjectSnapshotKindLabel("automatic"),
"Automatisch"
);
});
it("merges paginated revisions without duplicates in descending order", () => {
assert.deepEqual(
mergeProjectRevisionPages(
[revision(5), revision(4)],
[revision(4), revision(3)]
).map((entry) => entry.revisionNumber),
[5, 4, 3]
);
});
it("describes the immutable source revision of a snapshot", () => {
assert.equal(
getProjectSnapshotRevisionDescription(
snapshot(
25,
revision(25, {
description: "Verteilung UV-01 angelegt",
})
)
),
"Bearbeitung: Verteilung UV-01 angelegt"
);
assert.equal(
getProjectSnapshotRevisionDescription(snapshot(0, null)),
"Projektstart"
);
assert.equal(
getProjectSnapshotRevisionDescription(snapshot(25, null)),
"Änderungsdetails nicht verfügbar"
);
});
});
describe("project settings presentation", () => {
it("shows all supply types and marks types already in use", () => {
const markup = renderToStaticMarkup(
createElement(ProjectSettingsModal, {
isSaving: false,
onClose: () => undefined,
onExport: async () => undefined,
onImport: async () => undefined,
onSave: async () => undefined,
project: {
id: "project-1",
name: "Projekt",
internalProjectNumber: null,
externalProjectNumber: null,
buildingOwner: null,
description: null,
isPublicBuilding: true,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
enabledDistributionBoardSupplyTypes: [
"AV",
"MSR",
"SiBe",
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 0,
},
usedDistributionBoardSupplyTypes: ["SiBe"],
})
);
assert.match(markup, /MSR/);
assert.match(markup, /SiBe/);
assert.match(markup, /in Verwendung/);
assert.match(markup, /Öffentliches Gebäude/);
});
});
describe("project device modal presentation", () => {
it("renders explicit German labels for every editable device field", () => {
const markup = renderToStaticMarkup(
createElement(ProjectDeviceModal, {
globalDevices: [],
isSaving: false,
onClose: () => undefined,
onImportGlobal: async () => undefined,
onSave: async () => undefined,
})
);
for (const label of [
"Projektgerät hinzufügen",
"Interner Name",
"Anzeigename",
"Kategorie",
"Anschlussart",
"Kostengruppe",
"Anzahl",
"Leistung je Stück [kW]",
"Gleichzeitigkeitsfaktor",
"Bemerkung",
]) {
assert.match(markup, new RegExp(label.replace("[", "\\[").replace("]", "\\]")));
}
assert.doesNotMatch(markup, /Spannung \[V\]/);
assert.doesNotMatch(markup, /Phasenart/);
assert.match(markup, /Beleuchtung/);
assert.match(markup, /1-phasig/);
assert.match(markup, /3-phasig/);
});
it("rejects empty required numbers while keeping zero valid", () => {
assert.equal(isProjectDeviceNumberInRange("", 0), false);
assert.equal(isProjectDeviceNumberInRange(" ", 0), false);
assert.equal(isProjectDeviceNumberInRange("0", 0), true);
assert.equal(isProjectDeviceNumberInRange("0,8", 0, 1), true);
});
it("renders blank area values as invalid and warns about duplicate names", () => {
const markup = renderToStaticMarkup(
createElement(ProjectDeviceModal, {
existingDevices: [{ name: "Bürofläche", displayName: "Bürofläche" }],
globalDevices: [],
initialValues: {
name: "Bürofläche",
displayName: "Bürofläche",
quantity: null,
powerPerUnit: null,
},
isSaving: false,
onClose: () => undefined,
onImportGlobal: async () => undefined,
onSave: async () => undefined,
})
);
assert.match(markup, /gleichem internen Namen oder Anzeigenamen/);
assert.match(markup, /id="device-quantity"[^>]*value=""/);
assert.match(markup, /id="device-power"[^>]*value=""/);
assert.match(markup, /<button class="btn btn-primary" disabled="" type="submit">/);
assert.equal(
hasExistingProjectDeviceName(" bürofläche ", "", [
{ name: "BÜROFLÄCHE", displayName: "Fläche" },
]),
true
);
});
});
describe("project version history API", () => {
it("uses the revision-safe timeline and snapshot routes", async () => {
const requests: Array<{
url: string;
method: string;
body: unknown;
}> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await getProjectHistory("project-1");
await listProjectRevisions("project-1", {
limit: 10,
beforeRevision: 21,
});
await listProjectSnapshots("project-1");
await createNamedProjectSnapshot(
"project-1",
22,
"Planfreigabe",
"Vor Ausführung"
);
await restoreProjectSnapshot("project-1", "snapshot-1", 22);
await undoProjectCommand("project-1", 23);
await redoProjectCommand("project-1", 24);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/history",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/snapshots",
method: "GET",
body: null,
},
{
url: "/api/projects/project-1/snapshots",
method: "POST",
body: {
expectedRevision: 22,
name: "Planfreigabe",
description: "Vor Ausführung",
},
},
{
url: "/api/projects/project-1/snapshots/snapshot-1/restore",
method: "POST",
body: { expectedRevision: 22 },
},
{
url: "/api/projects/project-1/history/undo",
method: "POST",
body: { expectedRevision: 23 },
},
{
url: "/api/projects/project-1/history/redo",
method: "POST",
body: { expectedRevision: 24 },
},
]);
});
});