# 핀 공정필터 + 마크다운 공정서 게이트 (2026-07-22, CEO 요구 2건)
# 목적:
#   (a) 공정 필터 — 공정A 선택 시 A핀만 노출 / B핀 숨김, '전체' 선택 시 모두 노출
#   (b) 마크다운 렌더 — 제목·목록·굵게·코드·구분선 태그 생성 + HTML 이스케이프(XSS 차단)
#   (c) 구버전 pin_notes.json 호환 — step/md 없는 핀도 로드되고 '전체'에서만 보이며 본문은 옛 desc 사용
#   (d) 상세 패널이 오른쪽 도크(#fab-right) 안에 있음 / JS 예외 0
import json, sys, subprocess, time
from playwright.sync_api import sync_playwright

D = r"E:\도진팩토리\3D스캔및티칭시스템"
PORT = 8097
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": [], "steps": {}, "md": {}, "legacy": {}}


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)


# 구버전(step/md 없음) 1개 + 신버전 2개(공정A/공정B)
SEED = r"""
() => {
  const P = window.FAB_PINPHOTO;
  if (!P || !P._state) return { err: 'no FAB_PINPHOTO' };
  const s = P._state;
  s.product = 'TESTPROD';
  s.procSteps = ['공정A', '공정B'];
  s.pins = [
    { id:'legacy1', partIndex:0, path:'X/L', name:'L', local:{x:0,y:0,z:0},
      photos:[{ file:'a.jpg', desc:'옛날 한 줄 설명' }] },
    { id:'pinA', partIndex:0, path:'X/A', name:'A', local:{x:0,y:0,z:0}, step:'공정A',
      md:'## 취부\n- 기준면 먼저\n- **뒤틀림 주의**\n\n---\n\n`TIG` 사용\n\n<img src=x onerror=alert(1)>',
      photos:[] },
    { id:'pinB', partIndex:0, path:'X/B', name:'B', local:{x:0,y:0,z:0}, step:'공정B', md:'# 본용접', photos:[] }
  ];
  P.setCurrentStep('');
  return { ok: true, pins: s.pins.length };
}
"""

# 필터 판정은 dot 의 data-step-hidden 으로 읽는다(모델 미로드 상태에서도 결정적).
PROBE = r"""
(step) => {
  const P = window.FAB_PINPHOTO;
  P.setCurrentStep(step);
  const layer = document.getElementById('fab-pinphoto-overlay');
  const out = {};
  P._state.pins.forEach(p => {
    out[p.id] = { visible: P.pinVisibleIn(p, P._state.currentStep) };
  });
  const dots = layer ? layer.querySelectorAll('[data-step-hidden]') : [];
  out._domHidden = Array.from(dots).map(d => d.getAttribute('data-step-hidden'));
  out._sel = (document.getElementById('fab-pin-stepfilter') || {}).value;
  return out;
}
"""

MD = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const src = '# 제목1\n## 제목2\n- 항목1\n- 항목2\n1. 번호1\n2. 번호2\n\n**굵게** *기울임* `코드`\n\n---\n\n<script>alert(1)</script> & "따옴표"';
  const h = P.mdToHtml(src);
  return {
    h1: h.includes('<h1>제목1</h1>'), h2: h.includes('<h2>제목2</h2>'),
    ul: h.includes('<ul>') && h.includes('<li>항목1</li>'),
    ol: h.includes('<ol>') && h.includes('<li>번호1</li>'),
    strong: h.includes('<strong>굵게</strong>'),
    em: h.includes('<em>기울임</em>'),
    code: h.includes('<code>코드</code>'),
    hr: h.includes('<hr>'),
    noRawScript: !h.includes('<script'),
    escaped: h.includes('&lt;script&gt;') && h.includes('&amp;') && h.includes('&quot;'),
    html: h
  };
}
"""

# 상세 패널: pinA 를 열어 실제 DOM 에 마크다운 태그가 들어가고 script 노드는 0 인지
DETAIL = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const pin = P._state.pins.find(p => p.id === 'pinA');
  P.showDetail(pin);
  const box = document.getElementById('fab-pinphoto-md');
  const panel = document.getElementById('fab-pinphoto-detail');
  const dock = document.getElementById('fab-right');
  return {
    hasH2: !!(box && box.querySelector('h2')),
    liCount: box ? box.querySelectorAll('li').length : -1,
    hasStrong: !!(box && box.querySelector('strong')),
    hasCode: !!(box && box.querySelector('code')),
    hasHr: !!(box && box.querySelector('hr')),
    scriptNodes: box ? box.querySelectorAll('script').length : -1,
    imgNodes: box ? box.querySelectorAll('img').length : -1,
    textHasRawTag: box ? box.textContent.includes('<img') : false,
    inDock: !!(panel && dock && panel.parentElement === dock),
    stepBadge: (panel && panel.textContent.includes('공정')) || false
  };
}
"""

