# 제품 자세 저장하기(view_state) 게이트 (2026-07-22)
# 목적: CEO 가 기즈모로 맞춘 제품 자세를 제품별로 저장하고, 재로드 시 그 자세로 복원되는지 왕복 확인.
#   1) 서버 스모크: 정상 저장 200 / 경로이탈(../) 400 / 없는 제품 400 / 잘못된 view 400 / 삭제 200
#   2) 픽 계약: depth0 에서 부품을 픽하면 __fabPickTarget 이 '루트 그룹'을 준다
#      (= CEO 가 기즈모로 돌리는 대상 == 우리가 저장하는 대상. 이게 아니면 저장은 조용한 무동작)
#   3) 자세 변경 → 💾 저장 버튼 클릭 → view_state.json 이 디스크에 생김 + 값 일치
#   4) 페이지 새로고침 → 같은 블럭 로드 → 루트 quaternion 이 저장값과 일치(복원)
#   5) ↩ 자세 초기화 클릭 → 오버레이 파일 삭제 + 루트가 로드 당시 자세로 복귀
#   6) 원본 무수정: parts.json / index.json / obj 의 mtime·크기 불변
#   7) JS 예외 0
import json, os, sys, subprocess, time, shutil, urllib.request, urllib.error
from playwright.sync_api import sync_playwright

D = r"E:\도진팩토리\3D스캔및티칭시스템"
PARTS = os.path.join(D, "fab_models", "parts")
PORT = 8097
API = 8091
URL = "http://localhost:%d/fab.html" % PORT

