# 공용 오른쪽 패널 도크 게이트 (2026-07-21 / 2026-07-22 책갈피 개편)
# 목적: 새 기능마다 캔버스 오른쪽에 절대좌표로 패널을 띄워 서로 겹치던 문제가 구조적으로 끝났는지 확인.
#   1) window.FAB_DOCK 존재 + #fab-right 가 세로 스택 도크로 정비됨
#   2) 대상 패널 5종이 전부 #fab-right 의 자식 (캔버스 절대좌표 아님)
#   3) 기존 id 유지 (fab-explode-bar / fab-photonotes / fab-grouping-bar / fab-pinphoto-detail)
#   4) 패널을 동시에 N개 열어도 getBoundingClientRect 교차 0 (겹침 없음)
#   5) 도크 패널의 computed position !== 'absolute'
#   6) 안내문(#fab-dock-empty) 은 패널 등록 후 숨겨짐
#   7) 상단 툴바 버튼(#fab-actionbar) 은 그대로 남아 있음
#   8) JS 예외 0
#   9) [CEO 2026-07-22 정정] 오른쪽 가장자리 책갈피 2개(SHIN / 공정서)가 각각 독립으로 접히고 펴진다.
#      - SHIN만 펴면 SHIN만 보이고, 공정서만 펴면 도크만 보이고, 둘 다 펴면 둘 다 보이되 겹치지 않는다.
#      - FAB_DOCK.reveal() 은 공정서 칸만 자동으로 편다(SHIN 칸 상태는 건드리지 않는다 — 핀 클릭 경로).
import json, os, sys, subprocess, time
from playwright.sync_api import sync_playwright

D = r"E:\도진팩토리\3D스캔및티칭시스템"
PORT = 8098
URL = "http://localhost:%d/fab.html" % PORT

srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT)],
                       cwd=D, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1.5)

result = {"js_exceptions": [], "ext_noise": [], "checks": {}}


def _con(m):
    if m.type == "error":
        t = m.text
        if "ERR_CONNECTION_REFUSED" in t or "Failed to load resource" in t:
            result["ext_noise"].append(t)
        else:
            result["js_exceptions"].append("CONSOLE:" + t)


# 실제 툴바 버튼을 눌렀을 때(강제 display 조작이 아니라) 열리는 패널이
# 도크 안에 있고 서로 안 겹치는지 확인하는 단계.
CLICK_JS = r"""
() => {
  const dock = document.getElementById('fab-right');
  if (!dock) return { err: 'no dock' };
  if (window.FAB_DOCK && window.FAB_DOCK.setTab) window.FAB_DOCK.setTab('spec');
  const vis = Array.from(dock.children).filter(c =>
    c.id !== 'fab-dock-empty' && getComputedStyle(c).display !== 'none');
  const rects = vis.map(c => c.getBoundingClientRect());
  let overlaps = 0;
  for (let i = 0; i < rects.length; i++)
    for (let j = i + 1; j < rects.length; j++) {
      const a = rects[i], b = rects[j];
      if (Math.min(a.right,b.right) - Math.max(a.left,b.left) > 0.5 &&
          Math.min(a.bottom,b.bottom) - Math.max(a.top,b.top) > 0.5) overlaps++;
    }
  return {
    openedIds: vis.map(c => c.id || c.getAttribute('data-fab-dock-title') || '(anon)'),
    openedCount: vis.length,
    overlaps: overlaps,
    allInDock: vis.every(c => c.parentElement === dock),
    absCount: vis.filter(c => getComputedStyle(c).position === 'absolute').length
  };
}
"""

# 책갈피 2개를 원하는 조합으로 맞춘다 (독립 개폐)
BM_SET = r"""
([wantShin, wantSpec]) => {
  window.FAB_SHIN_SIDEBAR.open(wantShin);
  window.FAB_SPEC_SIDEBAR.open(wantSpec);
  return null;
}
"""

