# 자동 표시 + 말풍선 검증 (2026-07-22, CEO 확정 — 게이트 폐지)
#   ① 로드 직후 버튼을 누르지 않아도 그 계층 사진이 보이는가
#   ② 끈(SVG line)이 앵커와 사진을 잇는가
#   ③ 카메라를 돌리면 말풍선이 부드럽게 따라오는가(감쇠 보간 — 한 프레임 만에 스냅하지 않음)
#   ④ 사진을 끌어다 놓으면 그 자리에 머물고, 새로고침 후에도 유지되는가(pin.balloon 저장)
#   ⑤ 📍 버튼은 '핀 찍기 모드' 전용(보기와 분리)
import sys
from playwright.sync_api import sync_playwright

OUT = r"E:\도진팩토리\3D스캔및티칭시스템\05_BROWSER_TESTS"
URL = "http://localhost:8091/fab.html"   # ★ shot_server.py(8091) — /save-pinnotes 가 있는 실서버


def pick_product(pg, shot=None):
    """제품 드롭다운에서 메가페어_3.5_톤 선택 (로드 완료까지 대기)"""
    pg.get_by_text("제품 없음").first.click()
    pg.wait_for_timeout(1200)
    for cat in ["에버다임", "수산중공업", "동우인더스트리", "미분류"]:
        try:
            pg.get_by_text(cat, exact=False).first.click()
            pg.wait_for_timeout(500)
        except Exception as e:
            print("cat fail", cat, e)
        hit = pg.evaluate("() => Array.from(document.querySelectorAll('*'))"
                          ".filter(e=>e.children.length===0 && /메가페어/.test(e.textContent))"
                          ".map(e=>e.textContent.trim())")
        if hit:
            break
    if shot:
        pg.screenshot(path=shot)
    pg.get_by_text("메가페어_3.5_톤", exact=False).first.click()
    pg.wait_for_timeout(9000)


PINSTATE = """() => {
  const P = window.FAB_PINPHOTO, s = P && P._state;
  const layer = document.getElementById('fab-pinphoto-overlay');
  const dots = layer ? Array.from(layer.querySelectorAll('[data-step-hidden]')) : [];
  const lines = layer ? Array.from(layer.querySelectorAll('svg line')) : [];
  // 실제로 화면에 그어진 끈 = display 가 안 꺼진 line
  const liveLines = lines.filter(l => l.getAttribute('display') !== 'none')
    .map(l => ({x1:+l.getAttribute('x1'), y1:+l.getAttribute('y1'),
                x2:+l.getAttribute('x2'), y2:+l.getAttribute('y2')}));
  return {
    product: s && s.product,
    pins: s ? s.pins.map(p=>({id:p.id, ctxPath:p.ctxPath||null, photos:(p.photos||[]).length,
                              balloon:p.balloon||null})) : null,
    ctx: P.currentCtxPath(),
    domTotal: dots.length,
    drawn: dots.filter(d=>d.getAttribute('data-step-hidden')==='0'
                       && d.getAttribute('data-gate-hidden')==='0').length,
    shown: P.pinsShown(),
    mode: s && s.on,                       // 핀 찍기 모드(버튼) — 표시와 무관해야 한다
    btn: (document.getElementById('fab-pin-toggle')||{}).textContent,
    label: (document.getElementById('fab-pin-ctxlabel')||{}).textContent,
    chip: (document.getElementById('fab-pin-stranded')||{}).textContent,
    chipShown: (()=>{ const c=document.getElementById('fab-pin-stranded');
                      return !!(c && getComputedStyle(c).display !== 'none'); })(),
    balloons: layer ? layer.querySelectorAll('.fab-pin-balloon').length : -1,
    liveLines: liveLines,
    // 끈 길이(앵커→사진) — 3D 시야를 안 가릴 만큼 떨어져 있는지
    ropeLen: liveLines.map(l => Math.round(Math.hypot(l.x2-l.x1, l.y2-l.y1)))
  };
}"""

