diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index d92c4e7..088ddd4 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -71,7 +71,7 @@ jobs: PYSLACK - name: Fetch pinned headless HTTP integration client env: - HEADLESS_CLIENT_REVISION: 15ee91772d01b0a5cebe9853a35da31da0c0e102 + HEADLESS_CLIENT_REVISION: dcbbdad25de7c3b1d6bee317df6074da2c0642eb run: | set -eu mkdir -p .headless-client diff --git a/internal/platform/canvas_model.go b/internal/platform/canvas_model.go index b66152b..abd4195 100644 --- a/internal/platform/canvas_model.go +++ b/internal/platform/canvas_model.go @@ -266,7 +266,9 @@ func validateCanvasModel(model canvasModel) error { return errCanvasModel } if version == 1 && canvasChoice(component, "text", "image", "script", "plain-text", "plain-image", "icon", "frame") { - if !canvasString(data["title"], 300) || !canvasString(data["text"], 100000) || component == "frame" && geometry["rotation"] != float64(0) { + // Canonical frames can inherit rotation while changing owners. The + // legacy snapshot writer's zero-angle restriction does not apply here. + if !canvasString(data["title"], 300) || !canvasString(data["text"], 100000) { return errCanvasModel } if ai, exists := data["ai"]; exists && !validCanvasAI(ai, component) { diff --git a/internal/platform/canvas_model_test.go b/internal/platform/canvas_model_test.go index 1b082cb..0550d2d 100644 --- a/internal/platform/canvas_model_test.go +++ b/internal/platform/canvas_model_test.go @@ -34,6 +34,69 @@ func modelTestDocument() canvasModel { 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}}`