# 핀 정보 뷰어 (2026-07-22, CEO 지시 4건 + 추가 1건)
# 목적:
#   (1) 큰 사진이 오른쪽 공정서 패널 '최상단' · 패널 폭 가득 · 여러 장이면 좌우로 넘겨보기
#   (2) VLA 공정서 스키마 구획 표시 (제품/부위/도번/재질/공정/작업자/요약/상세설명/주의사항/태그)
#       + 새 항목은 pin 에 '추가만' (기존 md/photos/local/ctxPath 무손상)
#   (3) SHIN 음성 브리핑 — 🔊 버튼으로 켜고 끔, 기본 꺼짐(자동 재생 없음),
#       8093 꺼져 있으면 오류 없이 글로만 표시(자동 낭독 안 함)
#   (4) 폰(≤560px) 폭에서도 같은 내용이 보인다
#   (5) 말풍선 줌 연동 크기 — 기준거리 대비 (거리비)^(-0.6), 0.5~2.0 배 클램프
import json, 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": [], "hero": {}, "spec": {}, "tts": {},
          "zoom": {}, "input": {}, "phone": {}}


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)


# 실제 데이터(실리24톤-1)와 같은 모양: 사진 2장 + 마크다운 메모를 가진 핀
SEED = r"""
() => {
  const P = window.FAB_PINPHOTO;
  if (!P || !P._state) return { err: 'no FAB_PINPHOTO' };
  // ★★★ 안전장치 — 검증이 실제 저장 서버(8091)를 절대 건드리지 않게 한다 ★★★
  //   fab_pinphoto 의 savePins()/uploadPhoto() 는 apiBase()=8091 로 POST 한다.
  //   검증용 씨앗 핀을 그대로 저장해 버리면 CEO의 실제 pin_notes.json 이 덮여 사라진다(실제 사고 있었음).
  //   → 저장 계열 요청만 가로채서 성공만 흉내낸다. 화면 동작 검증에는 영향 없다.
  const _fetch = window.fetch.bind(window);
  window.__blockedSaves = [];
  window.fetch = function (u, o) {
    const url = String((u && u.url) || u || '');
    if (/\/save-(pinnotes|photo|viewstate|grouping)/.test(url)) {
      window.__blockedSaves.push(url);
      return Promise.resolve(new Response('{"ok":true}', { status: 200, headers: { 'Content-Type': 'application/json' } }));
    }
    return _fetch(u, o);
  };
  const s = P._state;
  s.product = '__TEST__실리24톤-1';
  s.procSteps = ['용접'];
  s.pins = [
    { id:'spec2', partIndex:0, path:'G04/Part88.1', name:'Part88.1', local:{x:0,y:0,z:0},
      step:'용접', ctxPath:['G04'],
      md:'## 내부 안쪽용접\n- 단속 용접 50-150 으로 TIG\n- **좌우 대칭으로 번갈아**',
      photos:[{file:'a.jpg',desc:''},{file:'b.jpg',desc:''}] },
    { id:'spec1', partIndex:0, path:'G04/Part85.1', name:'Part85.1', local:{x:0,y:0,z:0},
      step:'용접', ctxPath:['G04'], md:'보강판 풀용접', photos:[{file:'c.jpg',desc:''}] }
  ];
  // 음성 낭독을 가로채 '무엇을 읽었는지'만 기록(실제 소리 없음)
  window.__spoken = [];
  try {
    const ss = window.speechSynthesis || {};
    ss.speak = u => window.__spoken.push(String((u && u.text) || ''));
    ss.cancel = () => {};
    if (!window.speechSynthesis) Object.defineProperty(window, 'speechSynthesis', { value: ss });
  } catch (e) { window.__stubErr = String(e); }
  if (typeof window.SpeechSynthesisUtterance !== 'function') {
    window.SpeechSynthesisUtterance = function (t) { this.text = t; this.lang = ''; this.rate = 1; };
  }
  // [2026-07-23] 핀 🔊 는 이제 브라우저 내장 음성을 직접 부르지 않고
  //   공통 낭독기(DZW_SHIN_TTS)로 통일됐다 → 가로채는 지점도 그쪽으로 옮긴다.
  //   (여기를 안 바꾸면 __spoken 이 늘 비어 '읽지 않았다'고 잘못 판정한다)
  try {
    if (window.DZW_SHIN_TTS && typeof window.DZW_SHIN_TTS.speak === 'function') {
      window.DZW_SHIN_TTS.speak = t => window.__spoken.push(String(t || ''));
      window.DZW_SHIN_TTS.stop = () => {};
    }
  } catch (e) { window.__stubErr2 = String(e); }
  try { localStorage.removeItem('fab.pin.tts'); } catch (e) {}
  return { ok: true, pins: s.pins.length };
}
"""