ENTER_G03 = """() => {
  const I = window.FAB_ISO;
  const ctx = I.contextNode();
  let g03 = null;
  ctx.traverse(o => { if (!g03 && o.isGroup && /(^|\\/)G03$/.test(String(o.name))) g03 = o; });
  if (!g03) return 'G03 그룹 없음';
  let mesh = null;
  g03.traverse(o => { if (!mesh && o.isMesh) mesh = o; });
  return { g03: g03.name, ok: I.enterAt(mesh) };
}"""

# 말풍선 화면좌표(캔버스 기준 중심)
BAL_POS = """() => {
  const layer = document.getElementById('fab-pinphoto-overlay');
  // ★ 화면에 실제로 보이는 말풍선만 고른다.
  //   [CEO 확정 규칙] 숨겨진 핀의 말풍선 DOM 도 남아 있으므로(데이터 보존) 크기 0 인 것은 건너뛴다.
  const all = layer ? Array.from(layer.querySelectorAll('.fab-pin-balloon')) : [];
  const b = all.find(e => e.getBoundingClientRect().width > 0);
  if (!b) return null;
  const r = b.getBoundingClientRect();
  return { cx: r.left + r.width/2, cy: r.top + r.height/2, w: r.width };
}"""

fail = []
with sync_playwright() as p:
    b = p.chromium.launch()
    pg = b.new_page(viewport={"width": 1600, "height": 950})
    logs = []
    pg.on("console", lambda m: logs.append(m.type + ":" + m.text[:200]))
    pg.on("pageerror", lambda e: logs.append("pageerror:" + str(e)[:200]))
    pg.goto(URL, wait_until="load")
    pg.wait_for_timeout(2500)
    pg.screenshot(path=OUT + r"\_pinshot_0_initial.png")

    pick_product(pg, shot=OUT + r"\_pinshot_1_menu.png")

    # ① ★ [CEO 확정 규칙 — 사진은 블럭 안에서만. 전체에서는 절대 표시 금지. 번복 금지(2026-07-22)]
    #    로드 직후 = 전체(depth0) → 3D에 사진 0장, 툴바 '계층: 전체 · 핀 0',
    #    계층 정보 없는 옛 핀은 노란 '계층: 미지정 1' 칩으로만 알린다(데이터는 보존).
    st_root = pg.evaluate(PINSTATE)
    print("ROOT(전체=0장)>>>", st_root)
    pg.screenshot(path=OUT + r"\_pinshot_2_root.png")
    if st_root["drawn"] != 0:
        fail.append("① 규칙 위반 — 전체(depth0)에서 사진이 떴다: drawn=%r" % st_root["drawn"])
    if st_root["label"] != "계층: 전체 · 핀 0":
        fail.append("① 툴바 라벨 불일치: %r" % st_root["label"])
    if not (st_root["chipShown"] and st_root["chip"] == "계층: 미지정 1"):
        fail.append("① 미지정 칩 불일치: %r / shown=%r" % (st_root["chip"], st_root["chipShown"]))
    if st_root["mode"] is not False:
        fail.append("① 핀 찍기 모드가 켜진 채 시작됨: %r" % st_root["mode"])
    # 데이터 보존 확인 — 숨겼을 뿐 핀 2개는 그대로 있어야 한다
    if not (st_root["pins"] and len(st_root["pins"]) == 2):
        fail.append("① 핀 데이터 손실: %r" % st_root["pins"])

    # ①-b G03 진입 → 버튼 조작 없이 그 계층 핀이 자동으로 뜬다(PINAUTO 유지)
    print("ENTER>>>", pg.evaluate(ENTER_G03))
    pg.wait_for_timeout(2500)
    st_g03 = pg.evaluate(PINSTATE)
    print("G03(자동표시)>>>", st_g03)
    pg.screenshot(path=OUT + r"\_pinshot_3_g03.png")
    if not (st_g03["ctx"] == ["G03"] and st_g03["drawn"] >= 1 and st_g03["mode"] is False):
        fail.append("①-b G03 자동 표시 실패: %r" % st_g03)
    st_root = st_g03            # 이후 끈/드래그 검증은 블럭 안에서 진행한다
    # ② 끈이 앵커와 사진을 잇는다 + 충분히 길다
    if not (st_root["liveLines"] and min(st_root["ropeLen"]) >= 100):
        fail.append("② 끈 없음/너무 짧음: %r" % st_root["ropeLen"])

    # ④ 사진을 끌어다 놓기 → 그 자리에 머문다 + 저장
    pos = pg.evaluate(BAL_POS)
    tx = min(pos["cx"] + 230, 1500.0)
    ty = min(pos["cy"] + 170, 880.0)
    pg.mouse.move(pos["cx"], pos["cy"])
    pg.mouse.down()
    pg.mouse.move(tx, ty, steps=15)
    pg.mouse.up()
    pg.wait_for_timeout(1500)
    after = pg.evaluate(BAL_POS)
    st_drag = pg.evaluate(PINSTATE)
    print("DRAG>>> to=(%.0f,%.0f) at=%r balloon=%r" % (tx, ty, after, [q["balloon"] for q in st_drag["pins"]]))
    pg.screenshot(path=OUT + r"\_pinshot_5_dragged.png")
    if not after or abs(after["cx"] - tx) > 25 or abs(after["cy"] - ty) > 25:
        fail.append("④ 드래그한 자리에 머물지 않음: %r vs (%.0f,%.0f)" % (after, tx, ty))
    if not any(q["balloon"] for q in st_drag["pins"]):
        fail.append("④ pin.balloon 미저장")
    # 서버에 실제로 기록됐는지(파일 재요청)
    saved = pg.evaluate("""async () => {
      const r = await fetch('fab_models/parts/메가페어_3.5_톤/pin_notes.json?t=' + Date.now(), {cache:'no-store'});
      const j = await r.json();
      return j.pins.map(p => ({id:p.id, balloon:p.balloon||null,
                               keeps:!!(p.photos && p.photos.length), md:typeof p.md,
                               ctxPath:p.ctxPath||null}));
    }""")
    print("SAVED>>>", saved)
    if not any(q["balloon"] for q in saved):
        fail.append("④ pin_notes.json 에 balloon 미기록")
    if not all(q["keeps"] for q in saved):
        fail.append("④ 기존 photos 필드 손실")

    # ③ 카메라 회전 → 말풍선이 부드럽게 따라온다(스냅 아님)
    box = pg.locator("#fab-canvas canvas").bounding_box()
    p0 = pg.evaluate(BAL_POS)
    pg.mouse.move(box["x"] + box["width"] * 0.5, box["y"] + box["height"] * 0.5)
    pg.mouse.down()
    pg.mouse.move(box["x"] + box["width"] * 0.5 + 260, box["y"] + box["height"] * 0.5, steps=12)
    pg.mouse.up()
    pg.wait_for_timeout(60)
    p_mid = pg.evaluate(BAL_POS)          # 회전 직후 = 아직 목표에 도달하지 않음(감쇠 중)
    pg.wait_for_timeout(1500)
    p_end = pg.evaluate(BAL_POS)          # 충분히 기다리면 목표에 안착
    print("ROTATE>>> before=%r mid=%r end=%r" % (p0, p_mid, p_end))
    pg.screenshot(path=OUT + r"\_pinshot_4_rotate.png")
    if p_mid and p_end and abs(p_mid["cx"] - p_end["cx"]) + abs(p_mid["cy"] - p_end["cy"]) < 0.5:
        fail.append("③ 감쇠 보간 흔적 없음(즉시 스냅으로 보임)")

    # ④-b 새로고침 후에도 그 자리 유지
    pg.reload(wait_until="load")
    pg.wait_for_timeout(2500)
    pick_product(pg)
    # 새로고침 직후는 전체(depth0) — 규칙대로 0장인지 한 번 더 확인하고 G03 으로 다시 들어간다
    st_re_root = pg.evaluate(PINSTATE)
    if st_re_root["drawn"] != 0:
        fail.append("④-b 규칙 위반 — 새로고침 후 전체에서 사진이 떴다: %r" % st_re_root["drawn"])
    print("RELOAD-ROOT>>>", st_re_root["label"], st_re_root["chip"], st_re_root["drawn"])
    print("ENTER2>>>", pg.evaluate(ENTER_G03))
    pg.wait_for_timeout(2500)
    st_re = pg.evaluate(PINSTATE)
    after2 = pg.evaluate(BAL_POS)
    print("RELOAD>>>", [q["balloon"] for q in st_re["pins"]], after2)
    pg.screenshot(path=OUT + r"\_pinshot_6_reload.png")
    if not any(q["balloon"] for q in st_re["pins"]):
        fail.append("④ 새로고침 후 balloon 유실")

    # ⑤ 📍 버튼 = 핀 찍기 모드 전용(표시 수는 그대로)
    before_drawn = st_re["drawn"]
    pg.click("#fab-pin-toggle")
    pg.wait_for_timeout(800)
    st_mode = pg.evaluate(PINSTATE)
    print("PICKMODE>>>", st_mode["btn"], st_mode["mode"], st_mode["drawn"])
    if not (st_mode["mode"] is True and st_mode["drawn"] == before_drawn):
        fail.append("⑤ 📍 버튼이 표시를 건드림: %r" % st_mode)
    pg.click("#fab-pin-toggle")
    pg.wait_for_timeout(500)

    # ⑤-b 사진(말풍선)을 '탭' 하면 오른쪽 공정서 패널이 열린다 — 드래그와 탭 구분
    #     현장 작업자는 14px 점이 아니라 104px 사진을 누른다. 이 경로가 실제 사용 경로다.
    tap = pg.evaluate(BAL_POS)
    pg.mouse.move(tap["cx"], tap["cy"])
    pg.mouse.down()
    pg.mouse.up()                      # 움직임 0 = 탭
    pg.wait_for_timeout(1200)
    st_tap = pg.evaluate("""() => {
      const d = document.getElementById('fab-pinphoto-detail');
      const dock = document.getElementById('fab-right');
      return {
        open: !!(d && getComputedStyle(d).display !== 'none'),
        inDock: !!(d && dock && d.parentElement === dock),
        title: d ? (d.querySelector('span') && d.textContent.slice(0,40)) : null,
        hasPhoto: !!(d && d.querySelector('img') && d.querySelector('img').src),
        panels: document.querySelectorAll('#fab-pinphoto-detail').length   // 중복 생성 없음
      };
    }""")
    print("TAP>>>", st_tap)
    pg.screenshot(path=OUT + r"\_pinshot_7_tap.png")
    if not (st_tap["open"] and st_tap["inDock"] and st_tap["hasPhoto"] and st_tap["panels"] == 1):
        fail.append("⑤-b 사진 탭 → 공정서 열기 실패: %r" % st_tap)

    # ⑥ 블럭에서 나와 전체로 돌아가면 다시 0장 (규칙 왕복 확인)
    pg.evaluate("() => { const I = window.FAB_ISO; if (I && I.exitAll) I.exitAll(); }")
    pg.wait_for_timeout(1500)
    st_back = pg.evaluate(PINSTATE)
    print("BACK-TO-ROOT>>>", st_back["ctx"], st_back["label"], st_back["drawn"])
    if st_back["ctx"] != []:
        fail.append("⑥ 전체로 복귀 실패(exitAll): ctx=%r" % st_back["ctx"])
    elif st_back["drawn"] != 0:
        fail.append("⑥ 규칙 위반 — 전체로 돌아왔는데 사진이 남았다: %r" % st_back["drawn"])

    errs = [l for l in logs if l.startswith("error") or l.startswith("pageerror")]
    errs = [l for l in errs if "ERR_CONNECTION_REFUSED" not in l and "Failed to load resource" not in l]
    print("LOGS>>>", errs[:8])
    if errs:
        fail.append("JS 예외: %r" % errs[:3])
    b.close()

print("결과:", "FAIL" if fail else "PASS")
for f in fail:
    print(" -", f)
sys.exit(1 if fail else 0)
