152 lines
8.8 KiB
Go
152 lines
8.8 KiB
Go
package platform
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
const modelRootID = "10000000-0000-4000-8000-000000000001"
|
|
const modelChildID = "10000000-0000-4000-8000-000000000002"
|
|
const modelLegacyFrame = `{"id":"f","type":"frame","position":{"x":100,"y":200},"width":640,"height":400,"data":{"kind":"frame","title":"Partition","text":"","labelMode":"fixed"}}`
|
|
|
|
func modelJSON(value any) json.RawMessage { raw, _ := json.Marshal(value); return raw }
|
|
func modelTestID(n int) string { return fmt.Sprintf("90000000-0000-4000-8000-%012d", n) }
|
|
func modelTestAllocator() func() string {
|
|
next := 0
|
|
return func() string { next++; return modelTestID(next) }
|
|
}
|
|
func modelTestNode(id, owner string) map[string]any {
|
|
return map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": "text", "dataVersion": float64(1), "structure": "node", "data": map[string]any{"title": id, "text": "preserved"}, "geometry": map[string]any{"x": float64(0), "y": float64(0), "width": float64(260), "height": float64(200), "rotation": float64(0)}}
|
|
}
|
|
func modelTestDocument() canvasModel {
|
|
model := emptyModel(modelRootID)
|
|
model.Canvases[modelRootID] = modelCanvas{ID: modelRootID}
|
|
model.Versions[modelRootID] = 1
|
|
model.Elements["a"] = modelTestNode("a", modelRootID)
|
|
return model
|
|
}
|
|
|
|
func TestCanonicalMigrationResolvesCanvasLocalIdentitiesAndPorts(t *testing.T) {
|
|
root := `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"plain-text","title":"parent","text":""}},{"id":"f","type":"frame","position":{"x":100,"y":200},"width":640,"height":400,"data":{"kind":"frame","title":"F","text":"","subgraph":{"boardId":"` + modelChildID + `"},"ports":[{"id":"p","nodeId":"a","handle":"right","direction":"output"}]}}],"edges":[{"id":"e","source":"f","sourceHandle":"port:p","target":"a","targetHandle":"left"}],"viewport":{"x":0,"y":0,"zoom":1}}`
|
|
child := `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":10,"y":20},"width":240,"height":72,"data":{"kind":"plain-text","title":"child","text":""}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
|
|
snapshots := map[string]canvasSnapshot{modelRootID: {BoardID: modelRootID, Version: 3, Document: json.RawMessage(root)}, modelChildID: {BoardID: modelChildID, Version: 7, Document: json.RawMessage(child)}}
|
|
result, err := migrateCanvasModel(modelRootID, snapshots, []subgraphRelation{{Parent: modelRootID, Frame: "f", Child: modelChildID}}, modelTestAllocator())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ids := map[string]string{}
|
|
for _, mapping := range result.Identities {
|
|
ids[mapping.SourceCanvasID+"/"+mapping.Structure+"/"+mapping.SourceID] = mapping.ElementID
|
|
}
|
|
parentA, childA := ids[modelRootID+"/node/a"], ids[modelChildID+"/node/a"]
|
|
if parentA == childA || parentA == "" || childA == "" {
|
|
t.Fatal("canvas-local node IDs were merged")
|
|
}
|
|
edge := result.Model.Elements[ids[modelRootID+"/connection/e"]]
|
|
if edge["source"].(map[string]any)["elementId"] != childA || edge["target"].(map[string]any)["elementId"] != parentA || edge["ownerCanvasId"] != modelRootID {
|
|
t.Fatal("logical endpoint or common ancestor owner was lost")
|
|
}
|
|
if result.Model.Canvases[modelChildID].Parent.EntryElementID != ids[modelRootID+"/node/f"] || result.Model.Versions[modelRootID] != 4 || result.Model.Versions[modelChildID] != 8 {
|
|
t.Fatal("migration did not publish an identity/version barrier")
|
|
}
|
|
if string(snapshots[modelRootID].Document) != root || string(snapshots[modelChildID].Document) != child {
|
|
t.Fatal("migration mutated source snapshots")
|
|
}
|
|
projection, err := projectCanvasModel(result.Model)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, raw := range projection {
|
|
if _, valid := normalizeCanvas(raw); !valid {
|
|
t.Fatalf("invalid compatibility projection: %s", raw)
|
|
}
|
|
}
|
|
}
|
|
func TestCanonicalMigrationTurnsImplicitFramesIntoOwningCanvases(t *testing.T) {
|
|
root := `{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"plainText","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"plain-text","title":"a","text":"","frameId":"f"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
|
|
result, err := migrateCanvasModel(modelRootID, map[string]canvasSnapshot{modelRootID: {BoardID: modelRootID, Version: 1, Document: json.RawMessage(root)}}, nil, modelTestAllocator())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result.NewCanvases) != 1 || len(result.Model.Canvases) != 2 {
|
|
t.Fatal("legacy partition was not given a canvas")
|
|
}
|
|
var node map[string]any
|
|
for _, mapping := range result.Identities {
|
|
if mapping.SourceID == "a" {
|
|
node = result.Model.Elements[mapping.ElementID]
|
|
}
|
|
}
|
|
geometry := node["geometry"].(map[string]any)
|
|
if node["ownerCanvasId"] != result.NewCanvases[0] || geometry["x"] != float64(26) || geometry["y"] != float64(24) {
|
|
t.Fatal("membership or owner-local geometry changed")
|
|
}
|
|
if _, exists := node["data"].(map[string]any)["frameId"]; exists {
|
|
t.Fatal("second ownership fact leaked into canonical data")
|
|
}
|
|
}
|
|
func TestCanonicalOperationsPreserveOpaquePayloadAndStableMoveIdentity(t *testing.T) {
|
|
model := modelTestDocument()
|
|
frame := modelTestNode("f", modelRootID)
|
|
frame["componentId"] = "frame"
|
|
model.Elements["f"] = frame
|
|
model.Canvases[modelChildID] = modelCanvas{ID: modelChildID, Parent: &modelParent{CanvasID: modelRootID, EntryElementID: "f"}}
|
|
model.Versions[modelChildID] = 1
|
|
future := modelTestNode("future", modelRootID)
|
|
future["componentId"], future["dataVersion"], future["data"], future["futureEnvelope"] = "plugin.future", float64(42), map[string]any{"nested": map[string]any{"list": []any{"x", float64(2)}}}, map[string]any{"retain": true}
|
|
model.Elements["future"] = future
|
|
op := modelOperation{ID: modelTestID(1), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(modelRootID), After: modelJSON(modelChildID)}}}
|
|
after, touched, err := applyModelOperations(model, []modelOperation{op})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if after.Elements["a"]["id"] != "a" || after.Elements["a"]["ownerCanvasId"] != modelChildID || !touched[modelRootID] || !touched[modelChildID] {
|
|
t.Fatal("move changed identity or omitted a participant")
|
|
}
|
|
if !modelEqual(after.Elements["future"], future) || model.Elements["a"]["ownerCanvasId"] != modelRootID {
|
|
t.Fatal("opaque payload or old snapshot was mutated")
|
|
}
|
|
}
|
|
func TestCanonicalDeleteRestoreRejectsOldIncarnation(t *testing.T) {
|
|
model := modelTestDocument()
|
|
deleted, _, err := applyModelOperations(model, []modelOperation{{ID: modelTestID(1), Changes: []modelChange{{Kind: "element", ID: "a", Before: modelJSON(model.Elements["a"]), After: modelJSON(nil)}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if deleted.Tombstones["a"] != 1 {
|
|
t.Fatal("missing tombstone")
|
|
}
|
|
restored := modelTestNode("a", modelRootID)
|
|
restored["incarnation"] = float64(2)
|
|
live, _, err := applyModelOperations(deleted, []modelOperation{{ID: modelTestID(2), Changes: []modelChange{{Kind: "element", ID: "a", Before: modelJSON(nil), After: modelJSON(restored)}}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _, err = applyModelOperations(live, []modelOperation{{ID: modelTestID(3), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("stale")}}}})
|
|
if err != errModelConflict || live.Elements["a"]["data"].(map[string]any)["title"] != "a" {
|
|
t.Fatal("old callback claimed the restored incarnation")
|
|
}
|
|
}
|
|
func TestCanonicalValidationRejectsPartialChangesAndWrongEdgeOwner(t *testing.T) {
|
|
model := modelTestDocument()
|
|
before := modelJSON(model)
|
|
operations := []modelOperation{{ID: modelTestID(1), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("changed")}, {Kind: "field", Target: modelTarget{ID: "missing", Incarnation: 1}, Field: "geometry", Before: modelJSON(nil), After: modelJSON(nil)}}}}
|
|
if _, _, err := applyModelOperations(model, operations); err == nil {
|
|
t.Fatal("invalid second change was accepted")
|
|
}
|
|
if string(modelJSON(model)) != string(before) {
|
|
t.Fatal("failed batch changed the source")
|
|
}
|
|
frame := modelTestNode("f", modelRootID)
|
|
frame["componentId"] = "frame"
|
|
model.Elements["f"] = frame
|
|
model.Canvases[modelChildID] = modelCanvas{ID: modelChildID, Parent: &modelParent{CanvasID: modelRootID, EntryElementID: "f"}}
|
|
model.Versions[modelChildID] = 1
|
|
model.Elements["child"] = modelTestNode("child", modelChildID)
|
|
model.Elements["e"] = map[string]any{"id": "e", "ownerCanvasId": modelChildID, "incarnation": float64(1), "componentId": "connection", "dataVersion": float64(1), "structure": "connection", "data": map[string]any{}, "source": map[string]any{"elementId": "a"}, "target": map[string]any{"elementId": "child"}, "path": nil}
|
|
if validateCanvasModel(model) == nil {
|
|
t.Fatal("cross-child connection acquired a child owner")
|
|
}
|
|
}
|