245 lines
13 KiB
Go
245 lines
13 KiB
Go
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"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"}}`
|
|
|
|
// The schema runner mounts only the test binary, not the source tree.
|
|
//
|
|
//go:embed testdata/foundry-model-operation-v2.json
|
|
var canonicalFrontendFixture []byte
|
|
|
|
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 TestCanonicalFramesRetainRotationAcrossOwnerChanges(t *testing.T) {
|
|
model := modelTestDocument()
|
|
frame := modelTestNode("f", modelRootID)
|
|
frame["componentId"] = "frame"
|
|
frame["geometry"].(map[string]any)["rotation"] = float64(30)
|
|
model.Elements["f"] = frame
|
|
model.Canvases[modelChildID] = modelCanvas{ID: modelChildID, Parent: &modelParent{CanvasID: modelRootID, EntryElementID: "f"}}
|
|
model.Versions[modelChildID] = 1
|
|
member := modelTestNode("nested-frame", modelChildID)
|
|
member["componentId"] = "frame"
|
|
model.Elements["nested-frame"] = member
|
|
if err := validateCanvasModel(model); err != nil {
|
|
t.Fatalf("valid canonical frame rotation rejected: %v", err)
|
|
}
|
|
geometry := member["geometry"].(map[string]any)
|
|
moved := map[string]any{"x": float64(100), "y": float64(80), "width": float64(520), "height": float64(400), "rotation": float64(30)}
|
|
after, touched, err := applyModelOperations(model, []modelOperation{{ID: modelTestID(80), Changes: []modelChange{
|
|
{Kind: "field", Target: modelTarget{ID: "nested-frame", Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(modelChildID), After: modelJSON(modelRootID)},
|
|
{Kind: "field", Target: modelTarget{ID: "nested-frame", Incarnation: 1}, Field: "geometry", Before: modelJSON(geometry), After: modelJSON(moved)},
|
|
}}})
|
|
if err != nil || !touched[modelRootID] || !touched[modelChildID] {
|
|
t.Fatalf("rotated frame owner change failed: %v", err)
|
|
}
|
|
if after.Elements["nested-frame"]["ownerCanvasId"] != modelRootID || !modelEqual(after.Elements["nested-frame"]["geometry"], moved) || model.Elements["nested-frame"]["ownerCanvasId"] != modelChildID {
|
|
t.Fatal("owner change lost geometry or changed the source model")
|
|
}
|
|
projection, err := projectCanvasModel(after)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var document map[string]any
|
|
if err := json.Unmarshal(projection[modelRootID], &document); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rotated := 0
|
|
for _, raw := range document["nodes"].([]any) {
|
|
node := raw.(map[string]any)
|
|
if node["id"] != "f" && node["id"] != "nested-frame" {
|
|
continue
|
|
}
|
|
rotated++
|
|
if node["data"].(map[string]any)["rotation"] != float64(30) {
|
|
t.Fatal("reader projection erased canonical rotation")
|
|
}
|
|
}
|
|
if rotated != 2 {
|
|
t.Fatal("reader projection omitted a rotated frame")
|
|
}
|
|
patched, err := prepareModelAIPatch(after, modelRootID, "a", response{"text": "new text"})
|
|
if err != nil || patched.Elements["f"]["geometry"].(map[string]any)["rotation"] != float64(30) {
|
|
t.Fatalf("unrelated canonical AI patch rejected or rewound rotation: %v", err)
|
|
}
|
|
bad := cloneModel(after)
|
|
bad.Elements["f"]["geometry"].(map[string]any)["rotation"] = float64(360)
|
|
if validateCanvasModel(bad) == nil {
|
|
t.Fatal("out-of-range canonical rotation accepted")
|
|
}
|
|
legacy := json.RawMessage(`{"schema_version":1,"nodes":[{"id":"f","type":"frame","position":{"x":0,"y":0},"width":640,"height":400,"data":{"kind":"frame","title":"F","text":"","rotation":30}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`)
|
|
if _, valid := normalizeCanvas(legacy); valid {
|
|
t.Fatal("canonical support changed the legacy snapshot write contract")
|
|
}
|
|
}
|
|
|
|
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}
|
|
future["data"].(map[string]any)["constructor"] = map[string]any{"retain": true}
|
|
future["data"].(map[string]any)["__proto__"] = map[string]any{"ordinary": "JSON data"}
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestCanonicalFrontendProtocolFixture(t *testing.T) {
|
|
var input modelBatch
|
|
decoder := json.NewDecoder(bytes.NewReader(canonicalFrontendFixture))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if input.ProtocolVersion != canvasModelProtocol || !uuidSlugPattern.MatchString(input.RequestID) || input.Expected[modelRootID] != 3 {
|
|
t.Fatal("frontend fixture envelope is incompatible")
|
|
}
|
|
before := modelTestDocument()
|
|
before.Versions[modelRootID] = 3
|
|
after, touched, err := applyModelOperations(before, input.Operations)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !touched[modelRootID] || after.Versions[modelRootID] != 4 || after.Elements["a"]["data"].(map[string]any)["title"] != "renamed" || after.Elements["a"]["data"].(map[string]any)["text"] != "preserved" {
|
|
t.Fatal("frontend fixture did not apply exactly its intended field change")
|
|
}
|
|
}
|