# (1) 큰 사진 = 패널 최상단 · 패널 폭 가득 · 좌우 넘기기
HERO = r"""
() => {
  const P = window.FAB_PINPHOTO;
  P._state.product = '__TEST__실리24톤-1';
  const pin = P._state.pins.find(p => p.id === 'spec2');
  P.showDetail(pin);
  const panel = document.getElementById('fab-pinphoto-detail');
  const body  = panel.querySelector('div:nth-child(2)');   // 헤더 다음 = 본문
  const hero  = document.getElementById('fab-pinphoto-hero');
  const img   = document.getElementById('fab-pinphoto-bigphoto');
  const prev  = document.getElementById('fab-pinphoto-prev');
  const next  = document.getElementById('fab-pinphoto-next');
  const cnt   = document.getElementById('fab-pinphoto-count');
  const cs = getComputedStyle(body);
  const bw = body.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
  const iw = img.getBoundingClientRect().width;
  const out = {
    heroIsFirst: body.firstElementChild === hero,          // ★ 패널 최상단
    heroBeforeSpec: !!(hero.compareDocumentPosition(document.getElementById('fab-pinphoto-spec'))
                       & Node.DOCUMENT_POSITION_FOLLOWING),
    fullWidth: bw > 0 && (iw / bw) > 0.95,                 // ★ 패널 폭 가득
    imgWidthPx: Math.round(iw), bodyWidthPx: bw,
    navShown: getComputedStyle(prev).display !== 'none' && getComputedStyle(next).display !== 'none',
    count0: cnt.textContent,
    idx0: P.photoIndex(), n: P.photoCount(),
    src0: (img.getAttribute('src') || '').split('/').pop()
  };
  P.stepPhoto(1);
  out.idx1 = P.photoIndex();
  out.count1 = cnt.textContent;
  out.src1 = (img.getAttribute('src') || '').split('/').pop();
  P.stepPhoto(1);                                          // 마지막 → 처음으로 순환
  out.idxWrap = P.photoIndex();
  // 사진 1장인 핀 → 좌우 버튼 숨김
  P.showDetail(P._state.pins.find(p => p.id === 'spec1'));
  out.navHiddenSingle = getComputedStyle(document.getElementById('fab-pinphoto-prev')).display === 'none';
  P.showDetail(pin);
  return out;
}
"""

