Files
odysseus/tests/test_editor_document_geometry_js.py
T
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00

494 lines
24 KiB
Python

import json
import subprocess
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MODULE = (ROOT / "static/js/editor/document-geometry.js").as_uri()
def run_node(body: str):
source = textwrap.dedent(
f"""
import {{
cropDocument, flipDocument, resizeCanvasDocument,
resizeImageDocument, rotateDocument, flipLayer, rotateLayer,
}} from {json.dumps(MODULE)};
let tempId = 0;
function canvas(tag, width, height) {{
const value = {{ tag, width, height, draws:[], clears:[] }};
const ctx = {{
imageSmoothingEnabled:false,
imageSmoothingQuality:'low',
font:'', textBaseline:'', textAlign:'left', fillStyle:'', strokeStyle:'', lineWidth:0,
measureText(text) {{ return {{width:String(text).length * 8}}; }},
fillText() {{}}, strokeText() {{}},
drawImage(...args) {{ value.draws.push(args); }},
clearRect(...args) {{ value.clears.push(args); }},
save() {{}}, restore() {{}}, translate() {{}}, rotate() {{}}, scale() {{}}, setTransform() {{}},
}};
value.getContext = () => ctx;
return value;
}}
globalThis.document = {{ createElement: () => canvas(`temp-${{++tempId}}`, 0, 0) }};
function holder(id, tag, width, height, masks=[]) {{
const c = canvas(tag, width, height);
return {{ id, name:id, canvas:c, ctx:c.getContext('2d'), masks }};
}}
function mask(id, tag, width, height, mode, space) {{
const c = canvas(tag, width, height);
return {{ id, name:id, canvas:c, ctx:c.getContext('2d'), mode, space, visible:true }};
}}
function sourceDraw(holderValue) {{
const sourceCanvas = holderValue?.canvas || holderValue;
const rendered = sourceCanvas.draws.at(-1)?.[0];
const args = rendered?.draws?.[0] || [];
return {{ source:args[0]?.tag, args:args.slice(1) }};
}}
{body}
"""
)
result = subprocess.run(
["node", "--input-type=module", "-e", source],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
return json.loads(result.stdout)
def test_crop_updates_layer_and_document_mask_coordinate_spaces():
result = run_node(
"""
const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer');
const selectionMask = mask('mask-selection', 'selection-mask', 100, 80, 'selection', 'document');
const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, selectionMask]);
layer.activeMaskId = layerMask.id;
const main = canvas('main', 100, 80);
const state = {
imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'),
layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]),
lassoPoints:[{x:25,y:15},{x:45,y:15},{x:45,y:30},{x:25,y:30}],
guides:{vertical:[10,25,75],horizontal:[5,20,60]},
wandMask:null, wandLastSeed:null,
};
const crop = cropDocument(state, {x:20,y:10,w:50,h:40});
console.log(JSON.stringify({
crop, doc:[state.imgWidth,state.imgHeight], main:[main.width,main.height],
layer:[layer.canvas.width,layer.canvas.height], offset:state.layerOffsets.get(layer.id),
layerMask:[layerMask.canvas.width,layerMask.canvas.height],
selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height],
layerDraw:sourceDraw(layer), layerMaskDraw:sourceDraw(layerMask),
selectionMaskDraw:sourceDraw(selectionMask),
lasso:state.lassoPoints, guides:state.guides, activeMask:state.maskCanvas.tag,
}));
"""
)
assert result["crop"] == {"x": 20, "y": 10, "width": 50, "height": 40}
assert result["doc"] == [50, 40]
assert result["main"] == [50, 40]
assert result["layer"] == [50, 40]
assert result["offset"] == {"x": 0, "y": 0}
assert result["layerMask"] == [50, 40]
assert result["selectionMask"] == [50, 40]
assert result["layerDraw"] == {"source": "pixels", "args": [-10, -5]}
assert result["layerMaskDraw"] == {"source": "layer-mask", "args": [-10, -5]}
assert result["selectionMaskDraw"] == {"source": "selection-mask", "args": [-20, -10]}
assert result["lasso"] == [
{"x": 5, "y": 5}, {"x": 25, "y": 5},
{"x": 25, "y": 20}, {"x": 5, "y": 20},
]
assert result["guides"] == {"vertical": [5], "horizontal": [10]}
assert result["activeMask"] == "layer-mask"
def test_image_resize_resamples_layers_masks_offsets_and_selection():
result = run_node(
"""
const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer');
const selectionMask = mask('mask-selection', 'selection-mask', 100, 80, 'selection', 'document');
const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, selectionMask]);
layer.activeMaskId = selectionMask.id;
const main = canvas('main', 100, 80);
const wand = canvas('wand', 40, 30);
const state = {
imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'),
layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]),
lassoPoints:[{x:10,y:20},{x:30,y:20},{x:30,y:40}],
guides:{vertical:[10,50],horizontal:[20,70]},
wandMask:wand, wandLayerId:layer.id, wandLastSeed:{x:15,y:20,mode:'replace'},
};
const resized = resizeImageDocument(state, 200, 120);
console.log(JSON.stringify({
resized, doc:[state.imgWidth,state.imgHeight], layer:[layer.canvas.width,layer.canvas.height],
layerMask:[layerMask.canvas.width,layerMask.canvas.height],
selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height],
wand:[wand.width,wand.height], offset:state.layerOffsets.get(layer.id),
lasso:state.lassoPoints, guides:state.guides,
seed:state.wandLastSeed, activeMask:state.maskCanvas.tag,
}));
"""
)
assert result["resized"] == {
"width": 200, "height": 120, "scaleX": 2, "scaleY": 1.5
}
assert result["doc"] == [200, 120]
assert result["layer"] == [80, 45]
assert result["layerMask"] == [80, 45]
assert result["selectionMask"] == [200, 120]
assert result["wand"] == [80, 45]
assert result["offset"] == {"x": 20, "y": 8}
assert result["lasso"] == [
{"x": 20, "y": 30}, {"x": 60, "y": 30}, {"x": 60, "y": 60}
]
assert result["guides"] == {"vertical": [20, 100], "horizontal": [30, 105]}
assert result["seed"] == {"x": 30, "y": 30, "mode": "replace"}
assert result["activeMask"] == "selection-mask"
def test_canvas_resize_only_changes_document_space_masks():
result = run_node(
"""
const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer');
const selectionMask = mask('mask-selection', 'selection-mask', 100, 80, 'selection', 'document');
const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, selectionMask]);
layer.activeMaskId = layerMask.id;
const main = canvas('main', 100, 80);
const state = {
imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'),
layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]),
lassoPoints:[{x:10,y:10},{x:90,y:10},{x:90,y:70},{x:10,y:70}],
guides:{vertical:[10,90],horizontal:[20,70]},
wandMask:null, wandLastSeed:null,
};
resizeCanvasDocument(state, 60, 50);
console.log(JSON.stringify({
doc:[state.imgWidth,state.imgHeight], layer:[layer.canvas.width,layer.canvas.height],
layerMask:[layerMask.canvas.width,layerMask.canvas.height],
selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height],
offset:state.layerOffsets.get(layer.id), lasso:state.lassoPoints, guides:state.guides,
}));
"""
)
assert result["doc"] == [60, 50]
assert result["layer"] == [40, 30]
assert result["layerMask"] == [40, 30]
assert result["selectionMask"] == [60, 50]
assert result["offset"] == {"x": 10, "y": 5}
assert result["guides"] == {"vertical": [10], "horizontal": [20]}
assert all(0 <= point["x"] <= 60 and 0 <= point["y"] <= 50 for point in result["lasso"])
def test_canvas_resize_anchor_shifts_layers_and_document_space_state():
result = run_node(
"""
const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer');
const documentMask = mask('mask-document', 'document-mask', 100, 80, 'selection', 'document');
const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, documentMask]);
const main = canvas('main', 100, 80);
const selection = canvas('selection', 100, 80);
const state = {
imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'),
layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]),
lassoPoints:[{x:20,y:10},{x:40,y:10},{x:40,y:30}],
guides:{vertical:[10,90],horizontal:[20,70]},
savedSelections:[{canvas:selection}], wandMask:null, wandLastSeed:{x:30,y:25,mode:'replace'},
};
const resized = resizeCanvasDocument(state, 140, 120, {anchorX:0.5, anchorY:0.5});
console.log(JSON.stringify({
resized, offset:state.layerOffsets.get(layer.id),
layerMask:[layerMask.canvas.width,layerMask.canvas.height], documentMask:[documentMask.canvas.width,documentMask.canvas.height],
documentMaskDraw:sourceDraw(documentMask.canvas), selectionDraw:sourceDraw(selection),
lasso:state.lassoPoints, guides:state.guides, seed:state.wandLastSeed,
}));
"""
)
assert result["resized"] == {"width": 140, "height": 120}
assert result["offset"] == {"x": 30, "y": 25}
assert result["layerMask"] == [40, 30]
assert result["documentMask"] == [140, 120]
assert result["documentMaskDraw"]["args"] == [20, 20]
assert result["selectionDraw"]["args"] == [20, 20]
assert result["lasso"] == [{"x": 40, "y": 30}, {"x": 60, "y": 30}, {"x": 60, "y": 50}]
assert result["guides"] == {"vertical": [30, 110], "horizontal": [40, 90]}
assert result["seed"] == {"x": 50, "y": 45, "mode": "replace"}
def test_rotate_and_flip_keep_masks_offsets_and_selections_aligned():
result = run_node(
"""
const layerMask = mask('mask-layer', 'layer-mask', 40, 30, 'layer', 'layer');
const selectionMask = mask('mask-selection', 'selection-mask', 100, 80, 'selection', 'document');
const layer = holder('layer-1', 'pixels', 40, 30, [layerMask, selectionMask]);
layer.activeMaskId = layerMask.id;
const main = canvas('main', 100, 80);
const state = {
imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'),
layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:10,y:5}]]),
lassoPoints:[{x:20,y:10},{x:30,y:10},{x:30,y:20}],
guides:{vertical:[20,70],horizontal:[10,60]},
wandMask:canvas('wand', 40, 30), wandLayerId:layer.id,
wandLastSeed:{x:20,y:10,mode:'replace'},
};
const rotated = rotateDocument(state, 90);
const afterRotate = {
result:rotated, layer:[layer.canvas.width,layer.canvas.height],
layerMask:[layerMask.canvas.width,layerMask.canvas.height],
selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height],
wand:[state.wandMask.width,state.wandMask.height],
offset:state.layerOffsets.get(layer.id), lasso:state.lassoPoints,
guides:state.guides, seed:state.wandLastSeed,
};
const flipped = flipDocument(state, 'h');
console.log(JSON.stringify({
afterRotate, flipped, offset:state.layerOffsets.get(layer.id),
lasso:state.lassoPoints, guides:state.guides, seed:state.wandLastSeed,
layerMask:[layerMask.canvas.width,layerMask.canvas.height],
selectionMask:[selectionMask.canvas.width,selectionMask.canvas.height],
}));
"""
)
rotated = result["afterRotate"]
assert rotated["result"] == {"width": 80, "height": 100, "degrees": 90}
assert rotated["layer"] == [30, 40]
assert rotated["layerMask"] == [30, 40]
assert rotated["selectionMask"] == [80, 100]
assert rotated["wand"] == [30, 40]
assert rotated["offset"] == {"x": 45, "y": 10}
assert rotated["lasso"] == [
{"x": 70, "y": 20}, {"x": 70, "y": 30}, {"x": 60, "y": 30}
]
assert rotated["guides"] == {"vertical": [20, 70], "horizontal": [20, 70]}
assert rotated["seed"] == {"x": 70, "y": 20, "mode": "replace"}
assert result["flipped"] == {"width": 80, "height": 100, "axis": "h"}
assert result["offset"] == {"x": 5, "y": 10}
assert result["lasso"] == [
{"x": 10, "y": 20}, {"x": 10, "y": 30}, {"x": 20, "y": 30}
]
assert result["guides"] == {"vertical": [10, 60], "horizontal": [20, 70]}
assert result["seed"] == {"x": 10, "y": 20, "mode": "replace"}
assert result["layerMask"] == [30, 40]
assert result["selectionMask"] == [80, 100]
def test_group_masks_follow_every_document_geometry_operation():
result = run_node(
"""
const groupMask = mask('group-mask', 'group-mask-source', 100, 80, 'group', 'document');
const layer = holder('layer-1', 'pixels', 100, 80, []);
const group = {id:'group-1',layerIds:[layer.id],masks:[groupMask],activeMaskId:groupMask.id};
const main = canvas('main', 100, 80);
const state = {
imgWidth:100,imgHeight:80,mainCanvas:main,mainCtx:main.getContext('2d'),
layers:[layer],layerGroups:[group],activeGroupId:group.id,activeLayerId:layer.id,
layerOffsets:new Map([[layer.id,{x:0,y:0}]]),lassoPoints:[],guides:{},wandMask:null,
};
cropDocument(state,{x:10,y:5,w:80,h:60});
const crop={size:[groupMask.canvas.width,groupMask.canvas.height],draw:sourceDraw(groupMask),active:state.maskCanvas.tag};
resizeImageDocument(state,160,120);
const imageSize=[groupMask.canvas.width,groupMask.canvas.height];
resizeCanvasDocument(state,150,110);
const canvasSize=[groupMask.canvas.width,groupMask.canvas.height];
rotateDocument(state,90);
const rotateSize=[groupMask.canvas.width,groupMask.canvas.height];
flipDocument(state,'h');
console.log(JSON.stringify({crop,imageSize,canvasSize,rotateSize,flipSize:[groupMask.canvas.width,groupMask.canvas.height]}));
"""
)
assert result["crop"] == {
"size": [80, 60],
"draw": {"source": "group-mask-source", "args": [-10, -5]},
"active": "group-mask-source",
}
assert result["imageSize"] == [160, 120]
assert result["canvasSize"] == [150, 110]
assert result["rotateSize"] == [110, 150]
assert result["flipSize"] == [110, 150]
def test_placed_source_survives_document_resize_rotate_flip_and_crop():
result = run_node(
"""
const source = canvas('immutable-source', 40, 20);
const layer = holder('placed', 'preview', 40, 20, []);
layer.kind = 'placed';
layer.placed = {sourceCanvas:source,sourceWidth:40,sourceHeight:20,sourceName:'source.png',matrix:[1,0,0,1,10,5]};
const main = canvas('main', 100, 80);
const state = {
imgWidth:100,imgHeight:80,mainCanvas:main,mainCtx:main.getContext('2d'),
layers:[layer],layerGroups:[],activeLayerId:layer.id,
layerOffsets:new Map([[layer.id,{x:10,y:5}]]),lassoPoints:[],guides:{},wandMask:null,
};
resizeImageDocument(state,200,160);
const resized={matrix:[...layer.placed.matrix],size:[layer.canvas.width,layer.canvas.height],offset:state.layerOffsets.get(layer.id)};
rotateDocument(state,90);
const rotated={matrix:[...layer.placed.matrix],size:[layer.canvas.width,layer.canvas.height],offset:state.layerOffsets.get(layer.id)};
flipDocument(state,'h');
const flipped={matrix:[...layer.placed.matrix],size:[layer.canvas.width,layer.canvas.height],offset:state.layerOffsets.get(layer.id)};
cropDocument(state,{x:10,y:20,w:100,h:100});
console.log(JSON.stringify({resized,rotated,flipped,cropped:{matrix:layer.placed.matrix,size:[layer.canvas.width,layer.canvas.height],offset:state.layerOffsets.get(layer.id)},source:[layer.placed.sourceCanvas.tag,layer.placed.sourceCanvas.width,layer.placed.sourceCanvas.height]}));
"""
)
assert result["resized"] == {"matrix": [2, 0, 0, 2, 20, 10], "size": [80, 40], "offset": {"x": 20, "y": 10}}
assert result["rotated"] == {"matrix": [0, 2, -2, 0, 150, 20], "size": [40, 80], "offset": {"x": 110, "y": 20}}
assert result["flipped"] == {"matrix": [0, 2, 2, 0, 10, 20], "size": [40, 80], "offset": {"x": 10, "y": 20}}
assert result["cropped"] == {"matrix": [0, 2, 2, 0, 0, 0], "size": [40, 80], "offset": {"x": 0, "y": 0}}
assert result["source"] == ["immutable-source", 40, 20]
def test_saved_and_reselect_masks_follow_document_geometry():
result = run_node(
"""
const layer = holder('layer-1', 'pixels', 100, 80, []);
const saved = canvas('saved-selection', 100, 80);
const recent = canvas('last-selection', 100, 80);
const main = canvas('main', 100, 80);
const state = {
imgWidth:100,imgHeight:80,mainCanvas:main,mainCtx:main.getContext('2d'),
layers:[layer],layerGroups:[],activeLayerId:layer.id,
layerOffsets:new Map([[layer.id,{x:0,y:0}]]),lassoPoints:[],guides:{},wandMask:null,
savedSelections:[{id:'selection-1',name:'Subject',canvas:saved}],
lastSelection:{canvas:recent},
};
cropDocument(state,{x:10,y:5,w:80,h:60});
const crop={saved:[saved.width,saved.height],recent:[recent.width,recent.height],savedDraw:sourceDraw({canvas:saved})};
resizeImageDocument(state,160,120);
resizeCanvasDocument(state,150,110);
rotateDocument(state,90);
flipDocument(state,'h');
console.log(JSON.stringify({crop,saved:[saved.width,saved.height],recent:[recent.width,recent.height]}));
"""
)
assert result["crop"]["saved"] == [80, 60]
assert result["crop"]["recent"] == [80, 60]
assert result["crop"]["savedDraw"] == {"source": "saved-selection", "args": [-10, -5]}
assert result["saved"] == [110, 150]
assert result["recent"] == [110, 150]
def test_text_layer_stays_retained_through_crop_and_image_resize():
result = run_node(
"""
const layer = holder('layer-text', 'text-pixels', 80, 30, []);
layer.kind = 'text';
layer.text = {
content:'Caption', fontFamily:'Arial', fontSize:20, color:'#fff',
transform:{scaleX:1,scaleY:1,rotation:0,flipH:false,flipV:false},
};
const main = canvas('main', 100, 80);
const state = {
imgWidth:100, imgHeight:80, mainCanvas:main, mainCtx:main.getContext('2d'),
layers:[layer], activeLayerId:layer.id, layerOffsets:new Map([[layer.id, {x:20,y:10}]]),
lassoPoints:[], wandMask:null, wandLastSeed:null,
};
cropDocument(state, {x:10,y:5,w:70,h:50});
const afterCrop = {
kind:layer.kind, content:layer.text.content, offset:state.layerOffsets.get(layer.id),
size:[layer.canvas.width,layer.canvas.height],
};
resizeImageDocument(state, 140, 100);
console.log(JSON.stringify({
afterCrop, kind:layer.kind, content:layer.text.content,
offset:state.layerOffsets.get(layer.id), transform:layer.text.transform,
size:[layer.canvas.width,layer.canvas.height],
}));
"""
)
assert result["afterCrop"]["kind"] == "text"
assert result["afterCrop"]["content"] == "Caption"
assert result["afterCrop"]["offset"] == {"x": 10, "y": 5}
assert result["kind"] == "text"
assert result["content"] == "Caption"
assert result["offset"] == {"x": 20, "y": 10}
assert result["transform"]["scaleX"] == 2
assert result["transform"]["scaleY"] == 2
assert result["size"][0] > result["afterCrop"]["size"][0]
def test_image_menu_exposes_separate_canvas_and_image_size_commands():
topbar = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8")
wiring = (ROOT / "static/js/editor/wire-topbar-menus.js").read_text(encoding="utf-8")
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
assert 'data-image-action="canvas-size"' in topbar
assert 'data-image-action="image-size"' in topbar
assert "resizeCanvasDocument(state, newW, newH, options)" in wiring
assert "resizeImageDocument(state, newW, newH, {" in wiring
assert "_cropDocument(state, state.cropRect)" in editor
def test_flip_layer_and_rotate_layer_are_single_layer_and_keep_centre():
result = run_node(
"""
const layer = holder('layer','L',60,40,[mask('m','M',20,20,'layer','layer')]);
layer.masks[0].offset = {x:5,y:5};
const state = {
imgWidth:100, imgHeight:80,
layers:[layer], activeLayerId:layer.id,
layerOffsets:new Map([[layer.id, {x:20,y:10}]]),
};
flipLayer(state, layer, 'h');
const afterFlip = {
size:[layer.canvas.width,layer.canvas.height],
offset:state.layerOffsets.get(layer.id),
maskOffset:layer.masks[0].offset,
};
rotateLayer(state, layer, 90);
const afterRotate = {
size:[layer.canvas.width,layer.canvas.height],
offset:state.layerOffsets.get(layer.id),
};
console.log(JSON.stringify({afterFlip, afterRotate}));
"""
)
# Flip keeps the layer in place (same size + offset), mask offset reflects.
assert result["afterFlip"]["size"] == [60, 40]
assert result["afterFlip"]["offset"] == {"x": 20, "y": 10}
assert result["afterFlip"]["maskOffset"] == {"x": 35, "y": 5}
# Rotate swaps the layer's box but pins its centre (50, 30).
assert result["afterRotate"]["size"] == [40, 60]
assert result["afterRotate"]["offset"] == {"x": 30, "y": 0}
def test_flip_and_rotate_menu_scopes_match_photoshop():
wiring = (ROOT / "static/js/editor/wire-topbar-menus.js").read_text(encoding="utf-8")
extra = (ROOT / "static/js/editor/wire-extra-menus.js").read_text(encoding="utf-8")
topbar = (ROOT / "static/js/editor/build/topbar.js").read_text(encoding="utf-8")
editor = (ROOT / "static/js/galleryEditor.js").read_text(encoding="utf-8")
ctxmenu = (ROOT / "static/js/editor/canvas-context-menu.js").read_text(encoding="utf-8")
# Image menu = whole document (all layers + canvas).
assert "rotateAllLayers(90)" in wiring
assert "rotateAllLayers(180)" in wiring
assert "flipAllLayers('h')" in wiring
assert "flipAllLayers('v')" in wiring
assert "Image Rotation (whole image)" in topbar
assert 'data-image-action="flip-h"' in topbar
# Edit > Transform = active layer only.
assert 'data-edit-action="rotate-90"' in topbar
assert 'data-edit-action="flip-v"' in topbar
assert "invoke('layer.rotate', { degrees: 90 })" in extra
assert "invoke('layer.flip', { axis: 'h' })" in extra
# Right-click menu defaults to the active layer.
assert "invoke('layer.rotate', { degrees: 90 })" in ctxmenu
assert "invoke('layer.flip', { axis: 'v' })" in ctxmenu
# Wrappers + control ops exist.
assert "function _flipActiveLayer" in editor
assert "function _rotateActiveLayer" in editor
assert "'layer.flip'" in editor and "'layer.rotate'" in editor
# Quick Edit defaults to the active layer but routes whole-image phrasing
# ("the whole image/canvas/photo") to the document transforms. The hybrid
# fast path routes these through the control ops so the trace is observable.
assert "await _runQuickControlOp(wholeImage ? 'document.rotate' : 'layer.rotate', { degrees: deg });" in editor
assert "await _runQuickControlOp(wholeImage ? 'document.flip' : 'layer.flip', { axis });" in editor