# 현재 조합에서 무엇이 실제로 보이는지 + 둘이 겹치는지
BM_READ = r"""
() => {
  const seen = e => {
    if (!e) return false;
    const s = getComputedStyle(e);
    if (s.visibility !== 'visible' || s.display === 'none') return false;
    const b = e.getBoundingClientRect();
    if (b.width < 1 || b.height < 1) return false;
    return b.right > 2 && b.left < window.innerWidth - 2;   // 화면 밖 슬라이드 제외
  };
  const dock = document.getElementById('fab-right');
  const shin = document.getElementById('dzw-aria-chat');
  const out = {
    shinOpen: window.FAB_SHIN_SIDEBAR.isOpen(),
    specOpen: window.FAB_SPEC_SIDEBAR.isOpen(),
    dockSeen: seen(dock), shinSeen: shin ? seen(shin) : null,
    overlap: 0
  };
  if (out.dockSeen && out.shinSeen) {
    const a = dock.getBoundingClientRect(), b = shin.getBoundingClientRect();
    const ix = Math.min(a.right,b.right) - Math.max(a.left,b.left);
    const iy = Math.min(a.bottom,b.bottom) - Math.max(a.top,b.top);
    out.overlap = (ix > 0.5 && iy > 0.5) ? 1 : 0;
    out.rects = [[a.top,a.bottom],[b.top,b.bottom]];
  }
  return out;
}
"""