# (2) VLA 공정서 스키마 구획
SPEC = r"""
() => {
  const P = window.FAB_PINPHOTO;
  P._state.product = '__TEST__실리24톤-1';
  const pin = P._state.pins.find(p => p.id === 'spec2');
  // 새 항목 입력(추가만)
  pin.partNo = 'D-19010200'; pin.material = 'SM490A';
  pin.worker = '홍길동'; pin.summary = '실린더 홈선 용접';
  pin.notes = '단속용접 50-150, 좌우 대칭';
  pin.tags = ['용접','TIG','보강판'];
  P.showDetail(pin);
  const spec = document.getElementById('fab-pinphoto-spec');
  const cards = Array.from(spec.querySelectorAll('[data-spec]'));
  const keys = cards.map(c => c.getAttribute('data-spec'));
  const md = document.getElementById('fab-pinphoto-md');
  const descCard = spec.querySelector('[data-spec="description"]');
  const txt = k => {
    const c = spec.querySelector('[data-spec="' + k + '"]');
    return c ? c.textContent : '';
  };
  return {
    keys: keys,
    hasAll: ['product','part_name','part_no','material','process','worker',
             'summary','description','notes','tags'].every(k => keys.indexOf(k) >= 0),
    product: txt('product').includes('실리24톤-1'),
    partNo: txt('part_no').includes('D-19010200'),
    material: txt('material').includes('SM490A'),
    worker: txt('worker').includes('홍길동'),
    process: txt('process').includes('용접'),
    summary: txt('summary').includes('실린더 홈선 용접'),
    notes: txt('notes').includes('단속용접 50-150'),
    tagChips: spec.querySelectorAll('[data-spec="tags"] [data-tag]').length,
    // 6하원칙 꼬리표가 모든 구획에 붙어 있나
    sixW: ['누가','언제','어디서','무엇을','어떻게','왜'].every(w => spec.textContent.includes(w)),
    // 상세설명 = 기존 마크다운 md 필드가 그대로 살아 있다
    mdInDescCard: !!(descCard && md && descCard.contains(md)),
    mdHasH2: !!(md && md.querySelector('h2')),
    mdHasStrong: !!(md && md.querySelector('strong')),
    // 순수 코어(스키마 매핑)
    core: P.pinToSpec(pin, '실리24톤-1'),
    // 기존 필드 무손상
    fieldsIntact: pin.md.includes('단속 용접') && pin.photos.length === 2 &&
                  JSON.stringify(pin.ctxPath) === '["G04"]' && !!pin.local
  };
}
"""

# (2b) 입력 팝업 — 새 항목이 pin 에 '추가만' 되는가 (기존 필드 삭제 금지)
INPUT = r"""
() => {
  const P = window.FAB_PINPHOTO;
  P._state.product = '__TEST__실리24톤-1';
  const pin = P._state.pins.find(p => p.id === 'spec1');
  const before = { md: pin.md, photos: pin.photos.length, ctx: JSON.stringify(pin.ctxPath) };
  P.openEditor(pin, false);
  const pop = document.getElementById('fab-pinphoto-popup');
  const box = document.getElementById('fab-pinphoto-specinput');
  const inp = k => pop.querySelector('[data-spec-input="' + k + '"]');
  const out = { hasSpecInput: !!box,
                fields: Array.from(pop.querySelectorAll('[data-spec-input]')).map(i => i.getAttribute('data-spec-input')) };
  inp('partNo').value = 'D-77770000';
  inp('material').value = 'SS400';
  inp('worker').value = '김반장';
  inp('summary').value = '보강판 풀용접';
  inp('notes').value = '안쪽 링 먼저';
  inp('tags').value = '용접, 보강판 , ';
  pop.querySelectorAll('button').forEach(b => { if (b.textContent.trim() === '저장') b.click(); });
  out.partNo = pin.partNo; out.material = pin.material; out.worker = pin.worker;
  out.summary = pin.summary; out.notes = pin.notes; out.tags = pin.tags;
  out.mdKept = pin.md === before.md;
  out.photosKept = pin.photos.length === before.photos;
  out.ctxKept = JSON.stringify(pin.ctxPath) === before.ctx;
  // 다시 열면 저장값이 그대로 채워지나
  P.openEditor(pin, false);
  out.reopenPartNo = inp('partNo').value;
  out.reopenTags = inp('tags').value;
  document.getElementById('fab-pinphoto-popup').style.display = 'none';
  return out;
}
"""

