move_detect/tests/test_dist_parity.py
유창욱 93f896af87 fix(audit): 1.2.0 4차 재감사 — 확정 23건 전량 해소
4개 관점 병렬 감사 후 각 발견을 반증 시도해 확정. 반증 5건은 문서에 남겼다.

이번 감사의 요지: **구현도 단위테스트도 초록인데 납품 경로에서 동작하지 않는**
결함이 셋 있었다. 전부 같은 모양이다 — 테스트가 기본값이 아닌 설정을 썼거나,
값이 존재해도 되는지 결정하는 상류 허용 목록을 보지 않았다.

CRITICAL 4건:

R4-1 추락 승격이 기본 설정에서 한 번도 발화하지 않았다. link_s(3.0)를 now
기준으로 쟀는데 넘어짐은 prone_confirm_s(3.0) 지속 후 확정되므로 확정 시점에는
항상 3초 넘게 지나 있다. 다른 테스트가 전부 prone_confirm_s=0.5 를 써서
초록이었다. 착지(prone 시작) 시각과 비교하도록 고치고, 기본값 그대로 도는
회귀 테스트를 넣었다.

R4-2 max_workers:0 이 상류 두 곳에서 막혀 있었다 — 서버 규칙 경계가 [1,50]
이라 저장이 400 이고, 엣지 파서가 v<=0 으로 규칙 전체를 무효화해 같이 넣은
2인1조까지 사라졌다. 앞 커밋에서 고친 네 곳은 전부 이 둘보다 하류라 도달
불가였다.

R4-3 고소작업 프리셋(elevated)이 완전히 무동작이었다. 서버 경계에 키가 없어
400, 통과해도 엣지 파서가 침묵 삭제. 프리셋 안내문과 커밋 메시지가 시스템이
하지 않는 일을 약속하고 있었다.

R4-4 동시 다발이 한 사람을 두 명으로 셀 수 있었다. 트래커는 1초 가려짐에
트랙을 버리고 새 id 를 주고(max_age=1.0) 누운 사람은 새 트랙에서도 즉시
prone 이라 두 번째 fall 이 난다. 그러면 다친 한 사람에게 "공기호흡기 없이
진입 금지"가 나가 구조가 늦어진다 — 기능의 가치가 정반대로 뒤집힌다.
ghost 를 인원 집계에서 빼고, 거의 같은 자리의 사건은 같은 사람으로 본다.

HIGH 5건: 구역 그룹핑 사문화(엣지가 zone_id 를 안 실어 카메라가 다른 두
희생자가 절대 안 묶였다 — 밀폐공간이 정확히 그 상황), 창 미끄러짐으로 한
사고가 매 분 새 위급 건으로 쏟아지고 인원이 줄면 앞선 알림을 스스로 뒤집던
문제, 무장 미만료·forget 누락·이중 발보, 업그레이드 안내의 마이그레이션 2개
누락(신규 유형 INSERT 가 전부 DB CHECK 에 걸린다), BUILD-INFO 커밋 스탬프.

BUILD-INFO 는 1.1.0 에서 고친 R3-6 의 재발이다. 절차로 막으려다 두 번
실패했으므로 HEAD 일치를 검사하는 테스트로 막았다. README 마이그레이션
목록도 같은 이유로 테스트를 붙였다.

MEDIUM 14건 중 눈에 띄는 것: 정비 억제가 SCBA 경고를 삼켰고(허가가 발급된
그 구역·그 시간이 질식이 일어나는 자리다) 위급 3종을 억제 예외로 뺐다.
데모 시더 심각도가 제품과 7종 어긋나 fall 이 위급으로 찍혀 1.2.0 의 요점인
넘어짐/추락 구분이 데모에서 지워져 있었다 — 라벨과 달리 화면이 안 깨져
눈으로는 못 잡으므로 엣지를 단일 출처로 강제하는 테스트를 붙였다.
문서의 "17종"은 실제 20종(엣지 16 + 서버 4)으로 정정하고 신규 2종을
소개서 감지 표에 넣었다.

검증: pytest 335 passed / phpunit 269 passed. 신규 테스트 30건.
dist 는 이 커밋 이후 재생성한다(BUILD-INFO 가 HEAD 를 가리켜야 한다).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
2026-09-05 23:11:57 +09:00

123 lines
6 KiB
Python

