leistungsbilanz-ts/tests/project-version-history.test.ts
Julian Appel b45dc5002d Fix code-review findings across domain, persistence, server and frontend
Full-codebase review turned up five real correctness/security bugs and
a dozen smaller inconsistencies; all are fixed here with matching test
coverage:

- BMK uniqueness silently allowed German-umlaut duplicates ("Ä1" vs
  "ä1") because the DB's normalized index only folds ASCII case. Added
  a shared Unicode-aware pre-check used by every circuit/component
  insert and rename path (one of which had no pre-check at all).
- CircuitDeviceRow.simultaneityFactor had no upper bound at the row
  level (command model and snapshot/restore schema), unlike every
  sibling entity, letting a bad value silently corrupt power totals.
- Grid cell editing silently misread German thousands-separator input
  ("1.500" parsed as 1.5); "." is now rejected outright with a clear
  message instead of guessing.
- The editor's shared command runner (runCommand/applyHistory) had no
  re-entrancy guard, so a double click/drop could fire the same
  command twice and race a BMK collision or revision conflict. Added a
  synchronous ref guard plus isSaving on the buttons that lacked it.
- GET .../next-identifier leaked circuit-numbering state for sections
  in other projects (no ownership check, 400 instead of 404). Moved
  under /projects/:projectId and scoped it.

Also: added the missing circuits.section_id / circuit_device_rows.
circuit_id indexes (migration 0006), gave FormModal a focus trap /
Escape-to-close / focus restore and rebuilt ProjectSettingsModal on
top of it instead of duplicated markup, removed dead code (3 orphaned
domain model files, an unused persistence helper, a wrapper only used
by its own test), pointed the project page at GET /projects/:id
instead of listing+filtering client-side, closed the gap between the
documented 18 MB CSV limit and the ~17.17 MiB actually enforced, added
missing upper bounds on several free-text fields, filled in nine
missing German labels in the revision timeline, replaced a
key-order-fragile JSON.stringify equality check with a real field
comparison, made an implicit sort-order assumption in three
renumbering helpers explicit, cleared the sidebar's target selection
when it no longer resolves after a tree reload, and fixed
updateGlobalDevice to check-then-write instead of write-then-check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 21:31:16 +02:00

411 lines
12 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 { 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",
],
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/);
});
});
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 },
},
]);
});
});