# (3) 🔊 음성 브리핑 — 기본 꺼짐 / 버튼으로 켜고 끔 / 8093 꺼져 있으면 자동 낭독 없음
TTS = r"""
async () => {
  const P = window.FAB_PINPHOTO;
  P._state.product = '__TEST__실리24톤-1';
  const pin = P._state.pins.find(p => p.id === 'spec2');
  const btn = document.getElementById('fab-pinphoto-tts');
  window.__spoken = [];
  P.setTtsOn(false);
  P.showDetail(pin);                       // 8093 미가동 → 폴백(글만)
  await new Promise(r => setTimeout(r, 900));
  const out = {
    hasBtn: !!btn,
    defaultOff: P.ttsOn() === false,
    offLabel: btn.textContent,
    spokenWhenOff: window.__spoken.length,          // ★ 자동 재생 0
    shinTextOnly: (document.getElementById('fab-pinphoto-detail').textContent || '')
                    .includes('8093'),              // 8093 미가동 안내가 '글로' 표시
    briefOffReturns: P.briefNow()                   // 꺼져 있으면 아무 것도 안 함
  };
  btn.click();                                       // 🔊 켜기 (사용자 의사 → 즉시 낭독)
  out.onAfterClick = P.ttsOn();
  out.onLabel = btn.textContent;
  out.spokenAfterOn = window.__spoken.length;
  out.spokenText = (window.__spoken[0] || '').slice(0, 160);
  out.briefHasNotes = (window.__spoken[0] || '').includes('단속용접');   // 숙련자 준수사항 포함
  btn.click();                                       // 🔊 끄기
  out.offAfterClick2 = P.ttsOn();
  window.__spoken = [];
  P.showDetail(pin);
  await new Promise(r => setTimeout(r, 700));
  out.spokenWhenOffAgain = window.__spoken.length;    // 꺼진 뒤에는 다시 조용
  out.brief = P.buildBrief(pin, '실리24톤-1').slice(0, 120);
  P.setTtsOn(false);
  return out;
}
"""