LEGACY = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const legacy = P._state.pins.find(p => p.id === 'legacy1');
  P.showDetail(legacy);
  const box = document.getElementById('fab-pinphoto-md');
  return {
    mdOf: P.mdOf(legacy),                       // 옛 photos[0].desc 를 본문으로 승계
    stepOf: P.stepOf(legacy),                   // '' (미지정)
    visibleInAll: P.pinVisibleIn(legacy, ''),   // 전체에서 보임
    visibleInA: P.pinVisibleIn(legacy, '공정A'), // 특정 공정에서는 숨김
    rendered: box ? box.textContent.trim() : '',
    fieldsIntact: !!(legacy.photos && legacy.photos[0] && legacy.photos[0].desc)
  };
}
"""

# (e) 핀 생성/편집 팝업 배선 — 공정 선택지·직접입력 전환·마크다운 textarea·저장값 반영
POPUP = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const s = P._state;
  const fresh = { id:'newpin', partIndex:0, path:'X/N', name:'N', local:{x:0,y:0,z:0}, step:'', md:'', photos:[] };
  s.pins.push(fresh);
  P.openEditor(fresh, true);
  const pop = document.getElementById('fab-pinphoto-popup');
  const sel = pop.querySelector('select');
  const free = pop.querySelectorAll('input[type=text]')[0];
  const ta = pop.querySelector('textarea');
  const opts = Array.from(sel.options).map(o => o.textContent);
  const out = {
    popupOpen: getComputedStyle(pop).display !== 'none',
    options: opts,
    hasUnset: opts[0] === '(공정 미지정)',
    hasA: opts.some(t => t.includes('공정A')),
    hasB: opts.some(t => t.includes('공정B')),
    hasFree: opts[opts.length - 1].includes('직접 입력'),
    hasTextarea: !!ta,
    freeHiddenBefore: getComputedStyle(free).display === 'none'
  };
  // 직접 입력 전환
  sel.value = '__free__';
  sel.dispatchEvent(new Event('change'));
  out.freeShownAfter = getComputedStyle(free).display !== 'none';
  free.value = '직접입력공정';
  ta.value = '## 새 공정\n- 한 줄';
  // 저장 로직만 검증(서버 POST 는 실패해도 무방) — 값 반영 여부만 본다
  pop.querySelectorAll('button').forEach(b => { if (b.textContent.trim() === '저장') b.click(); });
  out.pinStep = fresh.step;
  out.pinMd = fresh.md;
  // 목록 선택 경로도 확인
  P.openEditor(fresh, false);
  const sel2 = document.getElementById('fab-pinphoto-popup').querySelector('select');
  // 직접 입력한 공정명은 이후 목록(allStepNames)에 합류하므로 재오픈 시 그 항목이 선택돼 있어야 한다
  out.reopenSelected = sel2.value;
  out.reopenInList = Array.from(sel2.options).some(o => o.value === '직접입력공정');
  sel2.value = '공정B'; sel2.dispatchEvent(new Event('change'));
  document.getElementById('fab-pinphoto-popup')
    .querySelectorAll('button').forEach(b => { if (b.textContent.trim() === '저장') b.click(); });
  out.pinStep2 = fresh.step;
  // 뒷정리
  s.pins = s.pins.filter(p => p.id !== 'newpin');
  P.render();
  return out;
}
"""