result = {"js_exceptions": [], "ext_noise": [], "smoke": {}, "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)


def wait_until(cond, timeout=15.0, step=0.2):
    """조건이 참이 될 때까지 폴링. 고정 sleep(경합으로 간헐 FAIL) 대신 사용."""
    t0 = time.time()
    while time.time() - t0 < timeout:
        if cond():
            return True
        time.sleep(step)
    return cond()


def post(path, obj):
    req = urllib.request.Request("http://localhost:%d%s" % (API, path),
                                 data=json.dumps(obj).encode("utf-8"),
                                 headers={"Content-Type": "application/json"}, method="POST")
    try:
        with urllib.request.urlopen(req, timeout=8) as r:
            return r.status, r.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", "replace")


# ── 대상 블럭: 부품 수·용량이 가장 작은 것 (헤드리스 로드 시간 최소화) ──
idx = json.load(open(os.path.join(PARTS, "index.json"), encoding="utf-8"))
blocks = idx["blocks"]


def weight(b):
    folder = os.path.join(PARTS, b["folder"])
    try:
        tot = sum(os.path.getsize(os.path.join(folder, f))
                  for f in os.listdir(folder) if f.endswith(".obj"))
    except OSError:
        tot = 1 << 40
    return tot


TARGET = sorted(blocks, key=weight)[0]
FOLDER = TARGET["folder"]
VS_PATH = os.path.join(PARTS, FOLDER, "view_state.json")
result["target"] = {"model": TARGET["model"], "folder": FOLDER,
                    "part_count": TARGET.get("part_count")}

# 기존 저장본이 있으면 백업했다가 마지막에 되돌린다 (데이터 손실 금지)
BACKUP = VS_PATH + ".testbak"
had_prev = os.path.isfile(VS_PATH)
if had_prev:
    shutil.copy2(VS_PATH, BACKUP)

# 원본 무수정 확인용 지문
orig_files = []
for f in sorted(os.listdir(os.path.join(PARTS, FOLDER))):
    if f in ("view_state.json", "view_state.json.testbak"):
        continue
    p = os.path.join(PARTS, FOLDER, f)
    orig_files.append((f, os.path.getsize(p), os.path.getmtime(p)))

# ── 8091 캡처서버: 떠 있으면 재사용, 아니면 이 테스트가 띄운다 ──
api_proc = None
try:
    urllib.request.urlopen("http://localhost:%d/fab.html" % API, timeout=3).close()
    api_running = True
except Exception:
    api_running = False
if not api_running:
    api_proc = subprocess.Popen([sys.executable, "shot_server.py"], cwd=D,
                                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    time.sleep(2.0)

srv = subprocess.Popen([sys.executable, "-m", "http.server", str(PORT)],
                       cwd=D, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(1.5)

GOOD_VIEW = {"position": [1.0, 2.0, 3.0],
             "quaternion": [0.0, 0.3826834323650898, 0.0, 0.9238795325112867],
             "scale": [1.0, 1.0, 1.0]}

LOAD_JS = r"""
async (folder) => {
  const idx = await (await fetch('fab_models/parts/index.json', {cache:'no-store'})).json();
  const b = idx.blocks.find(x => x.folder === folder);
  if (!b) return { err: 'block not found' };
  const g = await window.FAB.loadBlock(b.model, b.parts);
  if (!g) return { err: 'load returned nothing' };
  return { ok: true, name: g.name };
}
"""

STATE_JS = r"""
() => {
  const root = window.FAB_ISO._dbg.getRoot();
  const meshes = window.FAB_ISO._dbg.meshesOf(root);
  return {
    hasVS: !!(window.FAB_VS && window.FAB_VS.save && window.FAB_VS.reset),
    btnSave: !!document.getElementById('fab-viewstate-btn'),
    btnReset: !!document.getElementById('fab-viewstate-reset'),
    btnsInActionbar: (() => {
      const ab = document.getElementById('fab-actionbar');
      const s = document.getElementById('fab-viewstate-btn');
      const r = document.getElementById('fab-viewstate-reset');
      return !!(ab && s && r && s.parentElement === ab && r.parentElement === ab);
    })(),
    rootIsFabRoot: !!(root && root.userData && root.userData.__fabRoot),
    meshCount: meshes.length,
    // ★ 픽 계약: depth0 에서 부품을 픽하면 루트 그룹이 나와야 한다
    pickIsRoot: meshes.length ? (window.__fabPickTarget(meshes[0]) === root) : null,
    product: window.FAB_VS.product,
    appliedSaved: window.FAB_VS.appliedSaved,
    xform: window.FAB_VS.current()
  };
}
"""

ROTATE_JS = r"""
() => {
  // CEO 동작 모델: 부품을 클릭(픽) → 기즈모가 붙은 그 객체를 회전시킨다.
  const root = window.FAB_ISO._dbg.getRoot();
  const meshes = window.FAB_ISO._dbg.meshesOf(root);
  const target = window.__fabPickTarget(meshes[0]);   // == root (계약)
  target.rotation.set(0, Math.PI / 4, 0);             // 45도 돌려 '수평 맞추기' 흉내
  target.position.set(11, 22, 33);
  target.updateMatrixWorld(true);
  return { targetIsRoot: target === root, xform: window.FAB_VS.current() };
}
"""

try:
    # ── ① 서버 스모크 ──
    result["smoke"]["ok200"] = post("/save-viewstate?model=" + FOLDER,
                                    {"product": FOLDER, "view": GOOD_VIEW})[0]
    result["smoke"]["saved_on_disk"] = os.path.isfile(VS_PATH)
    result["smoke"]["traversal400"] = post("/save-viewstate",
                                           {"product": "../../etc", "view": GOOD_VIEW})[0]
    result["smoke"]["absolute400"] = post("/save-viewstate",
                                          {"product": "C:\\Windows", "view": GOOD_VIEW})[0]
    result["smoke"]["nosuch400"] = post("/save-viewstate",
                                        {"product": "___no_such_product___", "view": GOOD_VIEW})[0]
    result["smoke"]["badview400"] = post("/save-viewstate",
                                         {"product": FOLDER, "view": {"position": [1, 2], "quaternion": [0, 0, 0, 1], "scale": [1, 1, 1]}})[0]
    result["smoke"]["nan400"] = post("/save-viewstate",
                                     {"product": FOLDER, "view": {"position": [1, 2, "x"], "quaternion": [0, 0, 0, 1], "scale": [1, 1, 1]}})[0]
    result["smoke"]["clear200"] = post("/save-viewstate?model=" + FOLDER,
                                       {"product": FOLDER, "clear": True})[0]
    result["smoke"]["cleared_on_disk"] = not os.path.isfile(VS_PATH)

    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)

        # ── ② 1회차: 로드 → 픽 계약 → 회전 → 💾 저장 ──
        pg.goto(URL)
        pg.wait_for_timeout(3500)
        result["checks"]["load1"] = pg.evaluate(LOAD_JS, FOLDER)
        # 1회차엔 저장본이 없으니 '복원 시도 끝남'(product 세팅됨)만 기다린다
        pg.wait_for_function("() => !!(window.FAB_VS && window.FAB_VS.product)", timeout=15000)
        result["checks"]["state1"] = pg.evaluate(STATE_JS)
        result["checks"]["rotate"] = pg.evaluate(ROTATE_JS)
        pg.evaluate("() => document.getElementById('fab-viewstate-btn').click()")
        # 시간 대신 조건 대기 — 서버가 실제로 파일을 쓸 때까지 폴링(느린 장비에서도 안정)
        result["checks"]["savedFileExists"] = wait_until(lambda: os.path.isfile(VS_PATH))
        if result["checks"]["savedFileExists"]:
            result["checks"]["savedFile"] = json.load(open(VS_PATH, encoding="utf-8"))

        # ── ③ 2회차: 새로고침 → 같은 블럭 로드 → 자세 복원 확인 ──
        pg.reload()
        pg.wait_for_timeout(3500)
        result["checks"]["load2"] = pg.evaluate(LOAD_JS, FOLDER)
        # 복원은 view_state.json fetch 후에 끝나는 비동기 — 고정 sleep 대신 조건 대기
        try:
            pg.wait_for_function("() => !!(window.FAB_VS && window.FAB_VS.appliedSaved)", timeout=15000)
        except Exception as ex:
            result["js_exceptions"].append("RESTORE_TIMEOUT:" + str(ex)[:200])
        result["checks"]["state2"] = pg.evaluate(STATE_JS)

        # ── ④ 자세 초기화 → 파일 삭제 + 로드 당시 자세로 복귀 ──
        pg.evaluate("() => document.getElementById('fab-viewstate-reset').click()")
        result["checks"]["afterResetFileGone"] = wait_until(lambda: not os.path.isfile(VS_PATH))
        pg.wait_for_timeout(300)   # 삭제 응답 후 화면 자세 복귀까지 한 틱
        result["checks"]["state3"] = pg.evaluate(STATE_JS)
        b.close()
finally:
    srv.terminate()
    if api_proc:
        api_proc.terminate()
    # 백업 복구 / 테스트 잔재 제거
    if had_prev:
        shutil.move(BACKUP, VS_PATH)
    elif os.path.isfile(VS_PATH):
        os.remove(VS_PATH)

# 원본 무수정 확인
now_files = []
for f in sorted(os.listdir(os.path.join(PARTS, FOLDER))):
    if f in ("view_state.json", "view_state.json.testbak"):
        continue
    p = os.path.join(PARTS, FOLDER, f)
    now_files.append((f, os.path.getsize(p), os.path.getmtime(p)))
result["originals_untouched"] = (orig_files == now_files)


def close(a, b_, tol=1e-6):
    return a is not None and b_ is not None and len(a) == len(b_) and \
        all(abs(x - y) <= tol for x, y in zip(a, b_))


s1 = result["checks"].get("state1") or {}
s2 = result["checks"].get("state2") or {}
s3 = result["checks"].get("state3") or {}
rot = result["checks"].get("rotate") or {}
sf = result["checks"].get("savedFile") or {}
sm = result["smoke"]

saved_view = sf.get("view") or {}
rot_x = rot.get("xform") or {}

ok = (
    sm.get("ok200") == 200 and sm.get("saved_on_disk") is True
    and sm.get("traversal400") == 400 and sm.get("absolute400") == 400
    and sm.get("nosuch400") == 400 and sm.get("badview400") == 400 and sm.get("nan400") == 400
    and sm.get("clear200") == 200 and sm.get("cleared_on_disk") is True
    # 픽 계약 — 이게 깨지면 저장 기능 자체가 무의미
    and s1.get("pickIsRoot") is True and rot.get("targetIsRoot") is True
    and s1.get("rootIsFabRoot") is True
    and s1.get("hasVS") and s1.get("btnsInActionbar")
    and s1.get("product") == FOLDER
    and s1.get("appliedSaved") is False          # 1회차엔 저장본 없음
    # 저장 왕복
    and result["checks"].get("savedFileExists") is True
    and close(saved_view.get("quaternion"), rot_x.get("quaternion"))
    and close(saved_view.get("position"), rot_x.get("position"))
    # 복원
    and s2.get("appliedSaved") is True
    and close((s2.get("xform") or {}).get("quaternion"), saved_view.get("quaternion"))
    and close((s2.get("xform") or {}).get("position"), saved_view.get("position"))
    # 초기화
    and result["checks"].get("afterResetFileGone") is True
    and close((s3.get("xform") or {}).get("quaternion"), [0, 0, 0, 1])
    and close((s3.get("xform") or {}).get("position"), [0, 0, 0])
    and result["originals_untouched"] is True
    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)
