Include external models in project snapshots
This commit is contained in:
@@ -4,11 +4,23 @@ import { describe, it } from "node:test";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { createDatabaseContext } from "../src/db/database-context.js";
|
||||
import { ExternalModelStateRepository } from "../src/db/repositories/external-model-state.repository.js";
|
||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
||||
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
|
||||
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
||||
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
|
||||
import { externalRoomMappings } from "../src/db/schema/external-room-mappings.js";
|
||||
import { floors } from "../src/db/schema/floors.js";
|
||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { rooms } from "../src/db/schema/rooms.js";
|
||||
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
|
||||
import { createExternalCsvPreview } from "../src/external-model/application/external-csv-preview.js";
|
||||
import { parseExternalCsv } from "../src/external-model/csv/external-csv-transport.js";
|
||||
import {
|
||||
@@ -189,3 +201,228 @@ describe("external model state repository", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("external model snapshot and transfer", () => {
|
||||
it("captures, restores and undoes the complete external state", () => {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
try {
|
||||
insertLinkedExternalFixture(context.db);
|
||||
const populated = readProjectStateSnapshot(context.db, "project-1");
|
||||
assert.ok(populated);
|
||||
assert.equal(populated.state.schemaVersion, 4);
|
||||
assert.equal(populated.state.externalModel.objects[0].circuitDeviceRowId, "row-1");
|
||||
|
||||
context.db.delete(externalModelSources).run();
|
||||
const empty = readProjectStateSnapshot(context.db, "project-1");
|
||||
assert.ok(empty);
|
||||
assert.equal(empty.state.externalModel.source, null);
|
||||
|
||||
const repository = new ProjectStateRestoreCommandRepository(context.db);
|
||||
const restored = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createProjectStateRestoreCommand(
|
||||
empty.payloadSha256,
|
||||
populated.state
|
||||
),
|
||||
});
|
||||
assert.equal(
|
||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||
.state.objects[0].linkedProjectDeviceId,
|
||||
"device-1"
|
||||
);
|
||||
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: restored.revision.changeSetId,
|
||||
command: restored.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
new ExternalModelStateRepository(context.db).getByProject("project-1")
|
||||
.state.source,
|
||||
null
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("remaps every internal external-model link for a project duplicate", () => {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
try {
|
||||
insertLinkedExternalFixture(context.db);
|
||||
const transferRepository = new ProjectTransferRepository(context.db);
|
||||
const transfer = transferRepository.exportProject("project-1");
|
||||
assert.ok(transfer);
|
||||
const duplicate = transferRepository.importDuplicate(transfer);
|
||||
const copied = new ExternalModelStateRepository(context.db)
|
||||
.getByProject(duplicate.projectId).state;
|
||||
|
||||
assert.notEqual(copied.source?.id, "source-1");
|
||||
assert.notEqual(copied.importBatches[0].id, "batch-1");
|
||||
assert.notEqual(copied.roomMappings[0].id, "mapping-1");
|
||||
assert.notEqual(copied.objects[0].id, "object-1");
|
||||
assert.equal(copied.objects[0].ifcGuid, firstIfcGuid);
|
||||
assert.equal(copied.objects[0].lastSeenImportBatchId, copied.importBatches[0].id);
|
||||
assert.equal(copied.objects[0].externalRoomMappingId, copied.roomMappings[0].id);
|
||||
assert.notEqual(copied.objects[0].distributionBoardId, "board-1");
|
||||
assert.notEqual(copied.objects[0].linkedProjectDeviceId, "device-1");
|
||||
assert.notEqual(copied.objects[0].circuitDeviceRowId, "row-1");
|
||||
assert.equal(
|
||||
copied.importBatches[0].originalContentBase64,
|
||||
Buffer.from(utf8Bytes(quotedRevitCsv, true)).toString("base64")
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function insertLinkedExternalFixture(database: ReturnType<typeof createDatabaseContext>["db"]) {
|
||||
database.insert(projects).values({ id: "project-1", name: "Projekt" }).run();
|
||||
database.insert(floors).values({
|
||||
id: "floor-1",
|
||||
projectId: "project-1",
|
||||
name: "EG",
|
||||
sortOrder: 10,
|
||||
}).run();
|
||||
database.insert(rooms).values({
|
||||
id: "room-1",
|
||||
projectId: "project-1",
|
||||
floorId: "floor-1",
|
||||
roomNumber: "01/101",
|
||||
roomName: "Technik",
|
||||
}).run();
|
||||
database.insert(projectDevices).values({
|
||||
id: "device-1",
|
||||
projectId: "project-1",
|
||||
name: "socket",
|
||||
displayName: "Steckdose",
|
||||
phaseType: "single_phase",
|
||||
connectionKind: "socket",
|
||||
costGroup: null,
|
||||
category: "single_phase",
|
||||
quantity: 1,
|
||||
powerPerUnit: 0.12,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: null,
|
||||
remark: null,
|
||||
voltageV: 230,
|
||||
}).run();
|
||||
database.insert(distributionBoards).values({
|
||||
id: "board-1",
|
||||
projectId: "project-1",
|
||||
name: "UV 1",
|
||||
floorId: "floor-1",
|
||||
supplyType: "AV",
|
||||
simultaneityFactor: 1,
|
||||
}).run();
|
||||
database.insert(circuitLists).values({
|
||||
id: "list-1",
|
||||
projectId: "project-1",
|
||||
distributionBoardId: "board-1",
|
||||
name: "UV 1 Stromkreisliste",
|
||||
}).run();
|
||||
database.insert(circuitSections).values({
|
||||
id: "section-1",
|
||||
circuitListId: "list-1",
|
||||
key: "single_phase",
|
||||
displayName: "1-phasig 1",
|
||||
prefix: "-2F1.",
|
||||
sortOrder: 10,
|
||||
category: "single_phase",
|
||||
groupNumber: 1,
|
||||
}).run();
|
||||
database.insert(circuits).values({
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "section-1",
|
||||
equipmentIdentifier: "-2F1.1",
|
||||
displayName: "Steckdosen",
|
||||
sortOrder: 10,
|
||||
voltage: 230,
|
||||
isReserve: 0,
|
||||
}).run();
|
||||
database.insert(circuitDeviceRows).values({
|
||||
id: "row-1",
|
||||
circuitId: "circuit-1",
|
||||
linkedProjectDeviceId: "device-1",
|
||||
sortOrder: 10,
|
||||
name: "socket",
|
||||
displayName: "Steckdose",
|
||||
phaseType: "single_phase",
|
||||
connectionKind: "socket",
|
||||
costGroup: null,
|
||||
category: "single_phase",
|
||||
level: null,
|
||||
roomId: "room-1",
|
||||
roomNumberSnapshot: "01/101",
|
||||
roomNameSnapshot: "Technik",
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.12,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: null,
|
||||
remark: null,
|
||||
overriddenFields: null,
|
||||
}).run();
|
||||
|
||||
database.insert(externalModelSources).values({
|
||||
id: "source-1",
|
||||
projectId: "project-1",
|
||||
name: "Revit-Gesamtmodell",
|
||||
sourceType: "revit_csv",
|
||||
}).run();
|
||||
const configuration = configuredCsv();
|
||||
const bytes = utf8Bytes(quotedRevitCsv, true);
|
||||
database.insert(externalImportBatches).values({
|
||||
id: "batch-1",
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
importKind: "initial",
|
||||
importedAtIso: "2026-08-02T15:30:00.000Z",
|
||||
fileName: "revit.csv",
|
||||
sha256: "a".repeat(64),
|
||||
appliedProjectRevision: 0,
|
||||
configurationSnapshot: configuration,
|
||||
originalBytes: Buffer.from(bytes),
|
||||
document: parseExternalCsv(bytes, configuration),
|
||||
}).run();
|
||||
database.insert(externalRoomMappings).values({
|
||||
id: "mapping-1",
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
normalizedSourceRoomKey: "number:01/101",
|
||||
sourceFloorName: "EG",
|
||||
sourceRoomNumber: "01/101",
|
||||
sourceRoomName: "Technik",
|
||||
roomId: "room-1",
|
||||
defaultDistributionBoardId: "board-1",
|
||||
}).run();
|
||||
const preview = createExternalCsvPreview({
|
||||
fileName: "revit.csv",
|
||||
bytes,
|
||||
configuration,
|
||||
});
|
||||
const projected = projectInitialExternalObject(preview.objects[0], configuration);
|
||||
database.insert(externalModelObjects).values({
|
||||
id: "object-1",
|
||||
projectId: "project-1",
|
||||
sourceId: "source-1",
|
||||
ifcGuid: projected.ifcGuid,
|
||||
lastSeenImportBatchId: "batch-1",
|
||||
lastAcceptedImportBatchId: "batch-1",
|
||||
acceptedSourceValues: projected.sourceValues,
|
||||
planningValues: projected.planningValues,
|
||||
overriddenFields: [],
|
||||
externalRoomMappingId: "mapping-1",
|
||||
distributionBoardId: "board-1",
|
||||
linkedProjectDeviceId: "device-1",
|
||||
circuitDeviceRowId: "row-1",
|
||||
presenceStatus: "present",
|
||||
}).run();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ function minimalSnapshot() {
|
||||
},
|
||||
distributionBoards: [],
|
||||
externalCsvConfiguration: null,
|
||||
externalModel: {
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [],
|
||||
},
|
||||
circuitLists: [],
|
||||
circuitSections: [],
|
||||
distributionBoardComponents: [],
|
||||
@@ -149,6 +155,7 @@ describe("project state snapshot model", () => {
|
||||
baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
|
||||
delete (baseline.project as Record<string, unknown>).isPublicBuilding;
|
||||
delete (baseline as Record<string, unknown>).externalCsvConfiguration;
|
||||
delete (baseline as Record<string, unknown>).externalModel;
|
||||
|
||||
const upgraded = parseProjectStateSnapshot(baseline);
|
||||
|
||||
@@ -161,11 +168,35 @@ describe("project state snapshot model", () => {
|
||||
const previous = structuredClone(minimalSnapshot());
|
||||
previous.schemaVersion = 2 as typeof previous.schemaVersion;
|
||||
delete (previous as Record<string, unknown>).externalCsvConfiguration;
|
||||
delete (previous as Record<string, unknown>).externalModel;
|
||||
|
||||
const upgraded = parseProjectStateSnapshot(previous);
|
||||
|
||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||
assert.equal(upgraded.externalCsvConfiguration, null);
|
||||
assert.deepEqual(upgraded.externalModel, {
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("upgrades snapshot version 3 with an empty external model", () => {
|
||||
const previous = structuredClone(minimalSnapshot());
|
||||
previous.schemaVersion = 3 as typeof previous.schemaVersion;
|
||||
delete (previous as Record<string, unknown>).externalModel;
|
||||
|
||||
const upgraded = parseProjectStateSnapshot(previous);
|
||||
|
||||
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
|
||||
assert.equal(upgraded.externalCsvConfiguration, null);
|
||||
assert.deepEqual(upgraded.externalModel, {
|
||||
source: null,
|
||||
importBatches: [],
|
||||
roomMappings: [],
|
||||
objects: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed baseline and unknown snapshot shapes", () => {
|
||||
|
||||
Reference in New Issue
Block a user