# (f) 계층(컨텍스트) 필터 — 핀은 찍을 당시 계층에서만 보인다(상·하위 번짐 없음)
CTX = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const pinG   = { id:'ctxG',  ctxPath:['G01'] };
  const pinSub = { id:'ctxS',  ctxPath:['G01','부품A'] };
  const legacy = { id:'ctxL' };                       // 구버전 핀(ctxPath 없음) = 계층 미지정
  return {
    // 일치
    matchSame:    P.ctxMatch(pinG, ['G01']),
    matchRoot:    P.ctxMatch(legacy, []),
    matchDeep:    P.ctxMatch(pinSub, ['G01','부품A']),
    // 불일치 (상위/하위로 번지지 않음)
    missUp:       P.ctxMatch(pinG, []),
    missDown:     P.ctxMatch(pinG, ['G01','부품A']),
    missSibling:  P.ctxMatch(pinG, ['G02']),
    legacyInG:    P.ctxMatch(legacy, ['G01']),
    // pinVisibleIn 결합: 공정 AND 계층
    visBoth:      P.pinVisibleIn({ step:'공정A', ctxPath:['G01'] }, '공정A', ['G01']),
    visStepOnly:  P.pinVisibleIn({ step:'공정A', ctxPath:['G01'] }, '공정A', []),
    visCtxOnly:   P.pinVisibleIn({ step:'공정B', ctxPath:['G01'] }, '공정A', ['G01']),
    visLegacyRoot:P.pinVisibleIn({}, '', []),
    visLegacyIn:  P.pinVisibleIn({}, '', ['G01']),
    // [CEO 확정 규칙 · 번복 금지 2026-07-22] 사진은 블럭 안에서만.
    // 전체(depth0)에서는 계층 있는 핀도, 계층 미지정 옛 핀도, 어떤 핀도 보이지 않는다.
    visCtxAtRoot: P.pinVisibleIn({ ctxPath:['G01'] }, '', []),
    visEmptyCtxAtRoot: P.pinVisibleIn({ ctxPath:[] }, '', []),
    // 미지정(stranded) 판정 — 필드 없음 / [] 둘 다 미지정으로 잡혀야 목록에서 안 새어나간다
    strandedNoField: P.isStranded({}),
    strandedEmpty:   P.isStranded({ ctxPath:[] }),
    strandedHasPath: P.isStranded({ ctxPath:['G01'] }),
    strandedCount:   P.countStranded([{}, { ctxPath:[] }, { ctxPath:['G01'] }]),
    // 2인자 구 호출은 계층 필터 생략(하위호환)
    legacyCall:   P.pinVisibleIn({ ctxPath:['G01'] }, ''),
    // 화면 라벨 + 현재 계층 읽기(fab_isolate 단일 진실원천)
    labelRoot:    P.ctxLabel([]),
    labelG:       P.ctxLabel(['G01']),
    labelCount:   P.ctxLabel([], 0),
    curCtx:       P.currentCtxPath(),
    labelDom:     (document.getElementById('fab-pin-ctxlabel') || {}).textContent,
    labelInBar:   !!(document.getElementById('fab-pin-ctxlabel')
                     && document.getElementById('fab-pin-ctxlabel').parentElement.id === 'fab-actionbar')
  };
}
"""

# (g) [CEO 2026-07-22 원복] 전체(depth0) = 계층 미지정 핀만 보임 / 계층 진입 시 그 계층 핀 표시
#     + 미지정 핀은 '계층: 미지정 N' 칩과 구조 패널로 특정 계층에 묶을 수 있어야 한다.
DOMCTX = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const s = P._state;
  // FAB_ISO.state 는 getter(복사본)라 직접 대입이 안 먹는다 → 계층만 흉내내는 스텁으로 교체.
  //   (fab_isolate.js 는 무수정 — 여기서 잠깐 갈아끼웠다가 끝에서 되돌린다)
  const realIso = window.FAB_ISO;
  window.__fabRealIso = realIso;
  window.FAB_ISO = { _dbg: realIso && realIso._dbg, state: { isoPath: [] } };
  const setIso = (p) => { window.FAB_ISO.state = { isoPath: p }; };
  const hiddenCount = () => {
    const layer = document.getElementById('fab-pinphoto-overlay');
    if (!layer) return -1;
    return Array.from(layer.querySelectorAll('[data-step-hidden]'))
                .filter(d => d.getAttribute('data-step-hidden') === '1').length;
  };
  const total = () => {
    const layer = document.getElementById('fab-pinphoto-overlay');
    return layer ? layer.querySelectorAll('[data-step-hidden]').length : -1;
  };
  // 시드: 미지정 옛 핀 1 + G01 핀 1 + G01/부품A 핀 1
  s.currentStep = '';
  s.pins = [
    { id:'old1', partIndex:0, path:'X/O', name:'옛핀', local:{x:0,y:0,z:0}, photos:[{file:'a.jpg',desc:'옛 설명'}] },
    { id:'gp',   partIndex:0, path:'X/G', name:'G핀',  local:{x:0,y:0,z:0}, ctxPath:['G01'], photos:[] },
    { id:'sp',   partIndex:0, path:'X/S', name:'단품핀', local:{x:0,y:0,z:0}, ctxPath:['G01','부품A'], photos:[] }
  ];
  const out = {};
  // ① 전체(depth0) = 핀 0장 [CEO 확정 규칙 · 번복 금지]
  setIso([]);  P.render();
  out.rootHidden = hiddenCount();
  out.rootTotal  = total();
  // ② G01 진입 → G01 핀만
  setIso(['G01']); P.render();
  out.gHidden = hiddenCount();
  out.gVisibleIds = s.pins.filter(p => P.pinVisibleIn(p, s.currentStep, ['G01'])).map(p => p.id);
  // ③ 단품 진입 → 단품 핀만 (상위 G01 핀은 안 보임)
  setIso(['G01','부품A']); P.render();
  out.subVisibleIds = s.pins.filter(p => P.pinVisibleIn(p, s.currentStep, ['G01','부품A'])).map(p => p.id);
  // ④ 미지정 칩: 개수 표시 + 보임
  setIso([]); P.render();
  P.syncStrandedChip();
  const chip = document.getElementById('fab-pin-stranded');
  out.chipText = chip ? chip.textContent : null;
  out.chipShown = chip ? getComputedStyle(chip).display !== 'none' : false;
  out.chipInBar = !!(chip && chip.parentElement && chip.parentElement.id === 'fab-actionbar');
  // ⑤ 구조 패널: 도크 안에 붙고, 미지정 핀 1개가 목록에 있고, depth0 에서는 이동 버튼 비활성
  P.toggleStrandedPanel();
  const panel = document.getElementById('fab-pin-stranded-panel');
  const dock = document.getElementById('fab-right');
  out.panelInDock = !!(panel && dock && panel.parentElement === dock);
  out.rowCount = panel ? panel.querySelectorAll('button').length : -1;
  out.moveDisabledAtRoot = panel ? panel.querySelector('button').disabled : null;
  // ⑥ G01 로 들어가면 이동 버튼 활성 → 누르면 그 계층으로 이동하고 3D에 다시 보인다
  setIso(['G01']); P.renderStrandedPanel();
  out.moveEnabledInG = panel ? panel.querySelector('button').disabled === false : null;
  panel.querySelector('button').click();
  out.oldCtxPath = s.pins.find(p => p.id === 'old1').ctxPath;
  out.oldVisibleInG = P.pinVisibleIn(s.pins.find(p => p.id === 'old1'), '', ['G01']);
  // ⑦ 데이터 보존: 기존 필드(photos/desc)는 그대로
  const o = s.pins.find(p => p.id === 'old1');
  out.fieldsIntact = !!(o.photos && o.photos[0] && o.photos[0].desc === '옛 설명');
  // (라벨은 rAF 루프가 갱신하므로 별도 LABEL 프로브에서 대기 후 읽는다 — isoPath=['G01'] 유지)
  P.toggleStrandedPanel();
  P.render();
  return out;
}
"""