# (5) 줌 연동 말풍선 크기 — 순수 함수
ZOOM = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const z = (d, d0) => P.zoomScale(d, d0);
  return {
    same: z(100, 100),                       // 기준거리 = 1.0배
    far: +z(400, 100).toFixed(4),            // 4배 멀리 → 4^-0.6 = 0.4353 → 하한 0.5 로 클램프
    farMid: +z(200, 100).toFixed(4),         // 2배 멀리 → 2^-0.6 = 0.6598
    near: +z(50, 100).toFixed(4),            // 절반 거리 → 2^0.6 = 1.5157
    clampLo: z(1e9, 100), clampHi: z(1e-9, 100),
    monotone: z(50, 100) > z(100, 100) && z(100, 100) > z(200, 100),
    guard: z(0, 100) === 1 && z(100, 0) === 1
  };
}
"""

with sync_playwright() as pw:
    b = pw.chromium.launch()
    pg = b.new_page(viewport={"width": 1600, "height": 950})
    pg.on("console", _con)
    pg.on("pageerror", lambda e: result["js_exceptions"].append("PAGEERROR:" + str(e)))
    pg.goto(URL, wait_until="load")
    pg.wait_for_timeout(2500)

    seed = pg.evaluate(SEED)
    result["seed"] = seed
    result["hero"] = pg.evaluate(HERO)
    result["spec"] = pg.evaluate(SPEC)
    result["input"] = pg.evaluate(INPUT)
    result["tts"] = pg.evaluate(TTS)
    result["zoom"] = pg.evaluate(ZOOM)
    result["blockedSaves"] = pg.evaluate("() => window.__blockedSaves || []")
    pg.screenshot(path=D + r"\05_BROWSER_TESTS\_specviewer_pc.png")

    # (4) 폰 폭 — 같은 내용이 그대로 보인다
    pg.set_viewport_size({"width": 390, "height": 844})
    pg.wait_for_timeout(900)
    result["phone"] = pg.evaluate(r"""
    () => {
      const P = window.FAB_PINPHOTO;
      P.showDetail(P._state.pins.find(p => p.id === 'spec2'));
      const hero = document.getElementById('fab-pinphoto-hero');
      const spec = document.getElementById('fab-pinphoto-spec');
      const img = document.getElementById('fab-pinphoto-bigphoto');
      const r = img.getBoundingClientRect();
      return {
        heroVisible: !!hero && getComputedStyle(hero).display !== 'none',
        specCards: spec ? spec.querySelectorAll('[data-spec]').length : 0,
        imgW: Math.round(r.width),
        insideScreen: r.left >= -1 && r.right <= window.innerWidth + 1,
        ttsBtn: !!document.getElementById('fab-pinphoto-tts')
      };
    }""")
    pg.screenshot(path=D + r"\05_BROWSER_TESTS\_specviewer_phone.png")
    b.close()

srv.terminate()

h, s, t, z, i, ph = (result["hero"], result["spec"], result["tts"],
                     result["zoom"], result["input"], result["phone"])
checks = {
    "JS 예외 0": len(result["js_exceptions"]) == 0,
    "실제 저장 서버(8091) 미접촉": len(result.get("blockedSaves", [])) > 0
                                    and all("save-" in u for u in result["blockedSaves"]),
    "① 큰 사진이 패널 최상단": h.get("heroIsFirst") and h.get("heroBeforeSpec"),
    "① 사진이 패널 폭 가득": h.get("fullWidth"),
    "① 사진 2장 좌우 넘기기": (h.get("navShown") and h.get("count0") == "1 / 2"
                               and h.get("idx1") == 1 and h.get("src0") != h.get("src1")
                               and h.get("idxWrap") == 0),
    "① 1장이면 화살표 숨김": h.get("navHiddenSingle"),
    "② 스키마 10구획 표시": s.get("hasAll"),
    "② 값이 구획에 채워짐": all(s.get(k) for k in ("product", "partNo", "material", "worker",
                                                   "process", "summary", "notes")),
    "② 태그 칩 3개": s.get("tagChips") == 3,
    "② 6하원칙 꼬리표": s.get("sixW"),
    "② 상세설명 = 기존 md(마크다운) 유지": s.get("mdInDescCard") and s.get("mdHasH2") and s.get("mdHasStrong"),
    "② 기존 필드 무손상": s.get("fieldsIntact"),
    "② 입력칸 6종": i.get("hasSpecInput") and i.get("fields") == ["partNo", "material", "worker", "summary", "notes", "tags"],
    "② 입력값 추가 저장": (i.get("partNo") == "D-77770000" and i.get("worker") == "김반장"
                            and i.get("tags") == ["용접", "보강판"]),
    "② 저장이 기존 필드 안 지움": i.get("mdKept") and i.get("photosKept") and i.get("ctxKept"),
    "② 재오픈 시 값 복원": i.get("reopenPartNo") == "D-77770000",
    "③ 🔊 버튼 존재·기본 꺼짐": t.get("hasBtn") and t.get("defaultOff") and t.get("offLabel") == "🔇",
    "③ 자동 재생 없음(8093 꺼짐)": t.get("spokenWhenOff") == 0 and t.get("briefOffReturns") is False,
    "③ 8093 꺼져도 글로만 표시(오류 0)": t.get("shinTextOnly"),
    "③ 🔊 켜면 브리핑 낭독": t.get("onAfterClick") and t.get("onLabel") == "🔊" and t.get("spokenAfterOn") >= 1,
    "③ 브리핑에 준수사항 포함": t.get("briefHasNotes"),
    "③ 🔊 끄면 다시 조용": (t.get("offAfterClick2") is False) and t.get("spokenWhenOffAgain") == 0,
    "④ 폰에서도 같은 내용": (ph.get("heroVisible") and ph.get("specCards") == 10
                              and ph.get("insideScreen") and ph.get("ttsBtn")),
    "⑤ 줌: 기준거리에서 1.0배": z.get("same") == 1,
    "⑤ 줌: 멀수록 작아짐(0.6지수)": z.get("monotone") and z.get("farMid") == 0.6598,
    "⑤ 줌: 0.5~2배 클램프": z.get("clampLo") == 0.5 and z.get("clampHi") == 2.0 and z.get("far") == 0.5,
    "⑤ 줌: 0 나눗셈 방어": z.get("guard"),
}
print(json.dumps(result, ensure_ascii=False, indent=1))
print("\n── 판정 ──")
for k, v in checks.items():
    print(("  PASS  " if v else "  FAIL  ") + k)
ok = all(checks.values())
print("결과:", "PASS" if ok else "FAIL")
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
