fix(canvas): retain canonical frame rotation across owner changes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}}`
|
||||
|
||||
Reference in New Issue
Block a user