# 라벨은 projectLoop(rAF)가 갱신 → DOMCTX 직후 잠깐 기다렸다가 읽는다.
LABEL = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const el = document.getElementById('fab-pin-ctxlabel');
  const out = { inG: el ? el.textContent : null };
  window.FAB_ISO.state = { isoPath: [] };
  return out;
}
"""
LABEL_ROOT = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const el = document.getElementById('fab-pin-ctxlabel');
  const out = { atRoot: el ? el.textContent : null };
  P._state.pins = [];
  if (window.__fabRealIso) window.FAB_ISO = window.__fabRealIso;   // 실제 fab_isolate 복구
  P.render();
  return out;
}
"""

# (h) [CEO 2026-07-22 확정 · 게이트 폐지] 자동 표시 — 버튼을 누르지 않아도 그 계층 사진이 바로 보인다.
#     📍 버튼은 '핀 찍기 모드' 전용으로 분리(보기와 찍기 분리). 표시 수는 버튼과 무관해야 한다.
#     + 말풍선(끈으로 이어진 사진 카드) 기본 오프셋 / 드래그 저장(pin.balloon) 검증.
GATE = r"""
() => {
  const P = window.FAB_PINPHOTO;
  const s = P._state;
  const realIso = window.FAB_ISO;
  window.__fabRealIso2 = realIso;
  window.FAB_ISO = { _dbg: realIso && realIso._dbg, state: { isoPath: [] } };
  const setIso = (p) => { window.FAB_ISO.state = { isoPath: p }; };
  const layer = () => document.getElementById('fab-pinphoto-overlay');
  const btn = () => document.getElementById('fab-pin-toggle');
  const dots = () => Array.from(layer().querySelectorAll('[data-step-hidden]'));
  const gated = () => dots().filter(d => d.getAttribute('data-gate-hidden') === '1').length;
  // 실제로 3D에 그려진 핀 수 = 계층 필터 통과(게이트는 폐지되어 항상 0)
  const drawn = () => dots().filter(d => d.getAttribute('data-step-hidden') === '0'
                                      && d.getAttribute('data-gate-hidden') === '0').length;
  const passing = (ctx) => s.pins.filter(p => P.pinVisibleIn(p, s.currentStep, ctx)).length;
  s.currentStep = '';
  s.pins = [
    { id:'gate_root', partIndex:0, path:'X/O', name:'미지정핀', local:{x:0,y:0,z:0}, photos:[{file:'a.jpg',desc:'ㅇ'}] },
    { id:'gate_g',    partIndex:0, path:'X/G', name:'G핀', local:{x:0,y:0,z:0}, ctxPath:['G01'], photos:[{file:'g.jpg',desc:'ㄱ'}] }
  ];
  const out = {};
  // ★ [CEO 확정 규칙 — 사진은 블럭 안에서만. 전체에서는 절대 표시 금지. 번복 금지(2026-07-22)]
  //   ⓪ 전체(depth0) = 버튼과 무관하게 핀 0장. 이 두 단언이 규칙의 감시탑이다.
  P.setMode(false);                      // 핀 찍기 모드만 OFF
  setIso([]); P.render();
  out.rootDrawn = drawn();               // 전체에서는 0장이어야 한다
  out.autoCountAtRoot = passing([]);     // 전체에서는 0개 통과여야 한다
  // ① 블럭(G01)에 들어가면 버튼을 한 번도 누르지 않아도 자동으로 뜬다
  setIso(['G01']); P.render();
  out.autoShown = P.pinsShown();         // 항상 true (게이트 폐지)
  out.autoMode  = s.on;                  // 핀 찍기 모드는 꺼져 있어야 한다
  out.autoDrawn = drawn();               // 버튼 없이 G01 핀 1장이 그려진다
  out.autoGated = gated();               // 게이트로 막힌 핀 0
  out.btnInBar = !!(btn() && btn().parentElement && btn().parentElement.id === 'fab-actionbar');
  out.btnText = btn().textContent;       // '📍 핀 찍기' (보기 토글 아님)
  // ② 버튼 클릭 = 핀 찍기 모드만 켜짐. 보이는 핀 수는 변하지 않는다.
  btn().click();
  P.render();
  out.onMode = s.on;
  out.onBtnText = btn().textContent;
  out.onDrawn = drawn();
  btn().click();                          // 다시 끄기 — 그래도 사진은 계속 보인다
  P.render();
  out.offMode = s.on;
  out.offDrawn = drawn();
  // ③ 계층 진입 → 그 계층 핀만 자동 표시(버튼 조작 없음)
  setIso(['G01']); P.render();
  out.gDrawn   = drawn();
  out.gPassing = passing(['G01']);
  out.gVisibleIds = s.pins.filter(p => P.pinVisibleIn(p, s.currentStep, ['G01'])).map(p => p.id);
  // ④ 말풍선: 끈으로 이어진 사진 카드 DOM + 기본 오프셋이 충분히 멀고 '위쪽'
  //    (블럭 안에서만 뜨므로 G01 계층에서 확인한다 — CEO 확정 규칙)
  setIso(['G01']); P.render();
  const balloon = layer().querySelector('.fab-pin-balloon');
  const off = P.balloonOffset({ id:'gate_g' });
  out.balloonExists = !!balloon;
  out.balloonHasFloat = !!(balloon && balloon.querySelector('.fab-pin-float'));
  out.lineCount = layer().querySelectorAll('svg line').length;
  out.defaultDist = Math.round(Math.hypot(off.dx, off.dy));
  out.defaultUp = off.dy < 0;             // 항상 위로 뜬다(화면 중심 기준 뒤집힘 없음)
  out.balloonSize = P.BALLOON.size;
  // ⑤ 드래그 저장 필드: pin.balloon={dx,dy} 가 있으면 그 값이 그대로 쓰인다(되돌아오지 않음)
  const pinR = s.pins.find(p => p.id === 'gate_g');
  pinR.balloon = { dx: 222, dy: -333 };
  const off2 = P.balloonOffset(pinR);
  out.savedUsed = (off2.dx === 222 && off2.dy === -333);
  const pinO = s.pins.find(p => p.id === 'gate_root');
  out.fieldsIntact = !!(pinO.photos && pinO.photos[0] && pinO.photos[0].desc === 'ㅇ');   // 숨겨도 데이터는 보존
  // 정리 — 실제 fab_isolate 복구
  P.setMode(false);
  s.pins = [];
  if (window.__fabRealIso2) window.FAB_ISO = window.__fabRealIso2;
  P.render();
  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)

        result["seed"] = pg.evaluate(SEED)
        pg.evaluate("() => window.FAB_PINPHOTO.render()")
        pg.wait_for_timeout(250)
        for st in ["", "공정A", "공정B"]:
            result["steps"][st or "전체"] = pg.evaluate(PROBE, st)
            pg.wait_for_timeout(200)
        result["md"] = pg.evaluate(MD)
        result["detail"] = pg.evaluate(DETAIL)
        result["legacy"] = pg.evaluate(LEGACY)
        result["popup"] = pg.evaluate(POPUP)
        result["ctx"] = pg.evaluate(CTX)
        result["domctx"] = pg.evaluate(DOMCTX)
        pg.wait_for_timeout(400)
        result["label"] = pg.evaluate(LABEL)
        pg.wait_for_timeout(400)
        result["label"].update(pg.evaluate(LABEL_ROOT))
        pg.wait_for_timeout(300)
        result["gate"] = pg.evaluate(GATE)
        pg.wait_for_timeout(300)
        b.close()
finally:
    srv.terminate()

sA = result["steps"].get("공정A", {})
sB = result["steps"].get("공정B", {})
sAll = result["steps"].get("전체", {})
md = result["md"]
dt = result.get("detail", {})
lg = result["legacy"]
pu = result.get("popup", {})
cx = result.get("ctx", {})
dc = result.get("domctx", {})
lb = result.get("label", {})
gt = result.get("gate", {})

ok = (
    # (a) 공정 필터
    sA.get("pinA", {}).get("visible") is True
    and sA.get("pinB", {}).get("visible") is False
    and sA.get("legacy1", {}).get("visible") is False
    and sB.get("pinB", {}).get("visible") is True
    and sB.get("pinA", {}).get("visible") is False
    and all(v.get("visible") is True for k, v in sAll.items() if not k.startswith("_"))
    # [CEO 확정 규칙 · 번복 금지 2026-07-22] DOM 표시는 계층 필터도 함께 받는다 —
    #   이 시드 핀들은 전부 ctxPath 없음(미지정)이고 현재 계층은 전체(depth0)라
    #   어떤 공정을 골라도 3D에는 하나도 뜨지 않는다(= 3개 전부 숨김).
    and sA.get("_domHidden", []).count("1") == 3
    and sAll.get("_domHidden", []).count("1") == 3
    and sA.get("_sel") == "공정A"
    # (b) 마크다운 + 이스케이프
    and all(md.get(k) is True for k in
            ["h1", "h2", "ul", "ol", "strong", "em", "code", "hr", "noRawScript", "escaped"])
    and dt.get("hasH2") and dt.get("liCount", 0) >= 2 and dt.get("hasStrong")
    and dt.get("hasCode") and dt.get("hasHr")
    and dt.get("scriptNodes") == 0 and dt.get("imgNodes") == 0
    and dt.get("textHasRawTag") is True              # 태그는 '문자'로만 남아야 함
    and dt.get("inDock") is True
    # (c) 구버전 호환
    and lg.get("mdOf") == "옛날 한 줄 설명"
    and lg.get("stepOf") == ""
    and lg.get("visibleInAll") is True and lg.get("visibleInA") is False
    and "옛날 한 줄 설명" in (lg.get("rendered") or "")
    and lg.get("fieldsIntact") is True
    # (e) 생성/편집 팝업 배선
    and pu.get("popupOpen") is True
    and pu.get("hasUnset") and pu.get("hasA") and pu.get("hasB") and pu.get("hasFree")
    and pu.get("hasTextarea") is True
    and pu.get("freeHiddenBefore") is True and pu.get("freeShownAfter") is True
    and pu.get("pinStep") == "직접입력공정"
    and pu.get("pinMd") == "## 새 공정\n- 한 줄"
    and pu.get("reopenSelected") == "직접입력공정" and pu.get("reopenInList") is True
    and pu.get("pinStep2") == "공정B"
    # (f) 계층(컨텍스트) 필터 — 일치/불일치/구버전 3케이스
    and all(cx.get(k) is True for k in
            ["matchSame", "matchRoot", "matchDeep",
             "visBoth", "legacyCall",
             "strandedNoField", "strandedEmpty"])
    and all(cx.get(k) is False for k in
            ["missUp", "missDown", "missSibling", "legacyInG",
             "visStepOnly", "visCtxOnly", "visLegacyIn",
             # ★ [CEO 확정 규칙 — 사진은 블럭 안에서만. 전체에서는 절대 표시 금지. 번복 금지(2026-07-22)]
             #   이 세 단언이 규칙의 감시탑이다. 전체(depth0)에서는 어떤 핀도 False 여야 한다.
             #   여길 True 로 바꾸는 변경 = 규칙 위반. CEO 승인 없이 수정 금지.
             "visLegacyRoot", "visCtxAtRoot", "visEmptyCtxAtRoot",
             "strandedHasPath"])
    and cx.get("strandedCount") == 2          # 필드없음 + ctxPath:[] 둘 다 '미지정'으로 집계
    and cx.get("labelRoot") == "계층: 전체"
    and cx.get("labelG") == "계층: G01"
    and cx.get("labelCount") == "계층: 전체 · 핀 0"
    and cx.get("curCtx") == []
    and cx.get("labelDom") == "계층: 전체 · 핀 0"                   # ★ 전체 = 핀 0 (CEO 확정 규칙)
    and cx.get("labelInBar") is True
    # (g) 로드 직후 0장 / 계층 진입 시에만 그 계층 핀 표시 / 미지정 핀 구조 수단
    # ★ [CEO 확정 규칙 · 번복 금지] rootHidden == rootTotal 이어야 한다 = 전체에서 핀 0장
    and dc.get("rootTotal") == 3 and dc.get("rootHidden") == 3      # 전체 = 핀 0장
    and dc.get("gHidden") == 2                                      # G01 진입 → 1개만 표시
    and dc.get("gVisibleIds") == ["gp"]
    and dc.get("subVisibleIds") == ["sp"]                           # 상위 G01 핀은 번지지 않음
    and dc.get("chipText") == "계층: 미지정 1"
    and dc.get("chipShown") is True and dc.get("chipInBar") is True
    and dc.get("panelInDock") is True
    and dc.get("rowCount") == 1
    and dc.get("moveDisabledAtRoot") is True                        # 전체에서는 옮길 수 없음
    and dc.get("moveEnabledInG") is True
    and dc.get("oldCtxPath") == ["G01"]                             # 이 계층으로 옮기기 → ctxPath 부여
    and dc.get("oldVisibleInG") is True                             # 옮긴 뒤 다시 보인다
    and dc.get("fieldsIntact") is True                              # 기존 필드 삭제 없음
    and lb.get("inG", "").startswith("계층: G01 · 핀 ")
    and lb.get("atRoot") == "계층: 전체 · 핀 0"
    # (h) [CEO 확정] 게이트 폐지 — 버튼 없이 자동 표시 + 말풍선/끈/드래그 저장
    and gt.get("btnInBar") is True
    and gt.get("autoShown") is True                 # ① 게이트 폐지 = 항상 표시
    and gt.get("autoMode") is False                 #    핀 찍기 모드는 꺼진 채로
    # ★ [CEO 확정 규칙 — 사진은 블럭 안에서만. 전체에서는 절대 표시 금지. 번복 금지(2026-07-22)]
    #   이 두 줄을 0 이 아닌 값으로 바꾸는 변경 = 규칙 위반. CEO 승인 없이 수정 금지.
    and gt.get("rootDrawn") == 0                    #    전체(depth0) = 3D에 사진 0장
    and gt.get("autoCountAtRoot") == 0              #    전체에서는 어떤 핀도 필터를 통과하지 못한다
    and gt.get("autoDrawn") == 1                    #    블럭(G01)에 들어가면 버튼 없이 1장이 뜬다
    and gt.get("autoGated") == 0
    and gt.get("btnText") == "📍 핀 찍기"
    and gt.get("onMode") is True                    # ② 버튼 = 핀 찍기 모드 전용
    and gt.get("onBtnText") == "📍 핀 찍기 ON"
    and gt.get("onDrawn") == 1                      #    표시 수는 버튼과 무관
    and gt.get("offMode") is False
    and gt.get("offDrawn") == 1                     #    찍기 모드를 꺼도 사진은 계속 보인다
    and gt.get("gDrawn") == 1 and gt.get("gPassing") == 1   # ③ 계층 진입 시 자동 표시
    and gt.get("gVisibleIds") == ["gate_g"]
    and gt.get("balloonExists") is True             # ④ 말풍선 카드 + 살랑거림 + 끈
    and gt.get("balloonHasFloat") is True
    and gt.get("lineCount", 0) >= 1
    and gt.get("defaultDist", 0) >= 150             #    끈이 충분히 길다(3D 시야 안 가림)
    and gt.get("defaultUp") is True
    and gt.get("balloonSize", 0) >= 90
    and gt.get("savedUsed") is True                 # ⑤ 드래그로 저장한 자리를 그대로 쓴다
    and gt.get("fieldsIntact") is True              #    기존 필드 삭제 없음
    and not result["js_exceptions"]
)
print(json.dumps(result, ensure_ascii=False, indent=2))
print("결과:", "PASS" if ok else "FAIL")
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