"""납품 산출물(dist/)이 소스와 어긋났는지 검사.
dist/ 는 gitignore 대상 빌드 출력이라 git 이 드리프트를 알려주지 않는다. 실제로 기능 커밋 4개가
들어간 뒤 번들이 재생성되지 않아, Report/Export/Actions 컨트롤러와 마이그레이션 1건이 빠진
패키지가 납품 직전까지 남아 있었다. 스테일 번들은 '깨진' 상태가 아니라 '내부적으로 정합적인
구버전'이라 SHA256SUMS 도 통과한다 — 그래서 소스와 대조하는 검사가 따로 필요하다.
dist/ 가 없으면(클린 체크아웃) 스킵한다. 있으면 소스와 같아야 한다.
"""
import hashlib
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DIST = ROOT / "dist"
CAFE24_PRIV = DIST / "1_cafe24_upload" / "move_detect_app"
PI_SRC = DIST / "2_pi_install" / "src" / "edge"
needs_dist = pytest.mark.skipif(not DIST.is_dir(), reason="dist/ not built in this checkout")
def _digest(p: Path) -> str:
return hashlib.sha256(p.read_bytes()).hexdigest()
def _compare_tree(src: Path, packaged: Path, suffix: str) -> tuple[list[str], list[str]]:
"""(빠진 파일, 내용이 다른 파일) — 소스 기준."""
missing, differing = [], []
for f in sorted(src.rglob(f"*{suffix}")):
if "__pycache__" in f.parts:
continue
rel = f.relative_to(src)
target = packaged / rel
if not target.is_file():
missing.append(str(rel))
elif _digest(f) != _digest(target):
differing.append(str(rel))
return missing, differing
@needs_dist
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
def test_cafe24_package_matches_server_source():
missing, differing = _compare_tree(ROOT / "server" / "src", CAFE24_PRIV / "src", ".php")
assert not missing, f"번들에 없는 서버 소스(생성기 재실행 필요): {missing}"
assert not differing, f"번들과 소스 내용 불일치: {differing}"
@needs_dist
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
def test_cafe24_package_ships_every_migration_and_current_schema():
# 마이그레이션이 하나라도 빠지면 기존 DB 업그레이드가 전 요청 500 이 된다.
src = sorted(p.name for p in (ROOT / "server" / "migrations").glob("*.sql"))
packaged = sorted(p.name for p in (CAFE24_PRIV / "migrations").glob("*.sql"))
assert src == packaged, "동봉 마이그레이션이 소스와 다름 — 생성기 재실행 필요"
assert _digest(ROOT / "server" / "schema.sql") == _digest(CAFE24_PRIV / "schema.sql")
@needs_dist
@pytest.mark.skipif(not PI_SRC.is_dir(), reason="pi bundle not built")
def test_pi_bundle_matches_edge_source():
missing, differing = _compare_tree(ROOT / "edge", PI_SRC, ".py")
assert not missing, f"번들에 없는 엣지 소스(생성기 재실행 필요): {missing}"
assert not differing, f"번들과 소스 내용 불일치: {differing}"
@needs_dist
@pytest.mark.skipif(not (DIST / "2_pi_install" / "MANIFEST.txt").is_file(),
reason="pi bundle not built")
def test_pi_bundle_records_its_provenance():
# 버전·커밋이 없으면 현장에 올라간 빌드를 나중에 식별할 수 없다 — 스테일 번들이 조용히
# 통과한 구조적 원인.
text = (DIST / "2_pi_install" / "MANIFEST.txt").read_text(encoding="utf-8")
for field in ("version:", "git_commit:", "built_at:"):
assert field in text, f"MANIFEST.txt에 {field} 없음"
assert "version: unknown" not in text
@needs_dist
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
def test_build_info_stamps_the_commit_it_actually_ships():
"""BUILD-INFO 의 git_commit 이 현재 HEAD 와 같은가.
1.1.0 에서 이 결함(R3-6)을 고쳤는데 1.2.0 에서 그대로 재발했다 — 릴리스 커밋 **전에**
패키지를 만들면 스탬프가 코드를 담지 않은 커밋을 가리킨다. 기존 테스트는 필드의 존재만
확인했으므로 두 번 다 통과했다.
스탬프가 틀리면 나중에 "고객이 받은 것이 어느 코드인지"를 되짚을 방법이 사라진다.
절차로 막으려다 두 번 실패했으므로 테스트로 막는다.
"""
import subprocess
info = (CAFE24_PRIV / "BUILD-INFO.txt").read_text(encoding="utf-8")
stamped = next((l.split(":", 1)[1].strip() for l in info.splitlines()
if l.startswith("git_commit:")), None)
assert stamped, f"BUILD-INFO 에 git_commit 이 없습니다:\n{info}"
if stamped in ("unknown", ""):
pytest.skip("git 정보 없이 빌드된 패키지")
head = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT,
capture_output=True, text=True).stdout.strip()
assert stamped == head, (
f"패키지가 담은 코드와 스탬프가 다릅니다: BUILD-INFO={stamped}, HEAD={head}. "
"릴리스 커밋 이후에 생성기를 다시 돌리십시오.")
@needs_dist
@pytest.mark.skipif(not CAFE24_PRIV.is_dir(), reason="cafe24 package not built")
def test_upload_readme_lists_every_migration():
"""업그레이드 안내의 마이그레이션 표가 실제 파일을 전부 담는가.
빠지면 업그레이드한 고객이 그 마이그레이션을 영영 실행하지 않는다. 1.2.0 직전 상태에서
두 개가 빠져 있었고, 그중 하나가 없으면 신규 이벤트 유형의 INSERT 가 DB CHECK 에 걸려
전부 거부된다 — 감지기는 도는데 대시보드에 아무것도 안 뜨는, 이 프로젝트가 반복해서
잡아온 조용한 실패다.
"""
readme = (ROOT / "server" / "deploy" / "README-UPLOAD.md").read_text(encoding="utf-8")
for sql in sorted((ROOT / "server" / "migrations").glob("*.sql")):
assert sql.name in readme, (
f"업그레이드 안내에 {sql.name} 이 없습니다 — 업그레이드한 현장은 이 마이그레이션을 "
"영영 실행하지 않습니다")