JS = r"""
() => {
  const out = {};
  out.hasDock = !!(window.FAB_DOCK && window.FAB_DOCK.add);
  const dock = document.getElementById('fab-right');
  out.dockExists = !!dock;
  if (!dock) return out;
  out.dockDisplay = getComputedStyle(dock).display;
  out.dockFlexDir = getComputedStyle(dock).flexDirection;

  // [CEO 2026-07-22] 도크는 '공정서' 책갈피가 펴져 있을 때만 보인다.
  //   강제 전개 검사 전에 공정서 칸을 열어 열 폭을 확보한다.
  if (window.FAB_SPEC_SIDEBAR) try { window.FAB_SPEC_SIDEBAR.open(true); } catch(e){}

  // 각 모듈의 패널 생성을 강제로 트리거 (버튼 클릭 없이 빌드만)
  const ids = ['fab-explode-bar','fab-photonotes','fab-grouping-bar','fab-pinphoto-detail'];
  out.ids = {};
  ids.forEach(id => {
    const e = document.getElementById(id);
    out.ids[id] = { exists: !!e, inDock: !!(e && e.parentElement === dock) };
  });

  // 도크에 들어온 패널 전부 (id 없는 주석패널 포함)
  const kids = Array.from(dock.children).filter(c => c.id !== 'fab-dock-empty');
  out.dockChildCount = kids.length;

  // 전부 강제로 펼쳐서 동시에 열린 상태 만들기 + 도크 레이아웃 재계산(SHIN 아래 칸으로 슬라이드 인)
  kids.forEach(k => { k.style.display = 'flex'; });
  // 도크는 '작업 공정서' 책갈피에서만 보인다 → 겹침 검사 전에 그 탭으로 전환한다.
  if (window.FAB_DOCK && window.FAB_DOCK.setTab) window.FAB_DOCK.setTab('spec');
  if (window.FAB_DOCK && window.FAB_DOCK.layout) window.FAB_DOCK.layout();

  out.positions = kids.map(k => getComputedStyle(k).position);
  out.absCount = out.positions.filter(p => p === 'absolute').length;

  // 겹침 검사: 모든 쌍의 사각형 교차
  const rects = kids.map(k => k.getBoundingClientRect());
  let overlaps = 0, detail = [];
  for (let i = 0; i < rects.length; i++) {
    for (let j = i + 1; j < rects.length; j++) {
      const a = rects[i], b = rects[j];
      const ix = Math.min(a.right, b.right) - Math.max(a.left, b.left);
      const iy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
      if (ix > 0.5 && iy > 0.5) { overlaps++; detail.push([i, j, ix, iy]); }
    }
  }
  out.overlaps = overlaps;
  out.overlapDetail = detail;
  out.zeroSize = rects.filter(r => r.width < 1 || r.height < 1).length;

  const empty = document.getElementById('fab-dock-empty');
  out.emptyHidden = !!(empty && getComputedStyle(empty).display === 'none');

  const ab = document.getElementById('fab-actionbar');
  out.actionbarBtns = ab ? ab.children.length : -1;

  // [CEO 2026-07-22 정정] 핵심: 오른쪽 가장자리 화살표 책갈피 2개가 각각 독립으로 접히고 펴진다.
  //   가로 탭바는 폐기 → DOM 에 남아 있으면 안 된다.
  out.oldTabBarGone = !document.getElementById('fab-right-tabs');
  out.hasShinApi = !!(window.FAB_SHIN_SIDEBAR && window.FAB_SHIN_SIDEBAR.open);
  out.hasSpecApi = !!(window.FAB_SPEC_SIDEBAR && window.FAB_SPEC_SIDEBAR.open);
  // 가장자리 책갈피 버튼 = position:fixed + 오른쪽 폭 30px 이하 세로 탭
  out.edgeTabs = Array.from(document.body.children).filter(c => {
    if (c.tagName !== 'BUTTON') return false;
    const s = getComputedStyle(c);
    if (s.position !== 'fixed') return false;
    const b = c.getBoundingClientRect();
    return b.width <= 32 && b.height >= 40 && b.right > window.innerWidth * 0.5;
  }).map(c => ({ label: c.getAttribute('aria-label'), top: Math.round(c.getBoundingClientRect().top),
                 h: Math.round(c.getBoundingClientRect().height) }));
  out.edgeTabCount = out.edgeTabs.length;
  // 두 책갈피 버튼끼리도 겹치면 안 된다(세로로 나란히)
  out.edgeTabOverlap = (out.edgeTabCount === 2 &&
    Math.min(out.edgeTabs[0].top + out.edgeTabs[0].h, out.edgeTabs[1].top + out.edgeTabs[1].h)
    - Math.max(out.edgeTabs[0].top, out.edgeTabs[1].top) > 0.5) ? 1 : 0;

  const shin = document.getElementById('dzw-aria-chat');
  // '실제로 화면에 보이는가' — 숨김은 visibility(탭) 와 화면 밖 슬라이드(transform) 두 채널 모두 확인.
  //   (탭 전환 직후 visibility:hidden 은 슬라이드 애니메이션 0.3초 뒤에 적용되므로 좌표도 함께 본다)
  const seen = e => {
    if (!e) return false;
    const s = getComputedStyle(e);
    if (s.visibility !== 'visible' || s.display === 'none') return false;
    const b = e.getBoundingClientRect();
    return b.right > 2 && b.left < window.innerWidth - 2;
  };
  // 조합별 '보이는가' 검사는 슬라이드 애니메이션(.28s) 때문에 파이썬 쪽에서 대기하며 한다(BM_SET/BM_READ).

  // 캔버스에 오른쪽 절대좌표 패널이 남아 있지 않은지
  const canvas = document.getElementById('fab-canvas');
  out.canvasRightAbs = canvas ? Array.from(canvas.children).filter(c => {
    const s = getComputedStyle(c);
    return s.position === 'absolute' && s.right !== 'auto' && parseFloat(s.right) < 60 &&
           c.id !== 'fab-build-stamp';
  }).map(c => c.id || c.className || '(anon)') : null;

  // [CEO 2026-07-22] 핀 클릭 경로: FAB_DOCK.reveal() 이 공정서 칸을 자동으로 펴는가.
  //   그리고 그때 SHIN 칸은 건드리지 않는가(독립성).
  window.FAB_SPEC_SIDEBAR.open(false);
  window.FAB_SHIN_SIDEBAR.open(true);
  const target = kids[0] || null;
  if (target) window.FAB_DOCK.reveal(target, 'flex');
  out.revealOpensSpec = window.FAB_SPEC_SIDEBAR.isOpen();
  out.revealKeepsShin = window.FAB_SHIN_SIDEBAR.isOpen();
  return out;
}
"""

