# _pinch_dolly_verify.py — 핀치줌 dolly-through 행동검증 [2026-07-11]
# 목적: 두 손가락 핀치줌인이 target 코앞에서 "맴돌이"(r→0 수렴)가 아니라
#       dolly-through(반지름 floor + target 전방 전진)로 동작하는지 실제 핸들러를 구동해 확인.
# 방식: app._ptrs에 두 포인터를 심고(=pointerdown이 하는 일) 실제 canvas에 pointermove PointerEvent 디스패치.
#       두 손가락을 좌우 대칭(기하급수)으로 벌려 center 고정 → 팬 상쇄, 순수 줌만 남긴다.
#       핵심 지표 = fwdDot(target 이동의 '시선 전방' 성분). 팬은 화면평면 이동이라 이 성분에 거의 기여 못함.
# 사용: python 05_BROWSER_TESTS/_pinch_dolly_verify.py   (서버 8090 필요)
import asyncio, json, sys
from playwright.async_api import async_playwright

URL='http://localhost:8090/DOZIKWORKS_OS_v5_locked.dc.html?health=1'

SIM = r"""(()=>{
  const app=window.__dzw;
  if(!app||!app.orbit||!app.three||!app.three.rnd) return {ok:false,why:'app/orbit 미준비'};
  const dom=app.three.rnd.domElement, O=app.orbit;
  const c3=v=>[v.x,v.y,v.z];
  const fire=(id,x,y)=>dom.dispatchEvent(new PointerEvent('pointermove',{pointerId:id,clientX:x,clientY:y,bubbles:true,cancelable:true}));
  const cx=768, cy=432;
  const fwd=()=>{const sp=Math.sin(O.phi);return [-(sp*Math.cos(O.theta)), -(sp*Math.sin(O.theta)), -Math.cos(O.phi)];}; // 시선 전방(-dirOut)

  // 두 손가락을 기하급수로 벌리며 핀치줌인 시뮬 (steps회). 반환: target 전진의 전방성분 등
  function pinchIn(rStart, steps, ratio){
    O.tgt.set(0.1,0,0.9); O.theta=-0.81; O.phi=1.18; O.r=rStart;
    const f=fwd(), t0=c3(O.tgt), r0=O.r;
    let d=20; app._ptrs=new Map([[1,{x:cx-d,y:cy}],[2,{x:cx+d,y:cy}]]);
    for(let i=0;i<steps;i++){ d*=ratio; fire(1,cx-d,cy); fire(2,cx+d,cy); }
    const t1=c3(O.tgt), r1=O.r;
    const adv=[t1[0]-t0[0], t1[1]-t0[1], t1[2]-t0[2]];
    const advLen=Math.hypot(adv[0],adv[1],adv[2]);
    const fwdDot=adv[0]*f[0]+adv[1]*f[1]+adv[2]*f[2];         // target이 시선 전방으로 간 거리(m)
    const fwdFrac=advLen>1e-9? fwdDot/advLen : 0;
    return {r0,r1,advLen,fwdDot,fwdFrac,t0,t1};
  }

  // 검사1: 근접까지 강하게 줌인 → dolly-through (r가 rMin=0.1에 floor + target 전방 전진)
  const c1=pinchIn(0.5, 260, 1.02);
  // 검사2(회귀가드): 먼 거리에서 소량 줌인 → r만 감소(정상), target 전방 전진 없음
  const c2=pinchIn(2.0, 6, 1.05);

  const finite=[c1.r1,...c1.t1,c2.r1,...c2.t1].every(Number.isFinite);
  return {ok:true, finite, c1, c2};
})()"""

async def main():
    errs=[]
    async with async_playwright() as p:
        br=await p.chromium.launch(channel='msedge', headless=True)
        pg=await br.new_page(viewport={'width':1536,'height':864})
        pg.on('console', lambda m: errs.append(m.text[:200]) if m.type=='error' else None)
        pg.on('pageerror', lambda e: errs.append('PAGEERROR: '+str(e)[:200]))
        await pg.goto(URL)
        ready=False
        for _ in range(90):
            await pg.wait_for_timeout(1000)
            if await pg.evaluate("!!(window.__dzw&&window.__dzw.three&&window.__dzw._robotGrp&&window.__dzw.orbit)"):
                ready=True; break
        await pg.wait_for_timeout(2000)
        res=await pg.evaluate(SIM)
        await br.close()

    real_errs=[e for e in errs if 'favicon' not in e and 'Failed to load resource' not in e]
    print(json.dumps(res, ensure_ascii=False, indent=1))
    print('콘솔 에러:', len(real_errs))
    for e in real_errs[:5]: print('  -', e)
    if not ready: print('== FAIL: 앱 로딩 실패 =='); sys.exit(2)
    if not res.get('ok'): print('== FAIL:', res.get('why'), '=='); sys.exit(1)

    c1, c2 = res['c1'], res['c2']
    fails=[]
    if not res.get('finite'):              fails.append('NaN/무한값 발생')
    # 검사1 — dolly-through 발동 (버그였다면: r→~0.00005, target 불변으로 fwdDot≈0)
    if not (0.0999 <= c1['r1'] <= 0.1001):  fails.append(f"r가 rMin(0.1)에 floor 안됨(맴돌이 잔존): r1={c1['r1']}")
    if not (c1['fwdDot'] > 0.05):           fails.append(f"target이 시선 전방으로 안 나아감: fwdDot={c1['fwdDot']}m")
    if not (c1['fwdFrac'] > 0.9):           fails.append(f"전진이 시선정렬 아님(팬 오염): fwdFrac={c1['fwdFrac']}")
    # 검사2 — 정상거리 줌은 기존과 동일(반지름만), 전방 dolly 없음
    if not (c2['r1'] < c2['r0']):           fails.append(f"정상 줌인이 r 안줄임: {c2['r0']}→{c2['r1']}")
    if not (c2['r1'] >= 0.1):               fails.append(f"정상 줌인이 rMin 아래로: r1={c2['r1']}")
    if not (abs(c2['fwdDot']) < 0.005):     fails.append(f"정상거리인데 target을 전방으로 밀었음: fwdDot={c2['fwdDot']}m")
    if real_errs:                           fails.append(f"콘솔에러 {len(real_errs)}건")

    if fails:
        print('== FAIL =='); [print('  -',f) for f in fails]; sys.exit(1)
    print(f"== PASS: dolly-through 확인 — 근접줌: r floor {c1['r1']:.4f}m, target 전방 {c1['fwdDot']*100:.1f}cm 전진(정렬 {c1['fwdFrac']:.2f}) / 정상줌: r {c2['r0']}→{c2['r1']:.3f}m, 전방이동 {c2['fwdDot']*1000:.2f}mm(≈0) ==")

asyncio.run(main())
