@@ -2449,3 +2449,85 @@ def test_automorph_two_fixtures_sharing_a_room_render_different_auras(tmp_path):
24492449 f"the two fixtures' aura outlines must differ — identical outlines mean the partition "
24502450 f"was not applied and both fell back to the same full-room shape: { out } "
24512451 )
2452+
2453+
2454+ # ── Automorph icon endpoint: the fixture's REAL manual footprint ────────────
2455+ # (Garry, 2026-09-07: "the existing manual shapes are still meant to be a
2456+ # guide for the overall look, don't throw that info away.") The morph's
2457+ # starting shape is automorphIconRing — iconRingLocal scaled and rotated by
2458+ # the SAME markerScale transform the real glyph already draws with — so a
2459+ # strip's aura grows from its own long, angled footprint, not a generic hex.
2460+
2461+ def test_automorph_icon_ring_without_manual_size_is_exactly_the_plain_icon (tmp_path ):
2462+ """No recorded width/height and no rotation must be a byte-for-byte
2463+ no-op — the same identity contract every other Automorph control's rest
2464+ position holds, so no existing fixture's aura moves a pixel."""
2465+ out = _run_js (tmp_path , (
2466+ "import { automorphIconRing, iconRingLocal } from './iso_lights.mjs';\n "
2467+ "const a=automorphIconRing('circle', 0, 0, 0, 30, 10);\n "
2468+ "const b=iconRingLocal('circle', 10);\n "
2469+ "console.log(JSON.stringify({equal: JSON.stringify(a)===JSON.stringify(b)}));\n "
2470+ ))
2471+ assert out ["equal" ], "no manual size + no rotation must return iconRingLocal's own points untouched"
2472+
2473+
2474+ def test_automorph_icon_ring_scales_per_axis_and_rotates_like_the_real_glyph (tmp_path ):
2475+ """A wide manual footprint must stretch the ring along x (and leave y at
2476+ its soft-floored height); rotating the same fixture 90° must carry that
2477+ long axis to y — the same scale-then-rotate order the real glyph's own
2478+ `rotate(rot) scale(sx,sy)` transform applies to each point."""
2479+ out = _run_js (tmp_path , (
2480+ "import { automorphIconRing } from './iso_lights.mjs';\n "
2481+ "const ext=(pts)=>{let x=0,y=0;for(const p of pts){x=Math.max(x,Math.abs(p[0]));y=Math.max(y,Math.abs(p[1]));}return {x,y};};\n "
2482+ "const plain=ext(automorphIconRing('square', 0, 0, 0, 30, 10));\n "
2483+ "const wide=ext(automorphIconRing('square', 400, 20, 0, 30, 10));\n "
2484+ "const wideTurned=ext(automorphIconRing('square', 400, 20, 90, 30, 10));\n "
2485+ "console.log(JSON.stringify({plain, wide, wideTurned}));\n "
2486+ ))
2487+ plain , wide , turned = out ["plain" ], out ["wide" ], out ["wideTurned" ]
2488+ assert wide ["x" ] > plain ["x" ] * 2 , f"a 4 m width must visibly stretch the ring along x: { out } "
2489+ assert wide ["x" ] > wide ["y" ] * 2 , f"the stretched ring must actually be wide, not scaled uniformly: { out } "
2490+ assert abs (turned ["x" ] - wide ["y" ]) < 1e-6 and abs (turned ["y" ] - wide ["x" ]) < 1e-6 , (
2491+ f"rotating 90° must swap the long axis exactly: { out } "
2492+ )
2493+
2494+
2495+ def test_automorph_aura_grows_from_the_real_manual_footprint_not_a_generic_hex (tmp_path ):
2496+ """End-to-end: at a low room%, a fixture with a real 240cm-wide manual
2497+ footprint must render a much WIDER aura outline than the identical
2498+ fixture with no manual size — before this, both started from the same
2499+ small default-radius icon and the manual shape information never reached
2500+ the morph at all."""
2501+ NOW = 1_000_000_000_000
2502+
2503+ def render (extra_lp ):
2504+ model = {
2505+ "room_geometry_m" : {"Office" : {"type" : "poly" , "floor_id" : "main" , "points_m" : [[0 , 0 ], [8 , 0 ], [8 , 8 ], [0 , 8 ]]}},
2506+ "light_positions_m" : {"light.strip" : {"x_m" : 4 , "y_m" : 4 , "floor_id" : "main" , ** extra_lp }},
2507+ }
2508+ lbe = {"light.strip" : {"entity_id" : "light.strip" , "state" : "on" , "code" : "W01" , "shape" : "bar" , "isMotion" : False , "last_changed" : None }}
2509+ floors = [{"id" : "main" , "name" : "Main" , "level" : 0 }]
2510+ out = _run_js (tmp_path , (
2511+ "import * as M from './iso_lights.mjs';\n "
2512+ f"const MODEL={ json .dumps (model )} ;\n "
2513+ f"const LBE={ json .dumps (lbe )} ;\n "
2514+ f"const FLOORS={ json .dumps (floors )} ;\n "
2515+ f"const svg=M.buildIsoSVG(MODEL,{{}},new Set(),null,150,0,LBE,false,FLOORS,"
2516+ f"{{nowMs:{ NOW } , automorph:true, automorphRoomPct:1, automorphHardness:0, automorphStyle:'blueprint'}});\n "
2517+ "const m = svg.match(/<path d=\" ([^\" ]+)\" [^>]*stroke-dasharray=\" 4,3\" /);\n "
2518+ "if(!m){ console.log(JSON.stringify({w: null})); }\n "
2519+ "else {\n "
2520+ " const nums=[...m[1].matchAll(/(-?[\\ d.]+),(-?[\\ d.]+)/g)].map(mm=>[parseFloat(mm[1]),parseFloat(mm[2])]);\n "
2521+ " const xs=nums.map(p=>p[0]);\n "
2522+ " console.log(JSON.stringify({w: Math.max(...xs)-Math.min(...xs)}));\n "
2523+ "}\n "
2524+ ))
2525+ return out ["w" ]
2526+
2527+ plain_w = render ({})
2528+ manual_w = render ({"width_cm" : 240 , "height_cm" : 5 , "rotation" : 0 })
2529+ assert plain_w is not None and manual_w is not None , (plain_w , manual_w )
2530+ assert manual_w > plain_w * 2 , (
2531+ f"a 240cm manual width must make the aura's outline visibly wider than the "
2532+ f"default icon's ({ manual_w } vs { plain_w } ) — the manual footprint must reach the morph"
2533+ )
0 commit comments