try:
    with sync_playwright() as p:
        b = p.chromium.launch()
        pg = b.new_page(viewport={"width": 1400, "height": 900})
        pg.on("pageerror", lambda e: result["js_exceptions"].append(str(e)))
        pg.on("console", _con)
        pg.goto(URL)
        pg.wait_for_timeout(3500)

        # ① 실제 툴바 버튼 클릭 → 도크 안에서 열리고 안 겹치는지
        n = pg.evaluate("() => { const a=document.getElementById('fab-actionbar'); return a?a.children.length:0; }")
        for i in range(n):
            try:
                pg.evaluate("(i) => document.getElementById('fab-actionbar').children[i].click()", i)
                pg.wait_for_timeout(350)
            except Exception as ex:
                result["js_exceptions"].append("CLICK%d:%s" % (i, ex))
        result["click_phase"] = pg.evaluate(CLICK_JS)

        # ② 강제 전개(모든 패널 동시 오픈) 겹침 검사
        result["checks"] = pg.evaluate(JS)

        # ③ [CEO 2026-07-22 정정] 책갈피 2개 독립 개폐 — 4가지 조합 전부 확인(슬라이드 .28s 뒤 측정)
        for key, combo in (("bm_shin_only", [True, False]),
                           ("bm_spec_only", [False, True]),
                           ("bm_both",      [True, True]),
                           ("bm_none",      [False, False])):
            pg.evaluate(BM_SET, combo); pg.wait_for_timeout(700)
            result[key] = pg.evaluate(BM_READ)
        b.close()
finally:
    srv.terminate()

c = result["checks"]
cp = result.get("click_phase") or {}
ok = (
    cp.get("overlaps") == 0
    and cp.get("allInDock") is True
    and cp.get("absCount") == 0
    and cp.get("openedCount", 0) >= 1  # 클릭으로 최소 1개는 실제로 열려야 함
    and c.get("hasDock") and c.get("dockExists")
    and c.get("dockDisplay") == "flex" and c.get("dockFlexDir") == "column"
    and c.get("dockChildCount", 0) >= 4
    and c.get("overlaps") == 0
    and c.get("absCount") == 0
    and c.get("zeroSize") == 0
    and c.get("emptyHidden")
    and c.get("actionbarBtns", 0) > 0
    # ★ [CEO 2026-07-22 정정] 책갈피 2개가 각각 독립으로 접히고 펴진다
    and c.get("oldTabBarGone") is True          # 가로 탭바 폐기
    and c.get("hasShinApi") is True and c.get("hasSpecApi") is True
    and c.get("edgeTabCount") == 2              # 오른쪽 가장자리 화살표 책갈피 2개
    and c.get("edgeTabOverlap") == 0            # 세로로 나란히(버튼끼리 안 겹침)
    and (result.get("bm_shin_only") or {}).get("shinSeen") is True
    and (result.get("bm_shin_only") or {}).get("dockSeen") is False
    and (result.get("bm_spec_only") or {}).get("dockSeen") is True
    and (result.get("bm_spec_only") or {}).get("shinSeen") is False
    and (result.get("bm_both") or {}).get("shinSeen") is True
    and (result.get("bm_both") or {}).get("dockSeen") is True
    and (result.get("bm_both") or {}).get("overlap") == 0   # 둘 다 펴도 안 겹침
    and (result.get("bm_none") or {}).get("shinSeen") is False
    and (result.get("bm_none") or {}).get("dockSeen") is False
    and c.get("revealOpensSpec") is True        # 핀 클릭 → 공정서 칸 자동 열림
    and c.get("revealKeepsShin") is True        # 그때 SHIN 칸은 그대로(독립)
    # [CEO 2026-07-22 / 커밋 ed71e17] 분해 슬라이더는 의도적 예외 — 오른쪽 도크는 접히는 열이라
    #   접힌 상태에서 분해 조작기가 사라졌다. 그래서 캔버스에 둔다. 나머지 패널만 도크 소속을 요구한다.
    and all(v["exists"] for v in c.get("ids", {}).values())
    and all(v["inDock"] for k, v in c.get("ids", {}).items() if k != "fab-explode-bar")
    and c.get("ids", {}).get("fab-explode-bar", {}).get("inDock") is False
    and not result["js_exceptions"]
)
print(json.dumps(result, ensure_ascii=False, indent=2))
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
