fix(canvas): retain canonical frame rotation across owner changes
Foundry Go checks / check (push) Successful in 3m26s
Foundry Go checks / check (pull_request) Successful in 3m28s

This commit is contained in:
2026-11-11 18:53:06 +08:00
parent 36d69133b8
commit 506f778ac3
3 changed files with 67 additions and 2 deletions
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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) {
+63
View File
@@ -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}}`