This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
# CLAUDE.md — 매칭 시스템 작업 컨텍스트
|
||||
|
||||
이 파일은 Claude (AI) 가 `tests/` 디렉토리에서 작업할 때 참고하는 컨텍스트입니다.
|
||||
프로젝트 루트의 [../CLAUDE.md](../CLAUDE.md) 와 함께 사용.
|
||||
|
||||
## 작업 디렉토리
|
||||
|
||||
- 메인 작업 디렉토리: `tests/matching/`
|
||||
- 데이터 / 보고서 파일도 같은 위치
|
||||
- 실행 시 항상 `tests/matching/` 에서 (스크립트 내부 상대 경로 의존)
|
||||
|
||||
## 시스템 개요
|
||||
|
||||
MDX 콘텐츠 ↔ Figma Frame 32 개를 매칭하는 4 단계 파이프라인 (V1~V4).
|
||||
상세는 `README.md` / `PLAN.md` / `PROGRESS.md` 참조.
|
||||
|
||||
## 절대 규칙
|
||||
|
||||
### 1. 하드코딩 금지 (사용자 강조 사항)
|
||||
- 결과물을 직접 고치지 말고 **프로세스/코드를 고쳐라**
|
||||
- 임의 데이터 삽입 금지 (예: DECK 04 의 "제목·A 라벨·B 라벨·행 데이터" placeholder 사용 금지)
|
||||
- 모든 표시값은 실제 코드 결과 (yaml / 함수 출력) 에서 가져와야 함
|
||||
|
||||
### 2. 사용자 직접 수정 보존
|
||||
- 사용자가 HTML 파일을 직접 편집한 경우 **반드시 pipeline 코드에 반영** 후 재생성
|
||||
- 코드만 고치고 재실행하면 사용자 수정이 사라짐
|
||||
- 변경 시: 사용자 수정 8 개 모두 코드에 반영 → 재실행
|
||||
|
||||
### 3. 정직한 코드 동작 표시
|
||||
- 임원 보고용 deck 라도 **코드의 한계를 솔직히 표시**
|
||||
- 예: "MDX 자동 분석 결과 — 정책/요구사항 (사람이 보면 행렬형 비교)" 같은 표기
|
||||
- "이 축은 사실 frame 매칭에 영향 없음" 같은 ablation 결과는 임원용에는 빼지만, 내부 문서 (PROGRESS.md) 에는 명시
|
||||
|
||||
### 4. 임원 보고용 톤
|
||||
- 영문 enum 코드 (`policy_requirements`) 직접 노출 금지 — 한글 (정책/요구사항) 우선
|
||||
- 매칭 키워드는 5~10 개 + "등 N 개" 로 축약
|
||||
- 디자인: 그라데이션 / 화려한 카드 금지. 단순 표 + 흑백 + 강조 색 1~2 가지
|
||||
- 정의 / 부연 설명 최소화 (def 는 한 줄, 길게 풀어 쓰지 말 것)
|
||||
|
||||
## 명명 규칙
|
||||
|
||||
### 파이프라인 스크립트
|
||||
```
|
||||
pipeline_<숫자>_<이름>.py
|
||||
```
|
||||
- 01~07: 입력 추출 + 전처리 + 키워드
|
||||
- 08: V2 (semantic) / V3 (structure r2~r5) / V4 (template_fit, r1/r2)
|
||||
- 09: V2 진단
|
||||
- 10: Holdout 라벨링 / 평가
|
||||
- 11: templates_v1 감사
|
||||
- 12: templates_v2 생성 (r1, r2, r3, final, final_r2, promote_frame13)
|
||||
- 13: meeting docs / samples
|
||||
- 14: single sample
|
||||
- 15: bm25 / idf / logistic regression 비교
|
||||
- 16: deck 페이지 생성 (DECK 1~7)
|
||||
- 17: V4 full32 (32 frame 전체 평가)
|
||||
- 18: V4 slot 축 ablation
|
||||
|
||||
### 결과 파일
|
||||
```
|
||||
<단계>_<설명>_result.yaml
|
||||
```
|
||||
- `mdx_matching_result.yaml` — V1
|
||||
- `v2_semantic_rerank_result.yaml` — V2
|
||||
- `v3_structure_rerank_r5_result.yaml` — V3 (최종 r5)
|
||||
- `v4_full32_result.yaml` — V4 (32 frame 전체)
|
||||
- `structure_ontology_v2_final_r2.yaml` — Frame 32 DB
|
||||
|
||||
### 보고서
|
||||
```
|
||||
DECK_<번호>_<이름>.html — 임원 보고용 A4 페이지
|
||||
ATTACH_<번호>_<이름>.html — 부속 자료
|
||||
<NAME>_REPORT.html / .md — 분석 보고서
|
||||
```
|
||||
|
||||
## 자주 쓰는 명령어
|
||||
|
||||
```bash
|
||||
cd tests/matching/
|
||||
|
||||
# 매칭 시스템 전체 재실행
|
||||
python pipeline_06_2_mdx_matching.py
|
||||
python pipeline_08_v2_semantic_rerank.py
|
||||
python pipeline_08_v3_r5_structure_rerank.py
|
||||
python pipeline_17_v4_full32.py
|
||||
|
||||
# 보고서 재생성
|
||||
python pipeline_16_deck_4pages.py # DECK 1~7
|
||||
|
||||
# Ablation / 검증
|
||||
python pipeline_15_logistic_regression.py
|
||||
python pipeline_18_slot_axis_ablation.py
|
||||
```
|
||||
|
||||
## 파이프라인 핵심 가중치
|
||||
|
||||
### V1 키워드 매칭 (Logistic Regression 학습)
|
||||
```
|
||||
matching_score = 0.414 × 핵심 + 0.320 × 세트 + 0.265 × 연관
|
||||
```
|
||||
|
||||
### V3 구조 매칭
|
||||
```
|
||||
total = 0.40 × 레이아웃 일치 + 0.35 × 콘텐츠 성격 + 0.25 × 시각 의도
|
||||
```
|
||||
|
||||
### V4 종합 판정
|
||||
```
|
||||
confidence = 0.25 × anchor + 0.20 × cardinality + 0.20 × relation
|
||||
+ 0.15 × slot + 0.20 × content − penalty
|
||||
|
||||
라벨 임계값:
|
||||
≥ 0.90 → use_as_is (그대로 사용)
|
||||
≥ 0.75 → light_edit (가벼운 편집)
|
||||
≥ 0.60 → restructure (구조 재배치)
|
||||
< 0.60 → reject (사용 불가)
|
||||
```
|
||||
|
||||
## 데이터 소스
|
||||
|
||||
| 데이터 | 위치 | 용도 |
|
||||
|---|---|---|
|
||||
| Figma 텍스트 | `figma_to_html_agent/blocks/*/texts.md` | 32 frame 텍스트 추출 |
|
||||
| BEPS 마스터 | (별도 위치) | 키워드 보강용 |
|
||||
| MDX 검증 구간 | (`pipeline_01_extract_nodes.py` 의 `MDX_SECTIONS`) | 정답 매칭 검증 |
|
||||
| Frame 이미지 | `data/figma_previews/<프레임번호>.png` | DECK 시각화 |
|
||||
|
||||
## 테스트 픽스처 컨벤션 (F-5, INTEGRATION-AUDIT-01 §10.5.1)
|
||||
|
||||
테스트 데이터 / 샘플 참조의 정식 위치 규약. `tests/` 안에서만 적용되고 `src/**` 프로덕션 경로에는 적용되지 않음.
|
||||
|
||||
| 경로 | 상태 | 용도 | 비고 |
|
||||
|---|---|---|---|
|
||||
| `tests/phase_z2/fixtures/` | **존재 (정식)** | Phase Z 회귀 YAML 픽스처 | `test_fixtures_loader.py` 가 로드. 서브디렉토리 : `build_layout_css/`, `retry_gate/`. |
|
||||
| `tests/fixtures/` (루트) | **없음 (현재 미생성)** | 비-Phase-Z / 비-YAML 픽스처 미래 후보 | 샘플 인벤토리가 `tests/phase_z2/test_*.py` 인라인으로 감당 못 할 때만 별도 이슈로 신설. |
|
||||
| `samples/mdx_batch/**` , `samples/mdx/**` | 존재 | 통합 스모크 입력 | `tests/**` 에서만 참조 가능. `src/**` 런타임 경로 하드코딩 금지. |
|
||||
|
||||
규칙 :
|
||||
|
||||
- 테스트 코드에서는 `samples/mdx_batch/02.mdx` 같은 샘플 MDX 를 직접 참조해도 됨 (예 : `tests/phase_z2/test_pz2_vu_integration.py`). `src/**` 런타임 입력은 절대 샘플 파일명 / 콘텐츠를 핀하지 말 것.
|
||||
- 새 YAML 회귀 픽스처는 `tests/phase_z2/fixtures/` 아래 새 서브디렉토리로 추가. 루트 `tests/fixtures/` 신설은 금지 (별도 이슈 필요).
|
||||
- `src/**` 안에 등장하는 "BIM" / "건설산업 DX" / "재구성" 같은 sample-like 리터럴은 INTEGRATION-AUDIT-01 §10.4 (F-4) 에서 의도된 docstring / glossary / 예시 dict 로 분류 완료. annotation marker 가 붙어 있으면 의도된 example. 새 sample 리터럴을 `src/**` 에 도입하지 말 것.
|
||||
- 본 컨벤션의 anchor 정의는 `docs/architecture/INTEGRATION-AUDIT-01-REPORT.md` §10.5.1. 변경 시 anchor 부터 갱신.
|
||||
|
||||
## 자주 헷갈리는 것
|
||||
|
||||
### 영문 enum vs 한글 매핑
|
||||
- 코드 / yaml: 영문 enum (`comparative_matrix`, `cycle_interrelation`)
|
||||
- 보고서 표시: 한글 (`행렬형 비교`, `순환/상호 관계`)
|
||||
- DECK 05 의 키워드 사전 표는 양쪽 다 표시 (사용자 매칭 가능)
|
||||
|
||||
### 항목수 vs 슬롯 후보 개수
|
||||
- **동일** — `item_count = len(slot_candidates)` (표 / subsections / bullets 어떤 형태든)
|
||||
- V4 의 cardinality 축과 slot.within 부분은 **같은 신호의 중복 가중** (ablation 으로 확인)
|
||||
|
||||
### V3 vs V4 구조 점수
|
||||
- V3 = layout family + content_affinity + structure_intent (3 축)
|
||||
- V4 = anchor + cardinality + relation + slot + content (5 축)
|
||||
- **다른 모델**. V3 점수와 V4 confidence 는 별도 계산
|
||||
|
||||
## 사용자가 강조한 피드백
|
||||
|
||||
- "코드로 돌린 결과물이지 임의 데이터 아님" — 모든 표시 정직
|
||||
- "임원 보고용이야" — 부정적 부연 / 디테일 산식 빼기
|
||||
- "한가지만 해" — 한 번에 한 가지만 변경
|
||||
- "모든 변경은 pipeline 코드에 반영" — HTML 직접 수정은 일시적
|
||||
|
||||
## 진행 중 발견된 약점
|
||||
|
||||
`PROGRESS.md` 의 "발견된 약점" 표 참조. 8 개 모두 Phase E 작업 대상.
|
||||
|
||||
가장 시급:
|
||||
1. **02-2.2 매칭 실패** (E.5)
|
||||
2. **MDX 분석 LLM 화** (E.1, E.2)
|
||||
3. **슬롯 의미 매핑** (E.3, E.4)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
"""P4 (2026-05-19) — audit-only mode verification.
|
||||
|
||||
Covers:
|
||||
- _is_audit_issue: title pattern detection (positive + negative)
|
||||
- _audit_mode: title-based + CLI override (AUDIT_ONLY_OVERRIDE)
|
||||
- _check_audit_only_violations: forbidden prefix detection via mocked git status
|
||||
- AUDIT_ONLY_NOTE injection into context pack (via build_context_pack contract)
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_audit_mode.py
|
||||
"""
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import orchestrator # noqa: E402
|
||||
from orchestrator import ( # noqa: E402
|
||||
_is_audit_issue,
|
||||
_audit_mode,
|
||||
_check_audit_only_violations,
|
||||
_check_audit_commit_scope,
|
||||
_ensure_audit_baseline,
|
||||
_load_audit_baseline,
|
||||
_audit_baseline_path,
|
||||
AUDIT_ONLY_FORBIDDEN_PREFIXES,
|
||||
AUDIT_ONLY_NOTE,
|
||||
AUDIT_ALLOWED_COMMIT_GLOBS,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _is_audit_issue — title detection
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestIsAuditIssue:
|
||||
def test_integration_audit_bracket(self):
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT-01] cumulative review") is True
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT-02] something") is True
|
||||
assert _is_audit_issue("[INTEGRATION-AUDIT] no number") is True
|
||||
|
||||
def test_audit_only_bracket(self):
|
||||
assert _is_audit_issue("[AUDIT-ONLY] doc consistency check") is True
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _is_audit_issue("[integration-audit-03] foo") is True
|
||||
assert _is_audit_issue("[Audit-Only] bar") is True
|
||||
|
||||
def test_plain_integration_audit_phrase(self):
|
||||
assert _is_audit_issue("Quarterly integration audit for closed issues") is True
|
||||
assert _is_audit_issue("Integration Audit Q2") is True
|
||||
|
||||
def test_execution_issue_not_audit(self):
|
||||
"""execution sub-issue 가 audit 로 잘못 감지되면 안 됨."""
|
||||
assert _is_audit_issue("[IMP-15 실행-1] image_aspect_mismatch") is False
|
||||
assert _is_audit_issue("[IMP-15 exec-2] table overflow") is False
|
||||
|
||||
def test_unrelated_issues(self):
|
||||
assert _is_audit_issue("IMP-19 I4 zone 비중 분배") is False
|
||||
assert _is_audit_issue("Fix overflow bug") is False
|
||||
assert _is_audit_issue("docs(IMP-06): Stage 4 fix") is False
|
||||
|
||||
def test_empty_or_none(self):
|
||||
assert _is_audit_issue("") is False
|
||||
assert _is_audit_issue(None) is False
|
||||
|
||||
def test_audit_word_in_random_position_no_match(self):
|
||||
"""'audit' 가 단독으로 나오는 건 안 잡아야 함 — 'integration audit' 만."""
|
||||
assert _is_audit_issue("audit some code") is False
|
||||
assert _is_audit_issue("security audit") is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _audit_mode — combination with CLI override
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditMode:
|
||||
def setup_method(self):
|
||||
# 각 테스트 전에 override 리셋.
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
|
||||
def teardown_method(self):
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
|
||||
def test_title_based_only(self):
|
||||
assert _audit_mode("[INTEGRATION-AUDIT-01] foo") is True
|
||||
assert _audit_mode("IMP-19 zone") is False
|
||||
|
||||
def test_cli_override_forces_audit(self):
|
||||
"""title 에 marker 없어도 CLI flag 가 audit mode 강제."""
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = True
|
||||
assert _audit_mode("IMP-19 zone") is True
|
||||
assert _audit_mode("any title") is True
|
||||
assert _audit_mode("") is True
|
||||
|
||||
def test_override_off_falls_back_to_title(self):
|
||||
orchestrator.AUDIT_ONLY_OVERRIDE = False
|
||||
assert _audit_mode("IMP-19 zone") is False
|
||||
assert _audit_mode("[INTEGRATION-AUDIT-01]") is True
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# _check_audit_only_violations — git status parsing
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class _FakeCompleted:
|
||||
def __init__(self, stdout, returncode=0):
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
self.returncode = returncode
|
||||
|
||||
|
||||
class TestCheckAuditOnlyViolations:
|
||||
"""subprocess.run 을 monkeypatch 해서 다양한 git status 출력 시나리오 검증."""
|
||||
|
||||
def test_clean_tree(self, monkeypatch):
|
||||
def fake_run(*args, **kwargs):
|
||||
return _FakeCompleted(stdout="")
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_only_allowed_changes(self, monkeypatch):
|
||||
"""docs/architecture 변경만 있으면 violation 0."""
|
||||
stdout = (
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"?? docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md\n"
|
||||
" M docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_src_change_detected(self, monkeypatch):
|
||||
stdout = (
|
||||
" M src/phase_z2_pipeline.py\n"
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_templates_change_detected(self, monkeypatch):
|
||||
stdout = " M templates/phase_z2/families/something.html\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["templates/phase_z2/families/something.html"]
|
||||
|
||||
def test_tests_change_detected(self, monkeypatch):
|
||||
stdout = " M tests/phase_z2/test_overflow.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["tests/phase_z2/test_overflow.py"]
|
||||
|
||||
def test_multiple_violations(self, monkeypatch):
|
||||
stdout = (
|
||||
" M src/a.py\n"
|
||||
"?? src/b.py\n"
|
||||
" M templates/c.html\n"
|
||||
" M tests/d.py\n"
|
||||
" M docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n" # allowed
|
||||
" M data/runs/run123.json\n" # allowed (not in forbidden)
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert set(v) == {"src/a.py", "src/b.py", "templates/c.html", "tests/d.py"}
|
||||
|
||||
def test_renamed_file_destination_checked(self, monkeypatch):
|
||||
"""rename 의 경우 destination 만 검사."""
|
||||
stdout = "R docs/old.md -> src/new.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/new.py"]
|
||||
|
||||
def test_windows_backslash_path(self, monkeypatch):
|
||||
"""Windows backslash path 도 forward-slash 로 정규화돼서 매치."""
|
||||
stdout = " M src\\phase_z2_pipeline.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_quoted_path_with_spaces(self, monkeypatch):
|
||||
"""공백/특수문자 포함 path 는 quoted — quote strip 후 검사."""
|
||||
stdout = ' M "src/some file.py"\n'
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations()
|
||||
assert v == ["src/some file.py"]
|
||||
|
||||
def test_git_error_fails_open(self, monkeypatch):
|
||||
"""git 자체 실패 → 가드 false positive 안 만들고 빈 list 반환."""
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda *a, **kw: _FakeCompleted(stdout="", returncode=128))
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
def test_subprocess_exception_fails_open(self, monkeypatch):
|
||||
"""subprocess.run 자체가 raise 해도 가드 false positive X."""
|
||||
def boom(*a, **kw): raise RuntimeError("git missing")
|
||||
monkeypatch.setattr(subprocess, "run", boom)
|
||||
assert _check_audit_only_violations() == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# AUDIT_ONLY_NOTE constants — sanity
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: baseline-aware violations
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBaselineAwareViolations:
|
||||
def test_baseline_subtraction_removes_preexisting(self, monkeypatch):
|
||||
"""pre-existing forbidden path 는 baseline 에 있으면 violation 에서 제외."""
|
||||
stdout = (
|
||||
" M src/already_dirty.py\n" # baseline 안에 있음 — 제외돼야 함
|
||||
" M src/new_violation.py\n" # baseline 밖 — violation
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/already_dirty.py"}
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == ["src/new_violation.py"]
|
||||
|
||||
def test_baseline_none_keeps_all(self, monkeypatch):
|
||||
"""baseline=None 이면 기존 동작 — 모든 forbidden 잡음."""
|
||||
stdout = " M src/a.py\n M src/b.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations(baseline=None)
|
||||
assert set(v) == {"src/a.py", "src/b.py"}
|
||||
|
||||
def test_baseline_empty_set_keeps_all(self, monkeypatch):
|
||||
"""baseline=set() 이면 모두 새 violation 으로 잡음."""
|
||||
stdout = " M src/a.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_only_violations(baseline=set())
|
||||
assert v == ["src/a.py"]
|
||||
|
||||
def test_baseline_filters_all_violations(self, monkeypatch):
|
||||
"""모든 violation 이 baseline 에 있으면 빈 list 반환 — clean 으로 판정."""
|
||||
stdout = " M src/a.py\n M templates/b.html\n M tests/c.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/a.py", "templates/b.html", "tests/c.py"}
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == []
|
||||
|
||||
def test_baseline_path_normalized_match(self, monkeypatch):
|
||||
"""baseline 의 path 는 forward-slash 정규화 형태. Windows backslash 도 매치."""
|
||||
stdout = " M src\\windows_path.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
baseline = {"src/windows_path.py"} # baseline 도 forward-slash 형태로 저장
|
||||
v = _check_audit_only_violations(baseline=baseline)
|
||||
assert v == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: _ensure_audit_baseline / _load_audit_baseline
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditBaselinePersist:
|
||||
def test_save_and_load_roundtrip(self, monkeypatch, tmp_path):
|
||||
"""baseline 저장 → 로드 → 동일 path set 반환."""
|
||||
# Redirect ORCH_DIR to tmp_path for isolation.
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# Mock git status output.
|
||||
stdout = " M src/a.py\n?? src/b.py\n M docs/c.md\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
_ensure_audit_baseline(999)
|
||||
loaded = _load_audit_baseline(999)
|
||||
assert loaded == {"src/a.py", "src/b.py", "docs/c.md"}
|
||||
|
||||
def test_ensure_does_not_overwrite_existing(self, monkeypatch, tmp_path):
|
||||
"""이미 baseline 파일 있으면 덮어쓰지 않음 — resumed run 의 가드 일관성."""
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# First save with one set.
|
||||
stdout1 = " M src/original.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout1))
|
||||
_ensure_audit_baseline(999)
|
||||
# Second call with DIFFERENT git status — should NOT overwrite.
|
||||
stdout2 = " M src/different.py\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout2))
|
||||
_ensure_audit_baseline(999)
|
||||
loaded = _load_audit_baseline(999)
|
||||
# Original baseline preserved.
|
||||
assert loaded == {"src/original.py"}
|
||||
|
||||
def test_load_missing_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
assert _load_audit_baseline(8888) == set()
|
||||
|
||||
def test_load_corrupt_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
# Manually write corrupt JSON.
|
||||
p = tmp_path / "audit_baseline_7777.json"
|
||||
p.write_text("not valid json {{{", encoding="utf-8")
|
||||
assert _load_audit_baseline(7777) == set()
|
||||
|
||||
def test_load_non_list_returns_empty_set(self, monkeypatch, tmp_path):
|
||||
"""baseline 파일이 list 가 아닌 다른 JSON (예: dict) 이면 empty set."""
|
||||
monkeypatch.setattr(orchestrator, "ORCH_DIR", tmp_path)
|
||||
p = tmp_path / "audit_baseline_6666.json"
|
||||
p.write_text('{"unexpected": "shape"}', encoding="utf-8")
|
||||
assert _load_audit_baseline(6666) == set()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: _check_audit_commit_scope — Stage 5 guard
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditCommitScope:
|
||||
def test_clean_commit_audit_report_only(self, monkeypatch):
|
||||
"""audit report 파일만 commit 되면 통과."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-MATRIX.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_backlog_update_allowed(self, monkeypatch):
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/architecture/PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_src_file_in_commit_detected(self, monkeypatch):
|
||||
"""audit commit 에 src/ 파일이 끼면 violation."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"src/phase_z2_pipeline.py\n"
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_commit_scope()
|
||||
assert v == ["src/phase_z2_pipeline.py"]
|
||||
|
||||
def test_unrelated_doc_detected(self, monkeypatch):
|
||||
"""docs/ 라도 audit 관련 아닌 doc 은 violation."""
|
||||
stdout = (
|
||||
"docs/architecture/INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
"docs/some_other_doc.md\n" # 다른 doc
|
||||
"docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md\n" # audit 와 무관한 doc
|
||||
)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
v = _check_audit_commit_scope()
|
||||
assert set(v) == {"docs/some_other_doc.md",
|
||||
"docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md"}
|
||||
|
||||
def test_git_error_fails_open(self, monkeypatch):
|
||||
"""git show 자체 실패 → 빈 list (가드가 false positive 만들지 않음)."""
|
||||
monkeypatch.setattr(subprocess, "run",
|
||||
lambda *a, **kw: _FakeCompleted(stdout="", returncode=128))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_windows_backslash_normalized(self, monkeypatch):
|
||||
"""Windows backslash path 도 forward-slash 정규화 후 glob 매치."""
|
||||
stdout = "docs\\architecture\\INTEGRATION-AUDIT-01-REPORT.md\n"
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=stdout))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
def test_empty_commit_passes(self, monkeypatch):
|
||||
"""commit 에 파일 변경 없음 (보통 안 일어나지만) — 위반 없음."""
|
||||
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _FakeCompleted(stdout=""))
|
||||
assert _check_audit_commit_scope() == []
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# P4a: allowed-glob shape sanity
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestAuditCommitAllowedGlobs:
|
||||
def test_globs_have_audit_marker(self):
|
||||
"""모든 allowed glob 에 INTEGRATION-AUDIT 또는 BACKLOG 마커 존재."""
|
||||
for g in AUDIT_ALLOWED_COMMIT_GLOBS:
|
||||
assert ("INTEGRATION-AUDIT" in g) or ("BACKLOG" in g)
|
||||
|
||||
def test_globs_under_docs_architecture(self):
|
||||
"""모든 allowed path 가 docs/architecture/ 산하 — src/ 등 우발적 허용 차단."""
|
||||
for g in AUDIT_ALLOWED_COMMIT_GLOBS:
|
||||
assert g.startswith("docs/architecture/"), f"glob escapes docs/architecture/: {g}"
|
||||
|
||||
|
||||
class TestAuditOnlyConstants:
|
||||
def test_note_mentions_forbidden_prefixes(self):
|
||||
for p in AUDIT_ONLY_FORBIDDEN_PREFIXES:
|
||||
assert p in AUDIT_ONLY_NOTE, f"AUDIT_ONLY_NOTE missing prefix mention: {p}"
|
||||
|
||||
def test_note_mentions_allowed_paths(self):
|
||||
assert "INTEGRATION-AUDIT-*.md" in AUDIT_ONLY_NOTE
|
||||
assert "PHASE-Z-IMPLEMENTATION-ISSUE-BACKLOG.md" in AUDIT_ONLY_NOTE
|
||||
|
||||
def test_note_states_no_code_edit(self):
|
||||
# "report" 또는 "NOT code" 표현 명시 확인 (LLM 가독성 가드).
|
||||
lower = AUDIT_ONLY_NOTE.lower()
|
||||
assert "audit report" in lower or "report writing" in lower
|
||||
assert "not code" in lower or "no production" in lower
|
||||
|
||||
def test_forbidden_prefixes_no_trailing_slash_issues(self):
|
||||
"""블랙리스트는 startswith 매치 — 'src' (slash 없음) 면 'srcfoo.py' 도 매칭돼서 false positive.
|
||||
모든 prefix 가 '/' 로 끝나야 함."""
|
||||
for p in AUDIT_ONLY_FORBIDDEN_PREFIXES:
|
||||
assert p.endswith("/"), f"prefix '{p}' must end with '/' to avoid false matches"
|
||||
@@ -0,0 +1,492 @@
|
||||
"""P5 (2026-05-20) — Dormant trigger guard tests (issue #58, unit u4).
|
||||
|
||||
Covers the L3 dormant trigger layer end-to-end:
|
||||
|
||||
- u1 — docs/architecture/DORMANT-TRIGGERS.yaml schema + content for the
|
||||
IMP-16 / IMP-17 / IMP-18 / IMP-19 / IMP-20 axes.
|
||||
- u2 — scripts/check_dormant_triggers.py file-pattern + content-pattern
|
||||
matching, manual-evidence skip, followup-linked skip,
|
||||
false-positive guards, exit-0 standalone invocation.
|
||||
- u3 — orchestrator._check_dormant_triggers() helper fail-open contract
|
||||
and the Stage 4→5 _audit_mode() bypass predicate.
|
||||
- u5 — DORMANT-TRIGGERS.yaml self-documenting header
|
||||
(governance doc cross-reference test runs after u5 lands).
|
||||
|
||||
Each test names the IMP-# trigger it exercises (scope-qualified verification
|
||||
per the work-principles lock).
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_dormant_triggers.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
REGISTRY_PATH = ROOT / "docs" / "architecture" / "DORMANT-TRIGGERS.yaml"
|
||||
GOVERNANCE_PATH = ROOT / "docs" / "architecture" / "PROJECT-INTENT-AND-GOVERNANCE.md"
|
||||
CHECKER_PATH = ROOT / "scripts" / "check_dormant_triggers.py"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Shared fixtures
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def registry_entries() -> list[dict]:
|
||||
"""Parsed registry — used across schema + content tests."""
|
||||
with REGISTRY_PATH.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
assert isinstance(data, list), "registry root must be a YAML list"
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chkmod():
|
||||
"""Fresh import of the standalone checker module (function-scoped so
|
||||
monkeypatched module attributes do not leak across tests)."""
|
||||
if "check_dormant_triggers" in sys.modules:
|
||||
return importlib.reload(sys.modules["check_dormant_triggers"])
|
||||
import check_dormant_triggers as m # noqa: WPS433 — runtime import by design
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orch():
|
||||
"""Orchestrator module under test (u3 helper)."""
|
||||
import orchestrator as m # noqa: WPS433
|
||||
return m
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u1 — registry yaml schema + content
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRegistrySchema:
|
||||
"""u1 — DORMANT-TRIGGERS.yaml exists, parses, and shapes the 5 dormant axes."""
|
||||
|
||||
def test_registry_yaml_parses_with_pyyaml(self):
|
||||
"""Schema sanity (covers all of IMP-16/17/18/19/20): file parses as YAML list."""
|
||||
assert REGISTRY_PATH.exists(), f"registry missing: {REGISTRY_PATH}"
|
||||
with REGISTRY_PATH.open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
assert isinstance(data, list), "registry root must be a YAML list"
|
||||
|
||||
def test_registry_has_5_entries_4_active_plus_1_followup_linked(self, registry_entries):
|
||||
"""Stage 1/2 scope-lock: IMP-16 / IMP-17 / IMP-18 / IMP-19 + IMP-20 followup-linked."""
|
||||
assert len(registry_entries) == 5
|
||||
issues = sorted(e["issue"] for e in registry_entries)
|
||||
assert issues == [16, 17, 18, 19, 20]
|
||||
|
||||
def test_registry_required_fields_present(self, registry_entries):
|
||||
"""Every entry has issue / title / doc / status / trigger / on_trigger (covers IMP-16~20)."""
|
||||
for e in registry_entries:
|
||||
assert isinstance(e.get("issue"), int)
|
||||
assert isinstance(e.get("title"), str) and e["title"]
|
||||
assert isinstance(e.get("doc"), str) and e["doc"]
|
||||
assert "status" in e
|
||||
assert isinstance(e.get("trigger"), dict)
|
||||
assert isinstance(e.get("on_trigger"), dict)
|
||||
trig = e["trigger"]
|
||||
assert "description" in trig
|
||||
assert isinstance(trig.get("manual_evidence_required"), bool)
|
||||
|
||||
|
||||
class TestImp16Entry:
|
||||
"""IMP-16 (issue 16) — active watch on src/** reverse-path adapter."""
|
||||
|
||||
def test_imp16_active_src_glob_and_reverse_path_content_pattern(self, registry_entries):
|
||||
"""IMP-16 trigger: src/**/*.py glob + reverse_path / html_to_slide_mdx content."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 16)
|
||||
assert e["status"] == "documented:dormant"
|
||||
assert not e.get("followup_issue"), "IMP-16 is active, not followup-linked"
|
||||
t = e["trigger"]
|
||||
assert t["manual_evidence_required"] is False
|
||||
assert any("src/**" in p for p in t["file_patterns"])
|
||||
cps = t["content_patterns"]
|
||||
assert any("reverse_path" in p or "html_to_slide_mdx" in p for p in cps)
|
||||
|
||||
|
||||
class TestImp17Entry:
|
||||
"""IMP-17 (issue 17) — manual-evidence gate (3-cond User-GO AND)."""
|
||||
|
||||
def test_imp17_manual_evidence_required_true(self, registry_entries):
|
||||
"""IMP-17 trigger gate: manual_evidence_required=true (User GO + B4 + IMP-04/05 live)."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 17)
|
||||
assert e["trigger"]["manual_evidence_required"] is True
|
||||
|
||||
|
||||
class TestImp18Entry:
|
||||
"""IMP-18 (issue 18) — SVG partial under templates/phase_z2/."""
|
||||
|
||||
def test_imp18_active_watch_on_phase_z2_templates_with_svg_content(self, registry_entries):
|
||||
"""IMP-18 trigger: templates/phase_z2/{families,frames}/*.html + SVG signature."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 18)
|
||||
assert e["trigger"]["manual_evidence_required"] is False
|
||||
fps = e["trigger"]["file_patterns"]
|
||||
assert any("templates/phase_z2/" in p for p in fps)
|
||||
cps = e["trigger"]["content_patterns"]
|
||||
assert any("svg" in p.lower() or "viewBox" in p for p in cps)
|
||||
|
||||
|
||||
class TestImp19Entry:
|
||||
"""IMP-19 (issue 19) — manual-evidence gate (IMP-09 owner sign-off)."""
|
||||
|
||||
def test_imp19_manual_evidence_required_true(self, registry_entries):
|
||||
"""IMP-19 trigger gate: manual_evidence_required=true (failing-case + IMP-09 sign-off)."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 19)
|
||||
assert e["trigger"]["manual_evidence_required"] is True
|
||||
|
||||
|
||||
class TestImp20Entry:
|
||||
"""IMP-20 (issue 20) — followup-linked to open issue #55, note-only."""
|
||||
|
||||
def test_imp20_followup_linked_to_55_with_note_only_action(self, registry_entries):
|
||||
"""IMP-20 status: followup_issue=55, on_trigger.action=note_only (no checker watch)."""
|
||||
e = next(x for x in registry_entries if x["issue"] == 20)
|
||||
assert e.get("followup_issue") == 55
|
||||
assert e["on_trigger"]["action"] == "note_only"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u2 — check_dormant_triggers.py
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCheckerMatching:
|
||||
|
||||
def test_checker_clean_tree_no_alerts_covers_imp16_18(self, chkmod, monkeypatch):
|
||||
"""False-positive guard: empty change surface → no IMP-16 / IMP-18 alerts."""
|
||||
monkeypatch.setattr(chkmod, "collect_changed_files", lambda: [])
|
||||
entries = chkmod.load_registry()
|
||||
alerts = [a for a in (chkmod.check_entry(e, []) for e in entries) if a]
|
||||
assert alerts == []
|
||||
|
||||
def test_checker_imp16_alert_on_src_reverse_path_adapter(
|
||||
self, chkmod, monkeypatch, tmp_path
|
||||
):
|
||||
"""IMP-16 positive: src/foo/adapter.py with `reverse_path` content → alert."""
|
||||
fake_path = "src/foo/adapter.py"
|
||||
(tmp_path / "src" / "foo").mkdir(parents=True)
|
||||
(tmp_path / fake_path).write_text("def reverse_path(): pass\n", encoding="utf-8")
|
||||
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
|
||||
entries = chkmod.load_registry()
|
||||
imp16 = next(e for e in entries if e["issue"] == 16)
|
||||
result = chkmod.check_entry(imp16, [fake_path])
|
||||
assert result is not None
|
||||
assert result["issue"] == 16
|
||||
assert fake_path in result["match"]["files"]
|
||||
|
||||
def test_checker_imp16_no_alert_on_tests_path_false_positive_guard(
|
||||
self, chkmod, monkeypatch, tmp_path
|
||||
):
|
||||
"""False-positive guard: IMP-16 must NOT fire on tests/foo.py even with matching content."""
|
||||
fake_path = "tests/foo.py"
|
||||
(tmp_path / "tests").mkdir()
|
||||
(tmp_path / fake_path).write_text("def reverse_path(): pass\n", encoding="utf-8")
|
||||
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
|
||||
entries = chkmod.load_registry()
|
||||
imp16 = next(e for e in entries if e["issue"] == 16)
|
||||
assert chkmod.check_entry(imp16, [fake_path]) is None
|
||||
|
||||
def test_checker_imp18_alert_on_phase_z2_family_svg(
|
||||
self, chkmod, monkeypatch, tmp_path
|
||||
):
|
||||
"""IMP-18 positive: templates/phase_z2/families/new.html with <svg viewBox> → alert."""
|
||||
fake_path = "templates/phase_z2/families/new_partial.html"
|
||||
(tmp_path / "templates" / "phase_z2" / "families").mkdir(parents=True)
|
||||
(tmp_path / fake_path).write_text(
|
||||
'<svg viewBox="0 0 100 100"></svg>\n', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
|
||||
entries = chkmod.load_registry()
|
||||
imp18 = next(e for e in entries if e["issue"] == 18)
|
||||
result = chkmod.check_entry(imp18, [fake_path])
|
||||
assert result is not None
|
||||
assert result["issue"] == 18
|
||||
|
||||
def test_checker_imp18_flat_glob_boundary_nested_path_skipped(
|
||||
self, chkmod, monkeypatch, tmp_path
|
||||
):
|
||||
"""False-positive guard: IMP-18 flat glob `families/*.html` skips nested family path."""
|
||||
fake_path = "templates/phase_z2/families/nested/inner.html"
|
||||
(tmp_path / "templates" / "phase_z2" / "families" / "nested").mkdir(parents=True)
|
||||
(tmp_path / fake_path).write_text(
|
||||
'<svg viewBox="0 0 100 100"></svg>\n', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(chkmod, "REPO_ROOT", tmp_path)
|
||||
entries = chkmod.load_registry()
|
||||
imp18 = next(e for e in entries if e["issue"] == 18)
|
||||
assert chkmod.check_entry(imp18, [fake_path]) is None
|
||||
|
||||
def test_checker_skips_imp17_manual_evidence(self, chkmod):
|
||||
"""Guardrail: IMP-17 manual_evidence_required skips even with broad change surface."""
|
||||
entries = chkmod.load_registry()
|
||||
imp17 = next(e for e in entries if e["issue"] == 17)
|
||||
assert chkmod.check_entry(imp17, ["src/foo.py", "anything.html"]) is None
|
||||
|
||||
def test_checker_skips_imp19_manual_evidence(self, chkmod):
|
||||
"""Guardrail: IMP-19 manual_evidence_required skips even with broad change surface."""
|
||||
entries = chkmod.load_registry()
|
||||
imp19 = next(e for e in entries if e["issue"] == 19)
|
||||
assert chkmod.check_entry(imp19, ["src/foo.py"]) is None
|
||||
|
||||
def test_checker_skips_imp20_followup_linked(self, chkmod):
|
||||
"""Guardrail: IMP-20 followup_issue=55 skips (open issue #55 owns the watch)."""
|
||||
entries = chkmod.load_registry()
|
||||
imp20 = next(e for e in entries if e["issue"] == 20)
|
||||
assert chkmod.check_entry(imp20, ["anything", "src/a.py"]) is None
|
||||
|
||||
def test_checker_standalone_invocation_exit_0(self):
|
||||
"""Guardrail: standalone `python scripts/check_dormant_triggers.py` exits 0 always.
|
||||
|
||||
Exercises the IMP-16~20 informational-only contract — checker never blocks
|
||||
the orchestrator regardless of working-tree state.
|
||||
"""
|
||||
r = subprocess.run(
|
||||
[sys.executable, str(CHECKER_PATH)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=str(ROOT),
|
||||
timeout=30,
|
||||
)
|
||||
assert r.returncode == 0, f"checker exit={r.returncode} stderr={r.stderr!r}"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u3 — orchestrator helper + Stage 4→5 hook
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestOrchestratorDormantHook:
|
||||
|
||||
def test_orchestrator_helper_returns_list_on_clean_tree(self, orch):
|
||||
"""u3 helper returns list[dict] (possibly empty) — never raises (IMP-16~20 informational)."""
|
||||
result = orch._check_dormant_triggers()
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_orchestrator_helper_fail_open_on_subprocess_error(self, orch, monkeypatch):
|
||||
"""u3 helper: subprocess raise → [] (fail-open, no false positives across all IMPs)."""
|
||||
def boom(*a, **kw):
|
||||
raise RuntimeError("subprocess unavailable")
|
||||
monkeypatch.setattr(orch.subprocess, "run", boom)
|
||||
assert orch._check_dormant_triggers() == []
|
||||
|
||||
def test_orchestrator_helper_fail_open_on_nonzero_exit(self, orch, monkeypatch):
|
||||
"""u3 helper: subprocess returncode != 0 → [] (fail-open)."""
|
||||
class _Err:
|
||||
returncode = 1
|
||||
stdout = ""
|
||||
stderr = "boom"
|
||||
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _Err())
|
||||
assert orch._check_dormant_triggers() == []
|
||||
|
||||
def test_orchestrator_helper_fail_open_on_missing_alerts_file(
|
||||
self, orch, monkeypatch, tmp_path
|
||||
):
|
||||
"""u3 helper: subprocess OK but alert file absent → [] (fail-open)."""
|
||||
class _OK:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
|
||||
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
|
||||
assert orch._check_dormant_triggers() == []
|
||||
|
||||
def test_orchestrator_helper_parses_alerts_payload(self, orch, monkeypatch, tmp_path):
|
||||
"""u3 helper: reads `alerts` list out of .orchestrator/dormant_alerts.json payload."""
|
||||
class _OK:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
|
||||
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
|
||||
payload = {
|
||||
"alerts": [
|
||||
{"issue": 16, "title": "IMP-16 sample", "on_trigger": {"action": "create_runtime_issue"}},
|
||||
],
|
||||
}
|
||||
(tmp_path / "dormant_alerts.json").write_text(
|
||||
json.dumps(payload), encoding="utf-8"
|
||||
)
|
||||
result = orch._check_dormant_triggers()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0]["issue"] == 16
|
||||
|
||||
def test_orchestrator_helper_handles_non_list_alerts_payload(
|
||||
self, orch, monkeypatch, tmp_path
|
||||
):
|
||||
"""u3 helper: malformed `alerts` (non-list) → [] (fail-open, IMP-16~20 informational)."""
|
||||
class _OK:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
monkeypatch.setattr(orch.subprocess, "run", lambda *a, **kw: _OK())
|
||||
monkeypatch.setattr(orch, "ORCH_DIR", tmp_path)
|
||||
(tmp_path / "dormant_alerts.json").write_text(
|
||||
json.dumps({"alerts": "oops"}), encoding="utf-8"
|
||||
)
|
||||
assert orch._check_dormant_triggers() == []
|
||||
|
||||
def test_stage_4_to_5_hook_predicate_audit_bypass(self, orch):
|
||||
"""u3 Stage 4→5 hook guard: _audit_mode(title) True → dormant checker bypassed.
|
||||
|
||||
Mirrors P4a placement — the dormant hook condition is
|
||||
`sid == "test-verify" and not _audit_mode(title)`. This test asserts
|
||||
the gating predicate for the IMP-16/18 active watches.
|
||||
"""
|
||||
assert orch._audit_mode("[INTEGRATION-AUDIT-02] cumulative review") is True
|
||||
assert orch._audit_mode("[AUDIT-ONLY] doc consistency") is True
|
||||
assert orch._audit_mode("[P5][DORMANT-TRIGGER-GUARD] hook") is False
|
||||
assert orch._audit_mode("IMP-16 U2 wiring") is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u3 — Stage 4→5 hook integration (focused static assertions on run_stage)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRunStageDormantHookIntegration:
|
||||
"""u3 — Stage 4→5 hook must be wired into ``orchestrator.run_stage``.
|
||||
|
||||
Codex #5 rewind: testing ``_audit_mode()`` in isolation is insufficient.
|
||||
These focused static-source assertions on ``inspect.getsource(run_stage)``
|
||||
fail if the dormant hook is silently removed or its audit-bypass predicate
|
||||
is weakened — guarding the IMP-16/17/18/19/20 informational alert wiring.
|
||||
"""
|
||||
|
||||
def _run_stage_src(self, orch) -> str:
|
||||
import inspect
|
||||
return inspect.getsource(orch.run_stage)
|
||||
|
||||
def test_run_stage_invokes_check_dormant_triggers_on_test_verify_non_audit(self, orch):
|
||||
"""u3 contract: run_stage body calls _check_dormant_triggers().
|
||||
|
||||
Exercises the Stage 4 (test-verify) non-audit invocation path for
|
||||
IMP-16/18 active watches. If the call disappears from run_stage,
|
||||
this test fails (catches the regression that Codex #5 flagged).
|
||||
"""
|
||||
src = self._run_stage_src(orch)
|
||||
assert "_check_dormant_triggers()" in src, (
|
||||
"run_stage must invoke _check_dormant_triggers() — the L3 wiring "
|
||||
"for IMP-16/17/18/19/20 dormant alerts. Silent removal breaks the "
|
||||
"Stage 4→5 informational hook."
|
||||
)
|
||||
|
||||
def test_run_stage_dormant_hook_gated_by_test_verify_and_non_audit(self, orch):
|
||||
"""u3 contract: dormant hook is gated by both sid==test-verify AND not _audit_mode(title).
|
||||
|
||||
The predicate is what makes audit-only Stage 4 bypass the IMP-16~20
|
||||
checker (Stage 1 scope-lock guardrail). Removing either conjunct
|
||||
would silently re-enable the checker on audit-only issues whose
|
||||
change surface is restricted to audit-report docs.
|
||||
"""
|
||||
import re
|
||||
src = self._run_stage_src(orch)
|
||||
m = re.search(
|
||||
r'sid\s*==\s*"test-verify"\s+and\s+not\s+_audit_mode\(title\)\s*:\s*\n'
|
||||
r'\s+alerts\s*=\s*_check_dormant_triggers\(\)',
|
||||
src,
|
||||
)
|
||||
assert m is not None, (
|
||||
"Stage 4→5 dormant hook must be gated by "
|
||||
'`sid == "test-verify" and not _audit_mode(title):` immediately '
|
||||
"before `alerts = _check_dormant_triggers()`. Audit-only bypass "
|
||||
"and Stage 4 placement are part of the IMP-16~20 contract."
|
||||
)
|
||||
|
||||
def test_run_stage_dormant_hook_is_informational_no_continue(self, orch):
|
||||
"""u3 contract: dormant hook block must NOT contain a `continue` statement.
|
||||
|
||||
IMP-16~20 alerts are informational only (Stage 1 guardrail). The hook
|
||||
block between the _check_dormant_triggers() call and the next
|
||||
Stage 4 PASS log line ("YES (evidence verified)") must not short-
|
||||
circuit Stage 5 entry via `continue`. Comments mentioning the word
|
||||
"continue" are allowed (they document the contract).
|
||||
"""
|
||||
import re
|
||||
src = self._run_stage_src(orch)
|
||||
start = src.find("_check_dormant_triggers()")
|
||||
assert start >= 0
|
||||
end = src.find("YES (evidence verified)", start)
|
||||
assert end > start, (
|
||||
"could not locate Stage 4 PASS log line after dormant hook — "
|
||||
"run_stage shape may have shifted; re-examine integration."
|
||||
)
|
||||
hook_block = src[start:end]
|
||||
# Strip line comments before checking for the `continue` keyword as
|
||||
# an actual statement — the source intentionally documents the
|
||||
# informational-only contract with a `# Never continue — ...` comment.
|
||||
stripped_lines = []
|
||||
for line in hook_block.splitlines():
|
||||
code = line.split("#", 1)[0]
|
||||
stripped_lines.append(code)
|
||||
code_only = "\n".join(stripped_lines)
|
||||
assert not re.search(r'(^|\s)continue\b', code_only), (
|
||||
"dormant hook block contains a `continue` statement — IMP-16~20 "
|
||||
"alerts must never block Stage 5 entry (informational-only contract)."
|
||||
)
|
||||
|
||||
def test_run_stage_dormant_hook_positioned_before_stage_pass_return(self, orch):
|
||||
"""u3 contract: dormant hook is positioned within the Stage 4 YES PASS path.
|
||||
|
||||
Verifies the hook sits between the P4a audit commit-scope guard and
|
||||
the Stage 4 success log+return (i.e., on the PASS path), not in an
|
||||
unreachable branch. Protects against accidental relocation during
|
||||
future refactors.
|
||||
"""
|
||||
src = self._run_stage_src(orch)
|
||||
hook_pos = src.find("_check_dormant_triggers()")
|
||||
pass_log_pos = src.find("YES (evidence verified)", hook_pos)
|
||||
return_true_pos = src.find("return True", hook_pos)
|
||||
assert hook_pos >= 0
|
||||
assert 0 < pass_log_pos - hook_pos < 4000, (
|
||||
"dormant hook is not adjacent to the Stage 4 PASS log line — "
|
||||
"run_stage shape may have shifted away from PASS-path placement."
|
||||
)
|
||||
assert return_true_pos > pass_log_pos, (
|
||||
"Stage 4 `return True` should follow the PASS log line; "
|
||||
"dormant hook must precede both."
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# u5 — registry self-documentation + governance cross-reference
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRegistryHeaderAndGovernanceRef:
|
||||
|
||||
def test_registry_header_explains_schema_and_l3_purpose(self):
|
||||
"""u5 acceptance: yaml header explains schema + L3 informational-only purpose.
|
||||
|
||||
The header is the durable self-documentation surface that anchors the
|
||||
IMP-16/17/18/19/20 registry (per Stage 1 exit unit u5).
|
||||
"""
|
||||
text = REGISTRY_PATH.read_text(encoding="utf-8")
|
||||
assert "Schema" in text or "schema" in text
|
||||
assert "dormant" in text.lower()
|
||||
assert "L3" in text or "machine-readable" in text.lower()
|
||||
assert "Guardrails" in text or "informational" in text.lower()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not GOVERNANCE_PATH.exists()
|
||||
or "DORMANT-TRIGGERS.yaml" not in GOVERNANCE_PATH.read_text(encoding="utf-8"),
|
||||
reason="u5 governance-doc reference line not yet appended (passes after u5 lands).",
|
||||
)
|
||||
def test_governance_doc_references_registry(self):
|
||||
"""u5 deliverable: PROJECT-INTENT-AND-GOVERNANCE.md cites DORMANT-TRIGGERS.yaml as L3.
|
||||
|
||||
Runs after u5 lands — IMP-16~20 registry surfaces in the governance
|
||||
anti-patterns row so future maintainers find it without rediscovery.
|
||||
"""
|
||||
text = GOVERNANCE_PATH.read_text(encoding="utf-8")
|
||||
assert "DORMANT-TRIGGERS.yaml" in text
|
||||
@@ -0,0 +1,33 @@
|
||||
"""IMP-17 u1 (2026-05-19) — comment anchor for src/phase_z2_pipeline.py route hint table.
|
||||
|
||||
Stage 1 finding: line 564 previously referenced a non-existent ID ("IMP-31").
|
||||
The legitimate slot is IMP-17 (Gitea #17, carve-out — AI fallback only, normal path 밖).
|
||||
Line 565 (IMP-29 frontend zone-level override) must remain untouched.
|
||||
|
||||
Anchor re-pin (2026-05-20, IMP-30 u1 follow-up): V4Match.provisional field added at
|
||||
src/phase_z2_pipeline.py:179-184 shifted the route-hint table down by six lines.
|
||||
Pinned line numbers updated from 564/565 → 570/571 to track the actual anchor location.
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
PIPELINE = ROOT / "src" / "phase_z2_pipeline.py"
|
||||
|
||||
|
||||
def _lines() -> list[str]:
|
||||
return PIPELINE.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def test_line_570_references_imp17_not_imp31():
|
||||
line = _lines()[569] # 1-indexed line 570
|
||||
assert "restructure" in line, f"line 570 anchor drifted: {line!r}"
|
||||
assert "IMP-17" in line, f"line 570 must reference IMP-17 (carve-out): {line!r}"
|
||||
assert "IMP-31" not in line, f"line 570 must not reference non-existent IMP-31: {line!r}"
|
||||
|
||||
|
||||
def test_line_571_still_references_imp29():
|
||||
line = _lines()[570] # 1-indexed line 571
|
||||
assert "reject" in line, f"line 571 anchor drifted: {line!r}"
|
||||
assert "IMP-29" in line, f"line 571 must still reference IMP-29 frontend override: {line!r}"
|
||||
@@ -0,0 +1,348 @@
|
||||
"""P0-3 (2026-05-18) — orchestrator self-test minimum set.
|
||||
|
||||
Covers detect_agent (the bug that caused #45 infinite loop), parse_consensus,
|
||||
parse_remaining_units, IMPLEMENTATION_UNITS parsing, dual-write normalize.
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add design_agent root to sys.path so we can import orchestrator.py
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from orchestrator import (
|
||||
detect_agent,
|
||||
parse_consensus,
|
||||
parse_remaining_units,
|
||||
_is_execution_issue,
|
||||
)
|
||||
import re
|
||||
|
||||
|
||||
class TestExecutionIssueDetection:
|
||||
"""P1-4 — execution sub-issue title detection."""
|
||||
|
||||
def test_execution_korean_pattern(self):
|
||||
assert _is_execution_issue("[IMP-15 실행-1] image_aspect_mismatch") is True
|
||||
assert _is_execution_issue("[IMP-15 실행-2] table overflow") is True
|
||||
assert _is_execution_issue("[IMP-15 실행 3] something") is True
|
||||
|
||||
def test_execution_english_pattern(self):
|
||||
assert _is_execution_issue("[IMP-15 exec-1] image") is True
|
||||
assert _is_execution_issue("[IMP-15 EXEC 2] table") is True
|
||||
|
||||
def test_non_execution_title(self):
|
||||
assert _is_execution_issue("IMP-15 Step 14 visual_check 보강") is False
|
||||
assert _is_execution_issue("IMP-09 B-4 다른 layout zone-geometry") is False
|
||||
|
||||
def test_empty_title(self):
|
||||
assert _is_execution_issue("") is False
|
||||
assert _is_execution_issue(None) is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# detect_agent — the bug that caused #45 infinite loop
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDetectAgent:
|
||||
def test_claude_header(self):
|
||||
assert detect_agent("[Claude #1] Stage 1 ...") == "claude"
|
||||
|
||||
def test_codex_header(self):
|
||||
assert detect_agent("[Codex #1] Stage 1 review") == "codex"
|
||||
|
||||
def test_codex_body_with_claude_citation(self):
|
||||
"""The exact bug from #45 — Codex body contains [Claude #N] citation in
|
||||
EVIDENCE section. Old detect_agent returned 'claude' (wrong)."""
|
||||
body = """[Codex #2] Stage 2 Round #1 simulation-plan verification
|
||||
|
||||
Verdict: NO.
|
||||
|
||||
=== EVIDENCE ===
|
||||
- Read current-stage Gitea comment `[Claude #2] Stage 2 Round #1 - Plan` only
|
||||
"""
|
||||
assert detect_agent(body) == "codex", \
|
||||
"Codex body containing [Claude #N] citation must still detect as codex"
|
||||
|
||||
def test_claude_body_with_codex_citation(self):
|
||||
body = """[Claude #3] Stage 2 Round #2 - Plan
|
||||
|
||||
Addressing [Codex #2] findings ...
|
||||
"""
|
||||
assert detect_agent(body) == "claude"
|
||||
|
||||
def test_empty_body(self):
|
||||
assert detect_agent("") is None
|
||||
assert detect_agent(None) is None
|
||||
assert detect_agent(" \n ") is None
|
||||
|
||||
def test_no_agent_header(self):
|
||||
assert detect_agent("This is some random text without any agent marker") is None
|
||||
|
||||
def test_leading_whitespace_before_header(self):
|
||||
body = " \n[Codex #1] header after whitespace"
|
||||
assert detect_agent(body) == "codex"
|
||||
|
||||
def test_header_must_be_at_start(self):
|
||||
"""Body that doesn't start with [Agent header should return None."""
|
||||
body = "Some intro text.\n[Codex #1] header on second line"
|
||||
# P0-1 fix: only first non-empty line is checked.
|
||||
# First line = "Some intro text." → no match → None
|
||||
assert detect_agent(body) is None
|
||||
|
||||
def test_header_with_hash_immediately(self):
|
||||
"""[Codex#1] (no space) should still match per regex \\[Codex[\\s#]."""
|
||||
assert detect_agent("[Codex#1] hello") == "codex"
|
||||
assert detect_agent("[Claude#5] hi") == "claude"
|
||||
|
||||
def test_audit_anchor_preface_breaks_detection(self):
|
||||
"""P5 (2026-05-20) — regression: AUDIT-ONLY mode 의 'Audit anchor:' preface 가
|
||||
첫 줄에 박히면 detect_agent 는 None 반환 (P0-1 strict 의도된 동작).
|
||||
이게 #56 (INTEGRATION-AUDIT-02) 의 Stage 4 Round #14 infinite loop 의 직접 원인.
|
||||
해결책 = detect_agent 완화 X, AUDIT_ONLY_NOTE 가 agent header 를 first line 으로 강제."""
|
||||
body_anchor_first = (
|
||||
"Audit anchor: This audit verifies pipeline contracts...\n"
|
||||
"It does not implement runtime code.\n"
|
||||
"\n"
|
||||
"[Codex #14] Stage 4 (test-verify) Round #14 - INTEGRATION-AUDIT-02\n"
|
||||
"\n"
|
||||
"Verdict: PASS. Stage 3 satisfies all criteria.\n"
|
||||
"FINAL_CONSENSUS: YES\n"
|
||||
)
|
||||
assert detect_agent(body_anchor_first) is None, (
|
||||
"audit anchor preface as first line MUST cause detect_agent None "
|
||||
"(P0-1 strict). Fix path: comment format, not detect_agent."
|
||||
)
|
||||
|
||||
def test_audit_anchor_after_header_works(self):
|
||||
"""P5 (2026-05-20) — 올바른 format: agent header first line, anchor line 2+."""
|
||||
body_header_first = (
|
||||
"[Codex #14] Stage 4 (test-verify) Round #14 - INTEGRATION-AUDIT-02\n"
|
||||
"\n"
|
||||
"Audit anchor: This audit verifies pipeline contracts...\n"
|
||||
"\n"
|
||||
"Verdict: PASS.\n"
|
||||
"FINAL_CONSENSUS: YES\n"
|
||||
)
|
||||
assert detect_agent(body_header_first) == "codex"
|
||||
|
||||
# P5b (2026-05-20) — Stage 2 compact-plan first-line conflict regression.
|
||||
# #24 IMP-24 K6: Codex r1~r3 가 첫 줄을 '=== IMPLEMENTATION_UNITS ===' 로 시작 →
|
||||
# detect_agent None → orchestrator silent loop. fix path = comment format strict,
|
||||
# NOT detect_agent 완화 (P0-1 강화 그대로 유지).
|
||||
|
||||
def test_implementation_units_first_line_breaks_detection(self):
|
||||
"""=== IMPLEMENTATION_UNITS === 가 첫 줄이면 detect_agent None (P0-1 strict 정상 동작)."""
|
||||
body = (
|
||||
"=== IMPLEMENTATION_UNITS ===\n"
|
||||
"- id: u1\n"
|
||||
" summary: ...\n"
|
||||
" files:\n"
|
||||
" - docs/architecture/PHASE-Q-AUDIT.md\n"
|
||||
" tests:\n"
|
||||
" - pytest -q tests\n"
|
||||
" estimate_lines: 1\n"
|
||||
"\n"
|
||||
"FINAL_CONSENSUS: YES\n"
|
||||
)
|
||||
assert detect_agent(body) is None, (
|
||||
"=== IMPLEMENTATION_UNITS === as first line MUST cause detect_agent None "
|
||||
"(P0-1 strict). Fix path: enforce agent header first-line in prompt, not relax detect_agent."
|
||||
)
|
||||
|
||||
def test_compact_plan_with_header_first_works(self):
|
||||
"""올바른 Stage 2 compact format: [Codex #N] 첫 줄 → === IMPLEMENTATION_UNITS === 둘째 줄+."""
|
||||
body = (
|
||||
"[Codex #4] Stage 2 simulation-plan review - IMP-24 K6\n"
|
||||
"\n"
|
||||
"=== IMPLEMENTATION_UNITS ===\n"
|
||||
"- id: u1\n"
|
||||
" summary: ...\n"
|
||||
" tests:\n"
|
||||
" - pytest -q tests\n"
|
||||
"\n"
|
||||
"FINAL_CONSENSUS: YES\n"
|
||||
)
|
||||
assert detect_agent(body) == "codex"
|
||||
|
||||
def test_markdown_prefix_breaks_detection(self):
|
||||
"""P5b — `## [Codex #N]` 같은 markdown header prefix 도 detect_agent None.
|
||||
(#21 Stage 4 에서 관찰된 latent silent loop 원인.)"""
|
||||
body_hash = "## [Codex #1] Stage 4 test-verify Round #1\n\nVerdict: PASS\n"
|
||||
body_emoji = "📌 **[Claude #1] Stage 2 plan**\n\nbody\n"
|
||||
body_bold = "**[Codex #1] Stage 4**\n\nbody\n"
|
||||
assert detect_agent(body_hash) is None
|
||||
assert detect_agent(body_emoji) is None
|
||||
assert detect_agent(body_bold) is None
|
||||
|
||||
|
||||
class TestRulesAndCompactPlanFirstLineContract:
|
||||
"""P5b (2026-05-20) — RULES 와 COMPACT_PLAN_RULE 둘 다 first-line agent header
|
||||
rule 을 명시해야 함. wording 검증."""
|
||||
|
||||
def test_rules_has_first_line_strict(self):
|
||||
from orchestrator import RULES
|
||||
# RULES 안에 first-line strict + 모든 stage 적용 명시 있어야 함.
|
||||
assert "FIRST non-empty line" in RULES
|
||||
assert "[Claude #N]" in RULES and "[Codex #N]" in RULES
|
||||
# P5b OVERRIDES 키워드 — body rule 들이 first-line rule 보다 우선하지 않음을 강조
|
||||
assert "OVERRIDES" in RULES or "overrides" in RULES.lower()
|
||||
|
||||
def test_compact_plan_rule_carves_out_first_line(self):
|
||||
from orchestrator import COMPACT_PLAN_RULE
|
||||
# "body" 는 first-line agent header 다음부터 시작한다고 명시
|
||||
assert "FIRST non-empty line" in COMPACT_PLAN_RULE or "first-line agent header" in COMPACT_PLAN_RULE
|
||||
# "after the first-line" 같은 carve-out wording 검증
|
||||
body_lower = COMPACT_PLAN_RULE.lower()
|
||||
assert "after the first" in body_lower or "after the agent header" in body_lower
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# parse_consensus — YES/NO + rewind_target
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseConsensus:
|
||||
def test_yes_only(self):
|
||||
body = "Some text.\nFINAL_CONSENSUS: YES"
|
||||
assert parse_consensus(body) == ("YES", None)
|
||||
|
||||
def test_no_with_rewind_target(self):
|
||||
body = "Some text.\nrewind_target: stage_2_plan\nFINAL_CONSENSUS: NO"
|
||||
assert parse_consensus(body) == ("NO", "stage_2_plan")
|
||||
|
||||
def test_no_with_continue_same(self):
|
||||
body = "blah\nrewind_target: continue_same\nFINAL_CONSENSUS: NO"
|
||||
assert parse_consensus(body) == ("NO", "continue_same")
|
||||
|
||||
def test_no_target_only_in_last_10_lines(self):
|
||||
"""parse_consensus only scans last 10 lines."""
|
||||
body = "rewind_target: stage_1_review\n" + "\n".join(["filler"] * 20) + "\nFINAL_CONSENSUS: NO"
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target is None # too far from end to be picked up
|
||||
|
||||
def test_no_consensus_marker(self):
|
||||
assert parse_consensus("just text, no marker") == (None, None)
|
||||
|
||||
def test_empty_body(self):
|
||||
assert parse_consensus("") == (None, None)
|
||||
assert parse_consensus(None) == (None, None)
|
||||
|
||||
def test_unknown_rewind_target_ignored(self):
|
||||
body = "rewind_target: bogus_target\nFINAL_CONSENSUS: NO"
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target is None # bogus is not in REWIND_TARGET_TO_SID
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# parse_remaining_units — Stage 3 continue_same progress detection
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestParseRemainingUnits:
|
||||
def test_bracketed_list(self):
|
||||
body = "Remaining units: [u2, u3, u4]"
|
||||
assert parse_remaining_units(body) == {"u2", "u3", "u4"}
|
||||
|
||||
def test_comma_list_no_brackets(self):
|
||||
body = "Remaining units: u5, u6, u7"
|
||||
assert parse_remaining_units(body) == {"u5", "u6", "u7"}
|
||||
|
||||
def test_none_explicit(self):
|
||||
assert parse_remaining_units("Remaining units: none") == set()
|
||||
assert parse_remaining_units("Remaining units: []") == set()
|
||||
assert parse_remaining_units("Remaining units: (none)") == set()
|
||||
assert parse_remaining_units("Remaining units: -") == set()
|
||||
|
||||
def test_line_not_present(self):
|
||||
assert parse_remaining_units("no remaining units mentioned here") is None
|
||||
|
||||
def test_case_insensitive(self):
|
||||
body = "REMAINING UNITS: [U1, U2]"
|
||||
assert parse_remaining_units(body) == {"u1", "u2"}
|
||||
|
||||
def test_only_u_prefixed_digits(self):
|
||||
"""Sentence noise ignored — only u\\d+ pattern matched."""
|
||||
body = "Remaining units: I still need to do u3 and u7 work"
|
||||
assert parse_remaining_units(body) == {"u3", "u7"}
|
||||
|
||||
def test_empty_body(self):
|
||||
assert parse_remaining_units("") is None
|
||||
assert parse_remaining_units(None) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# IMPLEMENTATION_UNITS block parsing (used in Stage 2 YES guard)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestImplementationUnitsBlock:
|
||||
"""Reproduces the parser in run_stage Stage 2 YES guard (line ~810)."""
|
||||
|
||||
def _parse(self, body):
|
||||
iu_block_pat = re.compile(
|
||||
r"===\s*IMPLEMENTATION_UNITS\s*===\s*\n(.*?)(?=\n===\s|\Z)",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
iu_unit_pat = re.compile(r"^\s*-\s*id:\s*u\d+", re.IGNORECASE | re.MULTILINE)
|
||||
m = iu_block_pat.search(body or "")
|
||||
return bool(m and iu_unit_pat.search(m.group(1)))
|
||||
|
||||
def test_valid_block(self):
|
||||
body = """text
|
||||
|
||||
=== IMPLEMENTATION_UNITS ===
|
||||
- id: u1
|
||||
summary: ...
|
||||
- id: u2
|
||||
summary: ...
|
||||
"""
|
||||
assert self._parse(body) is True
|
||||
|
||||
def test_empty_block(self):
|
||||
body = "=== IMPLEMENTATION_UNITS ===\n(no entries)\n"
|
||||
assert self._parse(body) is False # header but no - id: uN entry
|
||||
|
||||
def test_block_missing(self):
|
||||
body = "just text, no implementation_units"
|
||||
assert self._parse(body) is False
|
||||
|
||||
def test_block_with_only_non_u_entries(self):
|
||||
body = """=== IMPLEMENTATION_UNITS ===
|
||||
- id: alpha
|
||||
summary: ...
|
||||
"""
|
||||
assert self._parse(body) is False # 'alpha' is not 'u\\d+'
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Direct integration check — the #45 bug case
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRegressionForIssue45Bug:
|
||||
"""Verify the exact body shape that caused #45 infinite loop is now handled."""
|
||||
|
||||
def test_codex_no_with_claude_citation_full_flow(self):
|
||||
body = """[Codex #3] Stage 2 Round #2 simulation-plan verification for issue #45
|
||||
|
||||
Verdict: NO. The plan covers main axes but violates two Stage 2 requirements.
|
||||
|
||||
Findings:
|
||||
- Unit u1 declares tests: [] in === IMPLEMENTATION_UNITS ===
|
||||
- xfail-strict mechanism unclear
|
||||
|
||||
=== EVIDENCE ===
|
||||
Commands run:
|
||||
- git rev-parse HEAD
|
||||
- Read current-stage Gitea comment `[Claude #3] Stage 2 Round #2 - Plan`
|
||||
|
||||
rewind_target: stage_2_plan
|
||||
FINAL_CONSENSUS: NO
|
||||
"""
|
||||
# P0-1 fix: detect_agent reads only first line → "[Codex #3]" → codex
|
||||
assert detect_agent(body) == "codex", "P0-1 regression test"
|
||||
# parse_consensus: NO + rewind_target stage_2_plan
|
||||
status, target = parse_consensus(body)
|
||||
assert status == "NO"
|
||||
assert target == "stage_2_plan"
|
||||
@@ -0,0 +1,310 @@
|
||||
"""P3-5 (2026-05-18) — subprocess cleanup hardening verification.
|
||||
|
||||
Covers:
|
||||
C1: 정상 종료 → tree 잔류 0
|
||||
C2: timeout → TimeoutExpired raise + 자손 0
|
||||
C3: grandchild spawn 후 parent timeout → grandchild 정리
|
||||
C4: 외부 (orchestrator 가 spawn 안한) 프로세스 보호
|
||||
C5: _kill_process_tree(self.pid) 호출해도 orchestrator 자살 안 함
|
||||
C6 (CORE): parent 정상 종료 후 grandchild orphan 정리 — PID 2780 regression
|
||||
|
||||
Run: pytest -q tests/orchestrator_unit/test_subprocess_cleanup.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from orchestrator import (
|
||||
_kill_process_tree,
|
||||
_kill_tracked,
|
||||
_run_with_tree_kill,
|
||||
_proc_signature,
|
||||
_is_same_process,
|
||||
_SPAWNED,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _py():
|
||||
"""Path to current Python interpreter — used to spawn dummy subprocesses."""
|
||||
return sys.executable
|
||||
|
||||
def _alive(pid):
|
||||
try:
|
||||
return psutil.Process(pid).is_running() and psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
|
||||
except psutil.NoSuchProcess:
|
||||
return False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Signature helpers
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSignatureHelpers:
|
||||
def test_proc_signature_alive(self):
|
||||
p = psutil.Process(os.getpid())
|
||||
sig = _proc_signature(p)
|
||||
assert sig is not None
|
||||
assert sig[0] == os.getpid()
|
||||
assert isinstance(sig[1], float)
|
||||
|
||||
def test_is_same_process_orch_self_blocked(self):
|
||||
"""C5 prep — orchestrator 자기 자신은 절대 same-process true 안 됨."""
|
||||
p = psutil.Process(os.getpid())
|
||||
sig = _proc_signature(p)
|
||||
# _is_same_process 가 _ORCH_PID 체크로 False 반환해야 함.
|
||||
assert _is_same_process(sig[0], sig[1]) is False
|
||||
|
||||
def test_is_same_process_dead_pid(self):
|
||||
# 사용 가능성 낮은 PID 999999 — 거의 확실히 죽음.
|
||||
assert _is_same_process(999999, time.time()) is False
|
||||
|
||||
def test_is_same_process_wrong_create_time(self):
|
||||
"""PID 재사용 회피 검증 — 같은 PID 라도 create_time 안 맞으면 False."""
|
||||
# 살아있는 외부 프로세스 빌려서 일부러 어긋난 create_time 으로 호출.
|
||||
# System Idle 같은 특수 프로세스 (create_time=0) 회피 — 우리가 띄운 dummy 사용.
|
||||
dummy = subprocess.Popen(
|
||||
[_py(), "-c", "import time; time.sleep(5)"],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
try:
|
||||
# 실제 create_time 보다 1 년 전 시각 → 명백한 mismatch.
|
||||
far_past = time.time() - 365 * 24 * 3600
|
||||
assert _is_same_process(dummy.pid, far_past) is False
|
||||
# 맞는 create_time 으로는 True 여야 함 (sanity).
|
||||
real_ct = psutil.Process(dummy.pid).create_time()
|
||||
assert _is_same_process(dummy.pid, real_ct) is True
|
||||
finally:
|
||||
dummy.kill()
|
||||
dummy.wait(timeout=5)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C1: 정상 종료 — tree 잔류 0
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC1_NormalExit:
|
||||
def test_dummy_short_run_no_residue(self):
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import time; time.sleep(0.3)"],
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
# 호출 후 _SPAWNED 에 우리 호출 잔재가 남으면 안 됨 (wrapper 가 discard).
|
||||
# 다른 테스트 영향 가능성 있어서 set 전체가 0 이 아니어도 됨, 단 우리 잔재 없으면 OK.
|
||||
# 보수적으로 — 우리 호출 직전에 _SPAWNED 가 비어있었으면 직후에도 비어있어야 함.
|
||||
assert len(_SPAWNED) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C2: Timeout — TimeoutExpired raise + 자손 정리
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC2_Timeout:
|
||||
def test_dummy_long_sleep_times_out(self):
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
_run_with_tree_kill(
|
||||
[_py(), "-c", "import time; time.sleep(60)"],
|
||||
timeout=1.5,
|
||||
)
|
||||
# raise 후에도 _SPAWNED 우리 잔재 없어야 함 (wrapper finally 가 discard).
|
||||
assert len(_SPAWNED) == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C3: grandchild orphan 정리 — parent timeout path
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC3_GrandchildTimeoutPath:
|
||||
def test_grandchild_killed_on_parent_timeout(self):
|
||||
# parent 가 grandchild 띄우고 자기는 sleep — timeout 으로 강제 종료.
|
||||
# grandchild 도 정리돼야 함.
|
||||
# PID 캡처를 위해 grandchild 가 자기 PID 를 파일에 기록.
|
||||
marker = ROOT / ".orchestrator" / "tmp" / "test_c3_gc_pid.txt"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
if marker.exists(): marker.unlink()
|
||||
|
||||
# grandchild 의 stdin/stdout/stderr 를 DEVNULL 로 분리 — production 의 claude.exe→python.exe -
|
||||
# 케이스와 동일 (grandchild 가 wrapper 의 pipe 핸들 안 상속). 안 그러면 pipe inheritance 로
|
||||
# communicate() 가 hang.
|
||||
spawn_code = (
|
||||
f"import subprocess, time, sys, os; "
|
||||
f"gc = subprocess.Popen("
|
||||
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
|
||||
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||
f"open(r'{marker}', 'w').write(str(gc.pid)); "
|
||||
f"time.sleep(60)"
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
_run_with_tree_kill(
|
||||
[_py(), "-c", spawn_code],
|
||||
timeout=3,
|
||||
)
|
||||
|
||||
# marker 파일에서 grandchild PID 읽기.
|
||||
assert marker.exists(), "grandchild marker not written — parent died too early"
|
||||
gc_pid = int(marker.read_text().strip())
|
||||
|
||||
# 잠시 대기 (cleanup 비동기 가능성) 후 grandchild 죽었는지 확인.
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and _alive(gc_pid):
|
||||
time.sleep(0.2)
|
||||
assert not _alive(gc_pid), f"grandchild PID {gc_pid} still alive after parent timeout"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C4: 외부 프로세스 보호
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC4_ExternalProcessProtection:
|
||||
def test_outsider_not_killed(self):
|
||||
# 사용자가 직접 띄운 척하는 외부 프로세스 (orchestrator 가 spawn 안 함).
|
||||
outsider = subprocess.Popen([_py(), "-c", "import time; time.sleep(10)"])
|
||||
try:
|
||||
# _kill_tracked 에 외부 PID 의 (잘못된) signature 넘기면 무시돼야 함.
|
||||
# signature 일치 안 하면 _is_same_process False → kill 안 됨.
|
||||
wrong_sig = [(outsider.pid, 0.0)] # create_time 안 맞음
|
||||
cleaned = _kill_tracked(wrong_sig)
|
||||
assert cleaned == 0
|
||||
assert _alive(outsider.pid), "outsider killed despite wrong create_time"
|
||||
finally:
|
||||
outsider.kill()
|
||||
outsider.wait(timeout=5)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C5: orchestrator 자살 방지
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC5_SelfKillProtection:
|
||||
def test_kill_process_tree_self_pid_noop(self):
|
||||
"""orchestrator(=pytest) PID 로 _kill_process_tree 호출해도 죽으면 안 됨."""
|
||||
result = _kill_process_tree(os.getpid())
|
||||
assert result == 0 # ORCH_PID 검사로 즉시 0 반환
|
||||
|
||||
def test_kill_tracked_with_orch_pid_noop(self):
|
||||
# 일부러 self signature 를 tracked 에 넣어도 _is_same_process False → skip.
|
||||
self_p = psutil.Process(os.getpid())
|
||||
self_sig = _proc_signature(self_p)
|
||||
cleaned = _kill_tracked([self_sig])
|
||||
assert cleaned == 0 # 자기 자신 보호
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C6 (CORE): parent 정상 종료 후 grandchild orphan 정리
|
||||
# — PID 2780 regression test
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC6_OrphanGrandchildAfterNormalExit:
|
||||
"""PID 2780 path: parent 가 정상 exit 했는데 grandchild 만 살아남는 케이스.
|
||||
monitor thread 가 parent 살아있을 때 grandchild 를 미리 추적해서 finally 에서 정리해야 함."""
|
||||
|
||||
def test_grandchild_killed_after_parent_normal_exit(self):
|
||||
marker = ROOT / ".orchestrator" / "tmp" / "test_c6_gc_pid.txt"
|
||||
marker.parent.mkdir(parents=True, exist_ok=True)
|
||||
if marker.exists(): marker.unlink()
|
||||
|
||||
# parent 가:
|
||||
# 1. grandchild 띄움 (DEVNULL 격리 — production claude.exe→python.exe - 과 동등).
|
||||
# 2. PID 마커에 기록.
|
||||
# 3. monitor 가 1초 polling 으로 catch 할 시간 확보 (2.5초 sleep).
|
||||
# 4. 정상 종료.
|
||||
spawn_code = (
|
||||
f"import subprocess, time, sys, os; "
|
||||
f"gc = subprocess.Popen("
|
||||
f" [sys.executable, '-c', 'import time; time.sleep(60)'], "
|
||||
f" stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
|
||||
f"open(r'{marker}', 'w').write(str(gc.pid)); "
|
||||
f"time.sleep(2.5); "
|
||||
f"sys.exit(0)"
|
||||
)
|
||||
|
||||
# 정상 종료 (timeout 안 걸림) — wrapper 의 finally cleanup 만으로 grandchild 잡혀야 함.
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", spawn_code],
|
||||
timeout=15,
|
||||
)
|
||||
assert r.returncode == 0, "parent did not exit normally"
|
||||
|
||||
# marker 에서 grandchild PID.
|
||||
assert marker.exists(), "grandchild marker missing"
|
||||
gc_pid = int(marker.read_text().strip())
|
||||
|
||||
# 정리 비동기 가능성 → 짧게 대기 후 확인.
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and _alive(gc_pid):
|
||||
time.sleep(0.2)
|
||||
assert not _alive(gc_pid), (
|
||||
f"REGRESSION: grandchild PID {gc_pid} survived parent normal exit "
|
||||
f"(PID 2780 path not fixed)"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# C7: input + encoding path — run_claude 가 실제 사용하는 호출 모드.
|
||||
# 2026-05-18 production bug: str input + encoding="utf-8" 일 때
|
||||
# wrapper 가 input 을 강제로 bytes 인코딩 → Popen text mode pipe 에
|
||||
# bytes 쓰려다 TypeError: write() argument must be str, not bytes.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestC7_InputEncodingPath:
|
||||
def test_str_input_with_encoding_utf8(self):
|
||||
"""run_claude 와 동일한 호출 모드 — input=str + encoding='utf-8'."""
|
||||
# stdin 에서 읽은 그대로 stdout 으로 echo. 한글 포함해서 encoding 검증.
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.write(sys.stdin.read())"],
|
||||
input="hello 안녕\n",
|
||||
encoding="utf-8",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
# encoding= 모드면 stdout 는 str 이어야 함.
|
||||
assert isinstance(r.stdout, str)
|
||||
assert "hello" in r.stdout
|
||||
assert "안녕" in r.stdout
|
||||
|
||||
def test_bytes_input_without_encoding(self):
|
||||
"""encoding 없으면 binary mode — input=bytes 그대로 통과."""
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
|
||||
input=b"raw bytes",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert isinstance(r.stdout, bytes)
|
||||
assert r.stdout == b"raw bytes"
|
||||
|
||||
def test_str_input_without_encoding_auto_encoded(self):
|
||||
"""input=str 인데 encoding 없으면 wrapper 가 자동 utf-8 인코딩."""
|
||||
r = _run_with_tree_kill(
|
||||
[_py(), "-c", "import sys; sys.stdout.buffer.write(sys.stdin.buffer.read())"],
|
||||
input="auto encode 한글",
|
||||
timeout=10,
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert isinstance(r.stdout, bytes)
|
||||
assert r.stdout.decode("utf-8") == "auto encode 한글"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Bonus: _SPAWNED discipline — 다중 호출 후 누적 안 됨
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestSpawnedDiscipline:
|
||||
def test_spawned_drained_between_calls(self):
|
||||
for _ in range(3):
|
||||
_run_with_tree_kill([_py(), "-c", "pass"], timeout=10)
|
||||
# 3 회 호출 후에도 우리 잔재 없음 (wrapper finally 가 discard).
|
||||
assert len(_SPAWNED) == 0
|
||||
@@ -0,0 +1,43 @@
|
||||
input:
|
||||
layout_preset: grid-2x2
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,63 @@
|
||||
input:
|
||||
layout_preset: grid-2x2
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top-left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.55
|
||||
h: 0.4
|
||||
top-right:
|
||||
x: 0.55
|
||||
y: 0
|
||||
w: 0.45
|
||||
h: 0.4
|
||||
bottom-left:
|
||||
x: 0
|
||||
y: 0.4
|
||||
w: 0.55
|
||||
h: 0.6
|
||||
bottom-right:
|
||||
x: 0.55
|
||||
y: 0.4
|
||||
w: 0.45
|
||||
h: 0.6
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 641px 525px
|
||||
rows: 228px 343px
|
||||
heights_px:
|
||||
- 228
|
||||
- 343
|
||||
widths_px:
|
||||
- 641
|
||||
- 525
|
||||
ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
width_ratios:
|
||||
- 0.55
|
||||
- 0.45
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: min_height_first + content_weight_distribution
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,41 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 1.0
|
||||
h: 0.3
|
||||
bottom:
|
||||
x: 0
|
||||
y: 0.3
|
||||
w: 1.0
|
||||
h: 0.7
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 176px 410px
|
||||
heights_px:
|
||||
- 176
|
||||
- 410
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.3
|
||||
- 0.7
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.8
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.2
|
||||
min_height_px: 150
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 377px 194px
|
||||
heights_px:
|
||||
- 377
|
||||
- 194
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.644
|
||||
- 0.332
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: min_height_first + content_weight_distribution
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: left-1-right-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right-top
|
||||
template_id: MOCK_right-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right-bottom
|
||||
template_id: MOCK_right-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 777px 389px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 777
|
||||
- 389
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.658
|
||||
- 0.33
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: left-1-right-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right-top
|
||||
template_id: MOCK_right-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right-bottom
|
||||
template_id: MOCK_right-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 1.0
|
||||
right-top:
|
||||
x: 0.4
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 0.5
|
||||
right-bottom:
|
||||
x: 0.4
|
||||
y: 0.5
|
||||
w: 0.6
|
||||
h: 0.5
|
||||
expected_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 466px 700px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 466
|
||||
- 700
|
||||
ratios:
|
||||
- 0.5
|
||||
- 0.5
|
||||
width_ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: left-2-right-1
|
||||
zones_data:
|
||||
- position: left-top
|
||||
template_id: MOCK_left-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: left-bottom
|
||||
template_id: MOCK_left-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 389px 777px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 389
|
||||
- 777
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.33
|
||||
- 0.658
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: left-2-right-1
|
||||
zones_data:
|
||||
- position: left-top
|
||||
template_id: MOCK_left-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: left-bottom
|
||||
template_id: MOCK_left-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left-top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.35
|
||||
h: 0.6
|
||||
left-bottom:
|
||||
x: 0
|
||||
y: 0.6
|
||||
w: 0.35
|
||||
h: 0.4
|
||||
right:
|
||||
x: 0.35
|
||||
y: 0
|
||||
w: 0.65
|
||||
h: 1.0
|
||||
expected_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 408px 758px
|
||||
rows: 343px 228px
|
||||
heights_px:
|
||||
- 343
|
||||
- 228
|
||||
widths_px:
|
||||
- 408
|
||||
- 758
|
||||
ratios:
|
||||
- 0.6
|
||||
- 0.4
|
||||
width_ratios:
|
||||
- 0.35
|
||||
- 0.65
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: top-1-bottom-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 314px 257px
|
||||
heights_px:
|
||||
- 314
|
||||
- 257
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.537
|
||||
- 0.439
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: top-1-bottom-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 1.0
|
||||
h: 0.3
|
||||
bottom-left:
|
||||
x: 0
|
||||
y: 0.3
|
||||
w: 0.5
|
||||
h: 0.7
|
||||
bottom-right:
|
||||
x: 0.5
|
||||
y: 0.3
|
||||
w: 0.5
|
||||
h: 0.7
|
||||
expected_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 171px 400px
|
||||
heights_px:
|
||||
- 171
|
||||
- 400
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.3
|
||||
- 0.7
|
||||
width_ratios:
|
||||
- 0.5
|
||||
- 0.5
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: top-2-bottom-1
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 583px 583px
|
||||
rows: 257px 314px
|
||||
heights_px:
|
||||
- 257
|
||||
- 314
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.439
|
||||
- 0.537
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: top-2-bottom-1
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top-left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 0.4
|
||||
top-right:
|
||||
x: 0.6
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 0.4
|
||||
bottom:
|
||||
x: 0
|
||||
y: 0.4
|
||||
w: 1.0
|
||||
h: 0.6
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 700px 466px
|
||||
rows: 228px 343px
|
||||
heights_px:
|
||||
- 228
|
||||
- 343
|
||||
widths_px:
|
||||
- 700
|
||||
- 466
|
||||
ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
width_ratios:
|
||||
- 0.6
|
||||
- 0.4
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 583px 583px
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: content_weight_distribution_cols
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,41 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 1.0
|
||||
right:
|
||||
x: 0.4
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 1.0
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 40.0fr 60.0fr
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 466
|
||||
- 700
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.7
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.3
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 816px 350px
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 816
|
||||
- 350
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.692
|
||||
- 0.297
|
||||
computation: content_weight_distribution_cols
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: grid-2x2_dynamic_2d
|
||||
description: |
|
||||
grid-2x2 (2x2 topology) is promoted to 2-D dynamic in IMP-09 PR 2.
|
||||
Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: horizontal2_dynamic_rows
|
||||
description: |
|
||||
horizontal-2 layout with dynamic_rows=True must pass the IMP-09 retry
|
||||
gate. The base trace should record retry_attempted=True (legacy
|
||||
plan/rerender path continues). retry_skipped_reason MUST NOT contain
|
||||
either of the IMP-09 gate skip strings.
|
||||
input_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 333px 238px
|
||||
heights_px: [333, 238]
|
||||
widths_px: [1180]
|
||||
ratios: [0.569, 0.407]
|
||||
width_ratios: [1.0]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: true
|
||||
retry_skipped_reason_excludes:
|
||||
- "dynamic_cols"
|
||||
- "fr_default_from_preset"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: left-1-right-2_dynamic_2d
|
||||
description: |
|
||||
left-1-right-2 (side-T-left topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 777px 389px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [777, 389]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.658, 0.33]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: left-2-right-1_dynamic_2d
|
||||
description: |
|
||||
left-2-right-1 (side-T-right topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 389px 777px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [389, 777]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.33, 0.658]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,23 @@
|
||||
case_id: single_fr_default
|
||||
description: |
|
||||
Any layout that fell through to fr_default_from_preset (single,
|
||||
T-shape, 2x2 in PR 1) has neither dynamic_rows nor dynamic_cols.
|
||||
Row-axis retry is a no-op and must be skipped by the IMP-09 gate
|
||||
with a fr_default_from_preset skip reason.
|
||||
input_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 1fr 1fr
|
||||
rows: 1fr 1fr
|
||||
heights_px: [285, 286]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.487, 0.489]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: false
|
||||
dynamic_cols: false
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "fr_default_from_preset"
|
||||
@@ -0,0 +1,26 @@
|
||||
case_id: top-1-bottom-2_dynamic_2d
|
||||
description: |
|
||||
top-1-bottom-2 (T topology) is promoted to 2-D dynamic in IMP-09
|
||||
PR 2 (dynamic_rows=True, dynamic_cols=True). Row-axis retry MUST be
|
||||
skipped by the IMP-09 gate with the "dynamic_cols (2-D topology)"
|
||||
skip reason, because row-only redistribution cannot reconcile both
|
||||
axes simultaneously.
|
||||
input_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 314px 257px
|
||||
heights_px: [314, 257]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.537, 0.439]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: top-2-bottom-1_dynamic_2d
|
||||
description: |
|
||||
top-2-bottom-1 (inverted-T topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 583px 583px
|
||||
rows: 257px 314px
|
||||
heights_px: [257, 314]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.439, 0.537]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: vertical2_dynamic_cols
|
||||
description: |
|
||||
vertical-2 layout with dynamic_cols=True must be skipped by the
|
||||
IMP-09 retry gate before plan/rerender, because the existing
|
||||
apply_retry_to_layout_css mutates only row-axis fields and would
|
||||
produce a misleading trace if it ran on a column-dynamic layout.
|
||||
input_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 583px 583px
|
||||
rows: 1fr
|
||||
heights_px: [585]
|
||||
widths_px: [583, 583]
|
||||
ratios: [1.0]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""IMP-09 PR 1 — build_layout_css contract tests.
|
||||
|
||||
Verifies horizontal-2 byte-identity for the legacy grid strings
|
||||
(areas / cols / rows) and that every return path now carries the new
|
||||
length-locked col-axis keys (widths_px / width_ratios / dynamic_cols).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_HEIGHT,
|
||||
SLIDE_BODY_WIDTH,
|
||||
build_layout_css,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float, min_h: int = 100) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
"min_height_px": min_h,
|
||||
}
|
||||
|
||||
|
||||
# ────────────────────── new-key contract ──────────────────────
|
||||
|
||||
|
||||
NEW_KEYS = {"widths_px", "width_ratios", "dynamic_cols"}
|
||||
|
||||
|
||||
def test_all_presets_carry_new_col_axis_keys():
|
||||
"""Every PR 1 return path must include widths_px / width_ratios /
|
||||
dynamic_cols, and heights_px / widths_px must be length-locked to
|
||||
the catalog grid (R rows, C cols)."""
|
||||
cases = [
|
||||
("single", [_zone("primary", 1.0)]),
|
||||
("horizontal-2", [_zone("top", 0.6), _zone("bottom", 0.4)]),
|
||||
("vertical-2", [_zone("left", 0.5), _zone("right", 0.5)]),
|
||||
("top-1-bottom-2", [
|
||||
_zone("top", 0.5),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]),
|
||||
("grid-2x2", [
|
||||
_zone("top-left", 0.25),
|
||||
_zone("top-right", 0.25),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]),
|
||||
]
|
||||
for preset, zones in cases:
|
||||
result = build_layout_css(preset, zones)
|
||||
missing = NEW_KEYS - set(result)
|
||||
assert not missing, f"{preset} missing new keys: {missing}"
|
||||
# heights_px / widths_px never empty in PR 1 (length-locked).
|
||||
assert len(result["heights_px"]) > 0, f"{preset} empty heights_px"
|
||||
assert len(result["widths_px"]) > 0, f"{preset} empty widths_px"
|
||||
|
||||
|
||||
# ────────────────────── horizontal-2 byte-identity ──────────────────────
|
||||
|
||||
|
||||
def test_horizontal_2_grid_strings_match_legacy():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
result = build_layout_css("horizontal-2", zones)
|
||||
|
||||
# Legacy contract: areas / cols / rows strings preserved.
|
||||
assert result["areas"] == '"top" "bottom"'
|
||||
assert result["cols"] == "1fr"
|
||||
assert result["rows"].count("px") == 2
|
||||
|
||||
# heights_px sum to body height; ratios consistent.
|
||||
assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is False
|
||||
|
||||
# New col-axis defaults: full body width, ratio 1.0.
|
||||
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
|
||||
assert result["width_ratios"] == [1.0]
|
||||
|
||||
|
||||
def test_horizontal_2_override_preserves_rows():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
override = {
|
||||
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
|
||||
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"horizontal-2", zones, override_zone_geometries=override
|
||||
)
|
||||
assert result["computation"] == "user_override_geometry"
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is False
|
||||
assert result["heights_px"][0] < result["heights_px"][1]
|
||||
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
|
||||
# Override ratio target.
|
||||
assert result["ratios"] == [0.3, 0.7]
|
||||
|
||||
|
||||
# ────────────────────── vertical-2 new dynamic ──────────────────────
|
||||
|
||||
|
||||
def test_vertical_2_normal_produces_dynamic_cols():
|
||||
zones = [_zone("left", 0.7), _zone("right", 0.3)]
|
||||
result = build_layout_css("vertical-2", zones)
|
||||
assert result["dynamic_rows"] is False
|
||||
assert result["dynamic_cols"] is True
|
||||
# cols string is px-based (no fr).
|
||||
assert "fr" not in result["cols"]
|
||||
assert result["cols"].count("px") == 2
|
||||
# Heights span full body in a single row.
|
||||
assert result["heights_px"] == [SLIDE_BODY_HEIGHT]
|
||||
# Widths reflect 70/30 weight split.
|
||||
assert result["widths_px"][0] > result["widths_px"][1]
|
||||
|
||||
|
||||
def test_vertical_2_override_keeps_fr_cols_legacy():
|
||||
"""PR 1 v-2 override path keeps legacy fr-string cols but now
|
||||
populates widths_px in pixels for downstream consumers."""
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
override = {
|
||||
"left": {"x": 0, "y": 0, "w": 0.4, "h": 1.0},
|
||||
"right": {"x": 0.4, "y": 0, "w": 0.6, "h": 1.0},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"vertical-2", zones, override_zone_geometries=override
|
||||
)
|
||||
assert result["computation"] == "user_override_geometry"
|
||||
assert "fr" in result["cols"]
|
||||
assert result["dynamic_cols"] is True
|
||||
assert result["dynamic_rows"] is False
|
||||
# widths_px now populated.
|
||||
assert len(result["widths_px"]) == 2
|
||||
assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert result["width_ratios"] == [0.4, 0.6]
|
||||
|
||||
|
||||
# ────────────────────── 2-D dynamic dispatch (PR 2) ──────────────────────
|
||||
|
||||
|
||||
def test_top_1_bottom_2_dynamic_2d_populates_geometry():
|
||||
"""T-shape (top-1-bottom-2) is dispatched through the 2-D dynamic
|
||||
builder in PR 2: heights_px / widths_px length-locked to grid
|
||||
R=2, C=2 with both dynamic flags True."""
|
||||
zones = [
|
||||
_zone("top", 0.5),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]
|
||||
result = build_layout_css("top-1-bottom-2", zones)
|
||||
assert result["computation"] == "2d_dynamic_aggregated"
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is True
|
||||
assert len(result["heights_px"]) == 2 # R rows
|
||||
assert len(result["widths_px"]) == 2 # C cols
|
||||
@@ -0,0 +1,101 @@
|
||||
"""IMP-09 PR 1 — _compute_per_zone_geometry tests (1-D paths).
|
||||
|
||||
Verifies the unified per-zone geometry aggregator on horizontal-2 and
|
||||
vertical-2 (the two 1-D presets active in PR 1). 2-D spanning zone
|
||||
cases (T / 2x2) are exercised in PR 2.
|
||||
|
||||
The helper aggregates grid-track sizes into per-zone dimensions and
|
||||
must produce length-locked outputs:
|
||||
- layout_css["heights_px"] length == R (parsed css_areas rows)
|
||||
- layout_css["widths_px"] length == C (parsed css_areas cols)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_HEIGHT,
|
||||
SLIDE_BODY_WIDTH,
|
||||
_compute_per_zone_geometry,
|
||||
build_layout_css,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
"min_height_px": 100,
|
||||
}
|
||||
|
||||
|
||||
def test_horizontal_2_per_zone_widths_match_slide_body():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
layout_css = build_layout_css("horizontal-2", zones)
|
||||
debug_zones = [{"position": "top"}, {"position": "bottom"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
# Heights mirror layout_css.heights_px.
|
||||
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
|
||||
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
|
||||
|
||||
|
||||
def test_vertical_2_per_zone_heights_match_slide_body():
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
layout_css = build_layout_css("vertical-2", zones)
|
||||
debug_zones = [{"position": "left"}, {"position": "right"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
# Both zones share the single row => height == SLIDE_BODY_HEIGHT.
|
||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
assert per_zone[1]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
# Widths mirror layout_css.widths_px.
|
||||
assert per_zone[0]["zone_width_px"] == layout_css["widths_px"][0]
|
||||
assert per_zone[1]["zone_width_px"] == layout_css["widths_px"][1]
|
||||
|
||||
|
||||
def test_heights_px_length_mismatch_raises():
|
||||
layout_css = {
|
||||
"areas": '"top" "bottom"',
|
||||
"heights_px": [300], # wrong length, expected 2
|
||||
"widths_px": [SLIDE_BODY_WIDTH],
|
||||
}
|
||||
with pytest.raises(ValueError, match="heights_px length"):
|
||||
_compute_per_zone_geometry(
|
||||
layout_css, [{"position": "top"}], GRID_GAP
|
||||
)
|
||||
|
||||
|
||||
def test_widths_px_length_mismatch_raises():
|
||||
layout_css = {
|
||||
"areas": '"left right"',
|
||||
"heights_px": [SLIDE_BODY_HEIGHT],
|
||||
"widths_px": [600], # wrong length, expected 2
|
||||
}
|
||||
with pytest.raises(ValueError, match="widths_px length"):
|
||||
_compute_per_zone_geometry(
|
||||
layout_css, [{"position": "left"}], GRID_GAP
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_position_raises():
|
||||
zones = [_zone("top", 0.5), _zone("bottom", 0.5)]
|
||||
layout_css = build_layout_css("horizontal-2", zones)
|
||||
debug_zones = [{"position": "ghost"}]
|
||||
with pytest.raises(ValueError, match="not present in css_areas"):
|
||||
_compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
|
||||
def test_fr_default_single_returns_full_body():
|
||||
# 'single' is the fr_default sink in PR 1; widths_px / heights_px
|
||||
# must still be populated (length 1 each).
|
||||
layout_css = build_layout_css("single", [_zone("primary", 1.0)])
|
||||
debug_zones = [{"position": "primary"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
@@ -0,0 +1,76 @@
|
||||
"""IMP-09 PR 1 — compute_zone_layout_cols tests.
|
||||
|
||||
Column-axis weight-only solver. Mirrors compute_zone_layout for rows.
|
||||
No min_width_px contract exists in frame_contracts.yaml (verified
|
||||
during Stage 2), so column distribution is purely content_weight.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_WIDTH,
|
||||
compute_zone_layout_cols,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
}
|
||||
|
||||
|
||||
def test_empty_zones_returns_empty_result():
|
||||
result = compute_zone_layout_cols([])
|
||||
assert result["widths_px"] == []
|
||||
assert result["width_ratios"] == []
|
||||
|
||||
|
||||
def test_two_equal_zones_split_evenly():
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP # one gap between two zones
|
||||
assert sum(result["widths_px"]) == available
|
||||
assert result["widths_px"][0] == result["widths_px"][1]
|
||||
assert result["computation"] == "content_weight_distribution_cols"
|
||||
|
||||
|
||||
def test_asymmetric_weights_distribute_by_ratio():
|
||||
zones = [_zone("left", 0.8), _zone("right", 0.2)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
# left should be ~4x right
|
||||
assert result["widths_px"][0] > result["widths_px"][1] * 3
|
||||
|
||||
|
||||
def test_zero_weight_guard_equal_split():
|
||||
zones = [_zone("left", 0.0), _zone("right", 0.0)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
assert result["widths_px"][0] == result["widths_px"][1]
|
||||
# weight_shares fallback to equal share.
|
||||
assert result["weight_shares"] == [0.5, 0.5]
|
||||
|
||||
|
||||
def test_integer_rounding_absorbed_by_last_zone():
|
||||
# Three zones with weights that don't divide evenly.
|
||||
zones = [
|
||||
_zone("a", 0.333333),
|
||||
_zone("b", 0.333333),
|
||||
_zone("c", 0.333334),
|
||||
]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - 2 * GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
|
||||
|
||||
def test_width_ratios_match_total_width():
|
||||
zones = [_zone("left", 0.6), _zone("right", 0.4)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
# width_ratios should be widths_px / SLIDE_BODY_WIDTH (not / available)
|
||||
assert abs(
|
||||
result["width_ratios"][0] - result["widths_px"][0] / SLIDE_BODY_WIDTH
|
||||
) < 1e-3
|
||||
@@ -0,0 +1,81 @@
|
||||
"""IMP-15 실행-4 (Gitea issue #48) — debug.json top-level event surfacing.
|
||||
|
||||
Verifies ``write_debug_json`` lifts ``image_events`` + ``table_events`` out of
|
||||
``visual_runtime_check`` and exposes them as top-level keys, mirroring the
|
||||
existing ``zone_geometries_px`` precedent (src/phase_z2_pipeline.py:2739).
|
||||
|
||||
Two scenarios:
|
||||
|
||||
* Populated — ``visual_runtime_check`` carries non-empty event lists; the
|
||||
written debug dict surfaces both at the top level with identical payloads.
|
||||
* None — ``visual_runtime_check is None``; both top-level keys default to ``[]``
|
||||
(no KeyError, no propagated None).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_pipeline import write_debug_json
|
||||
|
||||
|
||||
def _read_debug(run_dir: Path) -> dict:
|
||||
return json.loads((run_dir / "debug.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_write_debug_json_surfaces_image_and_table_events(tmp_path: Path) -> None:
|
||||
image_events = [
|
||||
{
|
||||
"src": "img/a.png",
|
||||
"zone_position": "primary",
|
||||
"zone_template_id": "tid-1",
|
||||
"natural_w": 200,
|
||||
"natural_h": 100,
|
||||
"rendered_w": 200,
|
||||
"rendered_h": 200,
|
||||
"delta": 1.0,
|
||||
}
|
||||
]
|
||||
table_events = [
|
||||
{
|
||||
"zone_position": "secondary",
|
||||
"zone_template_id": "tid-2",
|
||||
"clientWidth": 300,
|
||||
"scrollWidth": 360,
|
||||
"excess_x": 60,
|
||||
"wrapper_clipped_index": 0,
|
||||
}
|
||||
]
|
||||
visual_runtime_check = {
|
||||
"image_events": image_events,
|
||||
"table_events": table_events,
|
||||
"zone_geometries_px": [],
|
||||
}
|
||||
|
||||
write_debug_json(
|
||||
run_dir=tmp_path,
|
||||
layout_preset="single",
|
||||
debug_zones=[],
|
||||
layout_css={},
|
||||
visual_runtime_check=visual_runtime_check,
|
||||
)
|
||||
|
||||
debug = _read_debug(tmp_path)
|
||||
assert "image_events" in debug, "image_events must be a top-level key"
|
||||
assert "table_events" in debug, "table_events must be a top-level key"
|
||||
assert debug["image_events"] == image_events
|
||||
assert debug["table_events"] == table_events
|
||||
|
||||
|
||||
def test_write_debug_json_defaults_when_visual_runtime_check_none(tmp_path: Path) -> None:
|
||||
write_debug_json(
|
||||
run_dir=tmp_path,
|
||||
layout_preset="single",
|
||||
debug_zones=[],
|
||||
layout_css={},
|
||||
visual_runtime_check=None,
|
||||
)
|
||||
|
||||
debug = _read_debug(tmp_path)
|
||||
assert debug["image_events"] == []
|
||||
assert debug["table_events"] == []
|
||||
@@ -0,0 +1,100 @@
|
||||
"""IMP-09 PR 1 — fixture-driven regression checks.
|
||||
|
||||
Loads the YAML snapshots under tests/phase_z2/fixtures/ and exercises
|
||||
build_layout_css + _attempt_zone_ratio_retry against them. Any drift
|
||||
in IMP-09 output forces a fixture refresh, which is the lock surface
|
||||
called out in Stage 3 round 4 §5.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from src.phase_z2_pipeline import _attempt_zone_ratio_retry, build_layout_css
|
||||
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
# ──────────────────────── build_layout_css fixtures ────────────────────────
|
||||
|
||||
|
||||
_BUILD_DIR = FIXTURES_DIR / "build_layout_css"
|
||||
_BUILD_FIXTURES = sorted(_BUILD_DIR.glob("*.yaml")) if _BUILD_DIR.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture_path",
|
||||
_BUILD_FIXTURES,
|
||||
ids=[p.stem for p in _BUILD_FIXTURES],
|
||||
)
|
||||
def test_build_layout_css_matches_fixture(fixture_path: Path):
|
||||
payload = _load_yaml(fixture_path)
|
||||
inp = payload["input"]
|
||||
expected = payload["expected_layout_css"]
|
||||
|
||||
result = build_layout_css(
|
||||
inp["layout_preset"],
|
||||
inp["zones_data"],
|
||||
override_zone_geometries=inp.get("override_zone_geometries"),
|
||||
)
|
||||
# raw_zone_layout is intentionally not snapshotted (contains
|
||||
# solver internals); compare the rest.
|
||||
actual = {k: v for k, v in result.items() if k != "raw_zone_layout"}
|
||||
assert actual == expected, (
|
||||
f"layout_css drift in fixture {fixture_path.name}:\n"
|
||||
f" expected={expected}\n actual={actual}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────── retry_gate fixtures ──────────────────────────
|
||||
|
||||
|
||||
_RETRY_DIR = FIXTURES_DIR / "retry_gate"
|
||||
_RETRY_FIXTURES = sorted(_RETRY_DIR.glob("*.yaml")) if _RETRY_DIR.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture_path",
|
||||
_RETRY_FIXTURES,
|
||||
ids=[p.stem for p in _RETRY_FIXTURES],
|
||||
)
|
||||
def test_retry_gate_matches_fixture(fixture_path: Path, tmp_path: Path):
|
||||
payload = _load_yaml(fixture_path)
|
||||
layout_css = payload["input_layout_css"]
|
||||
router_decision = payload["router_decision"]
|
||||
expected = payload["expected_gate"]
|
||||
|
||||
trace = _attempt_zone_ratio_retry(
|
||||
run_dir=tmp_path,
|
||||
out_path=tmp_path / "final.html",
|
||||
slide_title="fixture",
|
||||
slide_footer=None,
|
||||
zones_data=[],
|
||||
debug_zones=[],
|
||||
layout_preset="fixture",
|
||||
layout_css=layout_css,
|
||||
overflow={},
|
||||
fit_classification={},
|
||||
router_decision=router_decision,
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
assert trace["retry_attempted"] == expected["retry_attempted"]
|
||||
skip_reason = trace.get("retry_skipped_reason")
|
||||
for needle in expected.get("retry_skipped_reason_contains", []):
|
||||
assert skip_reason is not None and needle in skip_reason, (
|
||||
f"expected {needle!r} in retry_skipped_reason, got {skip_reason!r}"
|
||||
)
|
||||
for forbidden in expected.get("retry_skipped_reason_excludes", []):
|
||||
if skip_reason is not None:
|
||||
assert forbidden not in skip_reason, (
|
||||
f"forbidden {forbidden!r} found in retry_skipped_reason {skip_reason!r}"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""IMP-09 PR 1 — _parse_css_areas strict validation tests.
|
||||
|
||||
Covers the four ValueError cases declared in the Stage 3 round 4 lock
|
||||
(plan §2-D): empty input, no quoted rows, empty row tokens, and
|
||||
non-rectangular grids. Also exercises positive parsing on all 8
|
||||
catalog presets so any future catalog drift in row/col counts is
|
||||
caught here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _parse_css_areas
|
||||
|
||||
|
||||
def test_parse_empty_string_raises():
|
||||
with pytest.raises(ValueError, match="no quoted row strings"):
|
||||
_parse_css_areas("")
|
||||
|
||||
|
||||
def test_parse_no_quotes_raises():
|
||||
with pytest.raises(ValueError, match="no quoted row strings"):
|
||||
_parse_css_areas("top top bottom-left bottom-right")
|
||||
|
||||
|
||||
def test_parse_empty_row_raises():
|
||||
# Whitespace-only quoted row -> tokens list is empty.
|
||||
with pytest.raises(ValueError, match="empty row"):
|
||||
_parse_css_areas('" "')
|
||||
|
||||
|
||||
def test_parse_non_rectangular_raises():
|
||||
# First row has 1 token, second row has 2 tokens.
|
||||
with pytest.raises(ValueError, match="non-rectangular"):
|
||||
_parse_css_areas('"top" "bottom-left bottom-right"')
|
||||
|
||||
|
||||
def test_parse_single_zone():
|
||||
rows, seen = _parse_css_areas('"primary"')
|
||||
assert rows == [["primary"]]
|
||||
assert seen == ["primary"]
|
||||
|
||||
|
||||
def test_parse_horizontal_2():
|
||||
rows, seen = _parse_css_areas('"top" "bottom"')
|
||||
assert rows == [["top"], ["bottom"]]
|
||||
assert seen == ["top", "bottom"]
|
||||
|
||||
|
||||
def test_parse_vertical_2():
|
||||
rows, seen = _parse_css_areas('"left right"')
|
||||
assert rows == [["left", "right"]]
|
||||
assert seen == ["left", "right"]
|
||||
|
||||
|
||||
def test_parse_top_1_bottom_2_span():
|
||||
rows, seen = _parse_css_areas('"top top" "bottom-left bottom-right"')
|
||||
assert rows == [["top", "top"], ["bottom-left", "bottom-right"]]
|
||||
# 'top' should appear once in seen even though it occupies two cells.
|
||||
assert seen == ["top", "bottom-left", "bottom-right"]
|
||||
|
||||
|
||||
def test_parse_grid_2x2_four_zones():
|
||||
rows, seen = _parse_css_areas(
|
||||
'"top-left top-right" "bottom-left bottom-right"'
|
||||
)
|
||||
assert rows == [
|
||||
["top-left", "top-right"],
|
||||
["bottom-left", "bottom-right"],
|
||||
]
|
||||
assert seen == ["top-left", "top-right", "bottom-left", "bottom-right"]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""IMP-09 PR 1 — _parse_fr_string tests.
|
||||
|
||||
Catalog presets only use `1fr` / `1fr 1fr` specs (verified
|
||||
templates/phase_z2/layouts/layouts.yaml). The helper must reject
|
||||
non-fr tokens and round to integer pixel sizes summing to `total`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _parse_fr_string
|
||||
|
||||
|
||||
def test_single_fr_returns_full_total():
|
||||
assert _parse_fr_string("1fr", 585) == [585]
|
||||
|
||||
|
||||
def test_two_equal_fr_splits_evenly():
|
||||
result = _parse_fr_string("1fr 1fr", 1180)
|
||||
assert result == [590, 590]
|
||||
assert sum(result) == 1180
|
||||
|
||||
|
||||
def test_unequal_fr_distributes_by_ratio():
|
||||
result = _parse_fr_string("2fr 1fr", 300)
|
||||
assert sum(result) == 300
|
||||
assert result[0] > result[1]
|
||||
|
||||
|
||||
def test_rounding_absorbed_by_last_track():
|
||||
# 1fr 1fr 1fr / total=100 -> 33,33,33 + diff 1 absorbed by last.
|
||||
result = _parse_fr_string("1fr 1fr 1fr", 100)
|
||||
assert sum(result) == 100
|
||||
assert result == [33, 33, 34]
|
||||
|
||||
|
||||
def test_non_fr_token_raises():
|
||||
with pytest.raises(ValueError, match="non-fr token"):
|
||||
_parse_fr_string("200px 1fr", 1000)
|
||||
|
||||
|
||||
def test_empty_spec_raises():
|
||||
with pytest.raises(ValueError, match="empty spec"):
|
||||
_parse_fr_string("", 1000)
|
||||
|
||||
|
||||
def test_zero_fr_raises():
|
||||
with pytest.raises(ValueError, match="total fr"):
|
||||
_parse_fr_string("0fr 0fr", 1000)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""IMP-12 u11 — plan_cross_zone_redistribute tests.
|
||||
|
||||
Stage 2 contract (unit u11):
|
||||
- multi-role zone feasible (deficit role + surplus role in the same zone)
|
||||
- single-role zone infeasible reason (no peer to donate surplus)
|
||||
|
||||
u4 wraps fit_verifier.redistribute() in the Step-17 plan signature; feasibility
|
||||
depends on whether deficit roles can be covered by surplus roles within the
|
||||
same container.zone group (see src/fit_verifier.py:496-590). The plan exposes
|
||||
role_heights_before / role_heights_after and surfaces the
|
||||
'can_redistribute=False — single-role zone(s)' substring when redistribution
|
||||
is impossible. The apply helper must scope output to [data-role=...] only
|
||||
(feedback_phase_z_spacing_direction — no :root / body / .slide / .zone).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
from src.phase_z2_retry import (
|
||||
apply_cross_zone_redistribute_css,
|
||||
plan_cross_zone_redistribute,
|
||||
)
|
||||
|
||||
|
||||
def _fit(roles: dict[str, tuple[float, float]]) -> FitAnalysis:
|
||||
"""roles dict = {role: (allocated_px, shortfall_px)}.
|
||||
|
||||
Sign convention matches fit_verifier.redistribute: shortfall_px > 0 = deficit,
|
||||
shortfall_px < 0 = surplus (usable = abs(shortfall) - min_margin_px).
|
||||
"""
|
||||
return FitAnalysis(
|
||||
roles={
|
||||
name: RoleFit(role=name, allocated_px=alloc, shortfall_px=short)
|
||||
for name, (alloc, short) in roles.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_multi_role_zone_feasible():
|
||||
"""Two roles in the same zone — deficit covered by surplus → feasible."""
|
||||
fit = _fit({"top": (200.0, 30.0), "bottom_l": (300.0, -50.0)})
|
||||
containers = {
|
||||
"top": {"zone": "slide_body", "height_px": 200},
|
||||
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||
}
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["action"] == "cross_zone_redistribute"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["can_redistribute"] is True
|
||||
assert plan["role_heights_before"] == {"top": 200.0, "bottom_l": 300.0}
|
||||
after = plan["role_heights_after"]
|
||||
# deficit (30) shifts top up, surplus (50-margin=40) shifts bottom_l down by 30.
|
||||
assert after["top"] > 200.0
|
||||
assert after["bottom_l"] < 300.0
|
||||
assert abs((after["top"] - 200.0) - (300.0 - after["bottom_l"])) < 1.0
|
||||
css = apply_cross_zone_redistribute_css(plan)
|
||||
assert '[data-role="top"]' in css
|
||||
assert '[data-role="bottom_l"]' in css
|
||||
# Scope lock — no global rules emitted.
|
||||
for forbidden in (":root", "body", ".slide", ".zone"):
|
||||
assert forbidden not in css
|
||||
|
||||
|
||||
def test_single_role_zone_infeasible_reason():
|
||||
"""Lone role in a zone has no peer to donate surplus → infeasible."""
|
||||
fit = _fit({"top": (200.0, 30.0)})
|
||||
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["can_redistribute"] is False
|
||||
reason = plan["failure_reason"]
|
||||
assert "single-role zone" in reason
|
||||
assert "can_redistribute=False" in reason
|
||||
# apply emits nothing when infeasible.
|
||||
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||
|
||||
|
||||
def test_empty_fit_analysis_infeasible():
|
||||
"""No roles at all → defensive infeasible (cannot redistribute nothing)."""
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=FitAnalysis(roles={}), containers={}, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["role_heights_before"] == {}
|
||||
assert "no roles" in plan["failure_reason"]
|
||||
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||
@@ -0,0 +1,119 @@
|
||||
"""IMP-12 u14 — failure_router cascade tests.
|
||||
|
||||
Stage 2 contract (unit u14):
|
||||
- donor_slack_insufficient → cross_zone_redistribute (impl=IMPLEMENTED)
|
||||
- 3 new failure types (cross_zone_redistribute_insufficient,
|
||||
glue_absorption_insufficient, font_step_insufficient) all route to
|
||||
expected next actions per the locked NEXT_ACTION_BY_FAILURE table
|
||||
- rerender_still_fails preserved → frame_reselect
|
||||
|
||||
u2 (classifier) inspects retry_trace["salvage_steps"][-1] for the 3 new
|
||||
salvage failure types via SALVAGE_FAILURE_TYPE_BY_ACTION; u3 wires those
|
||||
failure types onto the deterministic cascade in NEXT_ACTION_BY_FAILURE.
|
||||
u7 records the cascade actions as IMPLEMENTED in NEXT_ACTION_IMPLEMENTATION_STATUS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_failure_router import (
|
||||
NEXT_ACTION_BY_FAILURE,
|
||||
NEXT_ACTION_IMPLEMENTATION_STATUS,
|
||||
classify_retry_failure,
|
||||
enrich_retry_trace_with_failure_classification,
|
||||
route_retry_failure,
|
||||
)
|
||||
|
||||
|
||||
def test_donor_slack_insufficient_routes_to_cross_zone_redistribute_implemented():
|
||||
"""Stage 1 root cause — primary donor slack insufficient classifies as
|
||||
donor_slack_insufficient and routes onto the deterministic salvage cascade
|
||||
starting with cross_zone_redistribute (IMPLEMENTED per u7)."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {
|
||||
"feasible": False,
|
||||
"failure_reason": (
|
||||
"primary donor 'bottom' slack 15px (aggregate 25px from 2 donor(s)) "
|
||||
"< target_added_px 70px"
|
||||
),
|
||||
},
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None
|
||||
assert fc["failure_type"] == "donor_slack_insufficient"
|
||||
|
||||
nr = route_retry_failure("donor_slack_insufficient")
|
||||
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||
|
||||
# enrichment composes both fields onto the trace
|
||||
enrich_retry_trace_with_failure_classification(trace)
|
||||
assert trace["failure_classification"]["failure_type"] == "donor_slack_insufficient"
|
||||
assert trace["next_action_proposal"]["next_proposed_action"] == "cross_zone_redistribute"
|
||||
|
||||
|
||||
def test_no_donor_candidates_routes_to_cross_zone_redistribute_implemented():
|
||||
"""no_donor_candidates is the second cascade entry — also onto
|
||||
cross_zone_redistribute per the locked mapping."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {"feasible": False, "failure_reason": "no donor candidates"},
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc["failure_type"] == "no_donor_candidates"
|
||||
nr = route_retry_failure("no_donor_candidates")
|
||||
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
|
||||
"""u2 classifier inspects salvage_steps[-1]. u3 routes the 3 new failure
|
||||
types through the deterministic cascade: cross_zone → glue → font_step →
|
||||
layout_adjust. Verifies the locked NEXT_ACTION_BY_FAILURE table directly
|
||||
and via the classifier path."""
|
||||
# Direct mapping (u3 lock)
|
||||
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
|
||||
|
||||
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
|
||||
|
||||
# Classifier path via salvage_steps[-1].action → failure_type → next action
|
||||
cases = [
|
||||
("cross_zone_redistribute", "cross_zone_redistribute_insufficient", "glue_compression"),
|
||||
("glue_compression", "glue_absorption_insufficient", "font_step_compression"),
|
||||
("font_step_compression", "font_step_insufficient", "layout_adjust"),
|
||||
]
|
||||
for action, expected_ftype, expected_next in cases:
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"salvage_passed": False,
|
||||
"salvage_steps": [
|
||||
{"action": action, "passed": False, "failure_reason": "salvage failed"}
|
||||
],
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None, f"classifier returned None for action={action}"
|
||||
assert fc["failure_type"] == expected_ftype
|
||||
nr = route_retry_failure(fc["failure_type"])
|
||||
assert nr["next_proposed_action"] == expected_next
|
||||
|
||||
|
||||
def test_rerender_still_fails_preserved_routes_to_frame_reselect():
|
||||
"""Pre-cascade behavior preserved: when plan was feasible and rerender ran
|
||||
but visual still failed, classifier emits rerender_still_fails → frame_reselect."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {"feasible": True},
|
||||
"rerender_attempted": True,
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc["failure_type"] == "rerender_still_fails"
|
||||
nr = route_retry_failure("rerender_still_fails")
|
||||
assert nr["next_proposed_action"] == "frame_reselect"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""IMP-12 u13 — plan_font_step_compression tests.
|
||||
|
||||
Stage 2 contract (unit u13):
|
||||
- feasible case (15.2 → 13 closes excess)
|
||||
- infeasible (8px floor — FONT_SIZE_STEPS exhausted)
|
||||
- text_metrics missing → defensive infeasible reason
|
||||
|
||||
u6 wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
||||
Height savings per candidate font_size (Korean 1.6 line-height):
|
||||
height_saved = (current_font_px * 1.6 - font_size * 1.6) * available_lines
|
||||
|
||||
Scope lock per feedback_phase_z_spacing_direction:
|
||||
- apply_font_step_compression_css emits ONLY [data-zone-position="<pos>"] rule.
|
||||
- No :root / body / .slide / .zone selectors permitted.
|
||||
"""
|
||||
|
||||
from src.phase_z2_retry import (
|
||||
apply_font_step_compression_css,
|
||||
plan_font_step_compression,
|
||||
)
|
||||
|
||||
|
||||
def test_feasible_15_2_to_13_closes_excess() -> None:
|
||||
"""current=15.2, excess=20, lines=10 → 14.0 saves 19.2 (insufficient);
|
||||
13.0 saves 35.2 (>=20) → target_font_px=13.0. Emitted CSS scope-locked."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=20.0,
|
||||
available_lines=10, chars_per_line=40, zone_position="bottom_l",
|
||||
)
|
||||
assert plan["action"] == "font_step_compression"
|
||||
assert plan["zone_position"] == "bottom_l"
|
||||
assert plan["current_font_px"] == 15.2
|
||||
assert plan["excess_after_glue_px"] == 20.0
|
||||
assert plan["available_lines"] == 10
|
||||
assert plan["chars_per_line"] == 40
|
||||
assert plan["font_floor_px"] == 8.0
|
||||
assert plan["feasible"] is True
|
||||
assert plan["target_font_px"] == 13.0
|
||||
assert "failure_reason" not in plan
|
||||
|
||||
css = apply_font_step_compression_css(plan)
|
||||
assert '[data-zone-position="bottom_l"]' in css
|
||||
assert "font-size: 13.0px" in css
|
||||
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||
assert forbidden not in css, f"scope-lock violation: {forbidden!r} in css"
|
||||
|
||||
|
||||
def test_infeasible_font_floor_exhausted() -> None:
|
||||
"""current=15.2, excess=200, lines=10 — even 8.0px floor saves only 115.2,
|
||||
so FONT_SIZE_STEPS is exhausted → feasible=False, classifier-matching reason."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=200.0,
|
||||
available_lines=10, chars_per_line=40, zone_position="top",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["target_font_px"] is None
|
||||
assert plan["font_floor_px"] == 8.0
|
||||
reason = plan["failure_reason"]
|
||||
assert "font_step floor" in reason
|
||||
assert "8.0px" in reason
|
||||
assert "200.0px" in reason
|
||||
assert "FONT_SIZE_STEPS exhausted" in reason
|
||||
assert apply_font_step_compression_css(plan) == ""
|
||||
|
||||
|
||||
def test_text_metrics_missing_defensive_infeasible() -> None:
|
||||
"""available_lines=0 → guard fires before find_fitting_font_size;
|
||||
failure_reason carries the text_metrics missing substring (classifier-friendly)."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=40.0,
|
||||
available_lines=0, chars_per_line=40, zone_position="bottom_r",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["target_font_px"] is None
|
||||
assert "text_metrics missing" in plan["failure_reason"]
|
||||
assert "available_lines/chars_per_line required" in plan["failure_reason"]
|
||||
assert apply_font_step_compression_css(plan) == ""
|
||||
@@ -0,0 +1,87 @@
|
||||
"""IMP-12 u12 — plan_glue_compression tests.
|
||||
|
||||
Stage 2 contract (unit u12):
|
||||
- feasible case asserts emitted CSS contains [data-zone-position=...]
|
||||
selector and NO global :root / body / .slide rule (scope lock)
|
||||
- insufficient case feasible=False with envelope reason
|
||||
|
||||
u5 wraps space_allocator.calculate_glue_absorption + compute_glue_css_overrides
|
||||
in the Step-17 plan signature. Glue envelope per block_count (SPACING_GLUE):
|
||||
absorption_max = block_gap.shrink * (block_count-1) # 12 * (n-1)
|
||||
+ inner_gap.shrink * block_count # 8 * n
|
||||
+ title_gap.shrink * block_count # 4 * n
|
||||
+ container_padding.shrink * 2 # 8 * 2
|
||||
|
||||
block_count=3 → 12*2 + 8*3 + 4*3 + 8*2 = 24+24+12+16 = 76 px
|
||||
block_count=1 → 12*0 + 8*1 + 4*1 + 8*2 = 0+8+4+16 = 28 px
|
||||
|
||||
CSS must be wrapped under [data-zone-position="<pos>"] only
|
||||
(feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import (
|
||||
apply_glue_compression_css,
|
||||
plan_glue_compression,
|
||||
)
|
||||
|
||||
|
||||
def test_feasible_case_emits_zone_scoped_css():
|
||||
"""excess (40px) <= absorption_max (76px @ block_count=3) → feasible.
|
||||
|
||||
Emitted CSS must wrap overrides in [data-zone-position=...] selector and
|
||||
contain none of the global selectors banned by feedback_phase_z_spacing_direction.
|
||||
"""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=40.0, block_count=3, zone_position="bottom_l",
|
||||
)
|
||||
assert plan["action"] == "glue_compression"
|
||||
assert plan["zone_position"] == "bottom_l"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["excess_px"] == 40.0
|
||||
assert plan["block_count"] == 3
|
||||
assert plan["absorption_max_px"] == 76.0
|
||||
overrides = plan["overrides"]
|
||||
assert overrides, "feasible plan must return non-empty overrides"
|
||||
for key in ("--spacing-block", "--spacing-inner", "--container-padding"):
|
||||
assert key in overrides, f"missing override key {key}"
|
||||
|
||||
css = apply_glue_compression_css(plan)
|
||||
assert '[data-zone-position="bottom_l"]' in css
|
||||
assert "--spacing-block:" in css
|
||||
assert "--spacing-inner:" in css
|
||||
assert "--container-padding:" in css
|
||||
# Scope lock — no global rules permitted.
|
||||
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||
assert forbidden not in css, f"forbidden selector {forbidden!r} leaked into glue CSS"
|
||||
|
||||
|
||||
def test_insufficient_envelope_feasible_false_with_reason():
|
||||
"""excess (80px) > absorption_max (28px @ block_count=1) → infeasible.
|
||||
|
||||
failure_reason must surface the envelope shortage so the cascade router
|
||||
(NEXT_ACTION_BY_FAILURE) can route onward to font_step_compression.
|
||||
"""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=80.0, block_count=1, zone_position="top",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["absorption_max_px"] == 28.0
|
||||
reason = plan["failure_reason"]
|
||||
assert "glue envelope insufficient" in reason
|
||||
assert "excess_px 80" in reason
|
||||
assert "max absorption 28" in reason
|
||||
# apply emits nothing when infeasible — no accidental CSS mutation on revert path.
|
||||
assert apply_glue_compression_css(plan) == ""
|
||||
|
||||
|
||||
def test_excess_non_positive_no_compression_needed():
|
||||
"""excess_px <= 0 → defensive infeasible (no compression required)."""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=0.0, block_count=3, zone_position="bottom_r",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["overrides"] == {}
|
||||
assert plan["absorption_max_px"] == 0.0
|
||||
assert "no compression needed" in plan["failure_reason"]
|
||||
assert apply_glue_compression_css(plan) == ""
|
||||
@@ -0,0 +1,147 @@
|
||||
"""IMP-12 u10 — plan_zone_ratio_retry multi-donor aggregation tests.
|
||||
|
||||
Stage 2 contract (unit u10):
|
||||
- single-donor sufficient (regression — backward compat preserved)
|
||||
- single insufficient + 2nd sufficient (multi-donor PASS path)
|
||||
- aggregate insufficient (multi-donor FAIL path)
|
||||
|
||||
u1 extended plan_zone_ratio_retry from a single primary donor to greedy
|
||||
slack-desc aggregation across all eligible sibling zones. The plan dict
|
||||
now carries donors_used / aggregate_slack_used / aggregate_slack_available
|
||||
while preserving donor_zone_position + donor_reduced_px for the failure
|
||||
classifier substrings (router still keys off primary donor name).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import plan_zone_ratio_retry
|
||||
|
||||
|
||||
_ROUTER_ACTIVE = {"router_active": True}
|
||||
|
||||
|
||||
def _classification(target_pos: str, excess_y: float) -> dict:
|
||||
return {
|
||||
"classifications": [
|
||||
{
|
||||
"proposed_action": "zone_ratio_retry",
|
||||
"zone_position": target_pos,
|
||||
"inputs": {"excess_y": excess_y},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _zone(position: str, height_px: int, min_height_px: int,
|
||||
fit_status: str | None = "ok") -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"height_px": height_px,
|
||||
"min_height_px": min_height_px,
|
||||
"composition_rationale": {
|
||||
"capacity_fit": {"fit_status": fit_status},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _overflow_clean(donor_positions: list[str]) -> dict:
|
||||
return {
|
||||
"zones": [
|
||||
{"position": p, "overflowed": False, "clipped_inner": False}
|
||||
for p in donor_positions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_single_donor_sufficient_regression():
|
||||
"""One donor with abundant slack. Plan must remain feasible and the
|
||||
legacy donor_zone_position / donor_reduced_px fields must reflect the
|
||||
primary donor (router classifier substring stability)."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=200, min_height_px=180),
|
||||
_zone("bottom", height_px=400, min_height_px=200), # slack=200
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["bottom"]),
|
||||
fit_classification=_classification("top", excess_y=20.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan["feasible"] is True
|
||||
# target_added_px = ceil(20) + DEFAULT_SAFETY_MARGIN_PX(4) = 24
|
||||
assert plan["target_added_px"] == 24
|
||||
assert plan["donor_zone_position"] == "bottom"
|
||||
assert plan["donor_reduced_px"] == 24
|
||||
assert plan["donors_used"] == [
|
||||
{"position": "bottom", "reduced_px": 24,
|
||||
"slack_before": 200, "slack_after": 176}
|
||||
]
|
||||
assert plan["aggregate_slack_used"] == 24
|
||||
assert plan["aggregate_slack_available"] == 200
|
||||
assert plan["zones_after"]["top"] == 224
|
||||
assert plan["zones_after"]["bottom"] == 376
|
||||
|
||||
|
||||
def test_multi_donor_pass_primary_insufficient_secondary_covers():
|
||||
"""Primary donor alone has insufficient slack but primary + secondary
|
||||
aggregate covers target_added_px. Multi-donor greedy aggregation must
|
||||
split the deficit across both donors in slack-desc order."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=250, min_height_px=200), # slack=50
|
||||
_zone("bottom", height_px=240, min_height_px=200), # slack=40
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["middle", "bottom"]),
|
||||
fit_classification=_classification("top", excess_y=66.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
# target_added_px = ceil(66)+4 = 70. Primary (middle, slack=50) alone
|
||||
# cannot cover, but middle(50)+bottom(40)=90 >= 70.
|
||||
assert plan["feasible"] is True
|
||||
assert plan["target_added_px"] == 70
|
||||
assert plan["aggregate_slack_available"] == 90
|
||||
assert plan["aggregate_slack_used"] == 70
|
||||
assert plan["donor_zone_position"] == "middle" # primary
|
||||
assert plan["donor_reduced_px"] == 50 # primary takes its full slack
|
||||
assert [d["position"] for d in plan["donors_used"]] == ["middle", "bottom"]
|
||||
assert plan["donors_used"][0]["reduced_px"] == 50
|
||||
assert plan["donors_used"][1]["reduced_px"] == 20 # remainder
|
||||
assert plan["zones_after"]["top"] == 370
|
||||
assert plan["zones_after"]["middle"] == 200
|
||||
assert plan["zones_after"]["bottom"] == 220
|
||||
|
||||
|
||||
def test_multi_donor_fail_aggregate_insufficient():
|
||||
"""All donors combined still cannot cover target_added_px. Plan must
|
||||
be feasible=False with primary-donor substring preserved so the
|
||||
failure_router classifier still routes through donor_slack_insufficient."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=210, min_height_px=200), # slack=10
|
||||
_zone("bottom", height_px=215, min_height_px=200), # slack=15
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["middle", "bottom"]),
|
||||
fit_classification=_classification("top", excess_y=66.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
# target_added_px=70, aggregate=25 → fail
|
||||
assert plan["feasible"] is False
|
||||
assert plan["aggregate_slack_available"] == 25
|
||||
assert plan["aggregate_slack_used"] == 0
|
||||
assert plan["donors_used"] == []
|
||||
# Primary = highest-slack donor = bottom (15)
|
||||
assert plan["donor_zone_position"] == "bottom"
|
||||
assert plan["donor_max_slack"] == 15
|
||||
# Classifier substring stability: "donor", "slack", and "<" still present
|
||||
reason = plan["failure_reason"]
|
||||
assert "donor" in reason
|
||||
assert "slack" in reason
|
||||
assert "<" in reason
|
||||
# zones unchanged on fail (revert-friendly)
|
||||
assert plan["zones_after"]["top"] == 300
|
||||
assert plan["zones_after"]["middle"] == 210
|
||||
assert plan["zones_after"]["bottom"] == 215
|
||||
@@ -0,0 +1,196 @@
|
||||
"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection.
|
||||
|
||||
Tests Selenium-driven `<img>` aspect measurement added to ``run_overflow_check``:
|
||||
|
||||
* Fixture A — 200×100 image rendered at 200×100 → ``abs(delta) < tol``, no fail
|
||||
reason, ``passed=True``.
|
||||
* Fixture B — 200×100 image forced to render 200×200 → ``abs(delta) > 0.30``,
|
||||
fail reason includes ``image aspect mismatch in zone--primary:``,
|
||||
``passed=False``.
|
||||
* Fixture C — ``<img>`` with no ``.zone`` ancestor (attached directly under
|
||||
``.slide``) → event reports ``zone_position == "unknown"``.
|
||||
|
||||
Chromedriver resolution mirrors the pipeline's order
|
||||
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH fallback). When no driver is
|
||||
resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the
|
||||
tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
IMAGE_ASPECT_DELTA_TOL,
|
||||
PROJECT_ROOT,
|
||||
run_overflow_check,
|
||||
)
|
||||
|
||||
PIL_Image = pytest.importorskip("PIL.Image", reason="Pillow required for fixture PNGs")
|
||||
|
||||
|
||||
# ─── chromedriver skip / xfail guard ─────────────────────────────────
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
|
||||
|
||||
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
|
||||
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
|
||||
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
|
||||
for driver auto-resolution. The test resolver must mirror that fallback
|
||||
or PHASE_Z_REQUIRE_SELENIUM=1 produces spurious strict-XPASS failures on
|
||||
machines where Selenium Manager can satisfy the pipeline at runtime.
|
||||
"""
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── HTML / PNG fixture helpers ──────────────────────────────────────
|
||||
|
||||
_SLIDE_CSS = """
|
||||
html, body { margin: 0; padding: 0; }
|
||||
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
|
||||
.zone { display: block; }
|
||||
"""
|
||||
|
||||
|
||||
def _write_png(path: Path, width: int, height: int, colour=(120, 160, 200)) -> Path:
|
||||
img = PIL_Image.new("RGB", (width, height), colour)
|
||||
img.save(path, format="PNG")
|
||||
return path
|
||||
|
||||
|
||||
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
f"<style>{_SLIDE_CSS}</style></head><body>"
|
||||
'<div class="slide" data-page="1">'
|
||||
f"{body_inner}"
|
||||
"</div></body></html>"
|
||||
)
|
||||
path = tmp_path / name
|
||||
path.write_text(html, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _find_event(events, src_basename: str) -> dict:
|
||||
for ev in events:
|
||||
if Path(ev.get("src", "")).name == src_basename:
|
||||
return ev
|
||||
raise AssertionError(f"image_events missing entry for {src_basename}; got {events}")
|
||||
|
||||
|
||||
# ─── tests ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_image_no_distortion(tmp_path: Path) -> None:
|
||||
"""Fixture A — 200×100 image rendered at native 200×100. delta ≈ 0."""
|
||||
png = _write_png(tmp_path / "ok.png", 200, 100)
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_ok">'
|
||||
f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
|
||||
"</div>"
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="ok.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
assert result.get("image_events"), "image_events must be populated"
|
||||
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["zone_position"] == "primary"
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 100
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
|
||||
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert image_fails == [], f"unexpected image fail_reasons: {image_fails}"
|
||||
assert result["passed"] is True, result.get("fail_reasons")
|
||||
|
||||
|
||||
def test_image_forced_distortion(tmp_path: Path) -> None:
|
||||
"""Fixture B — 200×100 image forced to 200×200. delta > 0.30, fail emitted."""
|
||||
png = _write_png(tmp_path / "bad.png", 200, 100, colour=(200, 80, 80))
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_bad">'
|
||||
f'<img src="{png.name}" style="width:200px;height:200px;display:block">'
|
||||
"</div>"
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="bad.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 200
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) > 0.30
|
||||
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert len(image_fails) == 1, f"expected one image fail_reason, got: {image_fails}"
|
||||
msg = image_fails[0]
|
||||
assert msg.startswith("image aspect mismatch in zone--primary:"), msg
|
||||
assert "natural=2.000" in msg and "rendered=1.000" in msg
|
||||
assert f"src={png.name}" in msg or png.name in msg
|
||||
assert result["passed"] is False
|
||||
|
||||
|
||||
def test_image_no_zone_ancestor(tmp_path: Path) -> None:
|
||||
"""Fixture C — <img> attached directly under .slide → zone_position == 'unknown'."""
|
||||
png = _write_png(tmp_path / "loose.png", 200, 100, colour=(80, 200, 120))
|
||||
body = f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
|
||||
html_path = _write_slide_html(tmp_path, body, name="loose.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["zone_position"] == "unknown"
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert image_fails == []
|
||||
@@ -0,0 +1,334 @@
|
||||
"""IMP-15 실행-2 (Gitea issue #46) — Step 14 table_self_overflow detection.
|
||||
|
||||
Tests Selenium-driven ``<table>`` self-overflow measurement and element-identity
|
||||
wrapper dedup added to ``run_overflow_check``:
|
||||
|
||||
* Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper ancestor →
|
||||
``table_events`` entry reports ``wrapper_clipped_index = None`` and an
|
||||
``excess_*`` exceeding ``TABLE_SCROLL_TOL_PX``; Python aggregation then emits
|
||||
a ``table self-overflow`` fail_reason and flips ``result["passed"] = False``.
|
||||
* Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper. The wrapper itself
|
||||
self-overflows (registers in ``clippedWrapperMap``) and the inner table also
|
||||
self-overflows. Asserts dedup is honored: the table's ``wrapper_clipped_index``
|
||||
resolves to the wrapper's map index (non-null) so the Python aggregation MUST
|
||||
NOT emit a ``table self-overflow`` fail_reason — only the wrapper's pre-existing
|
||||
``inner clipped`` fail line remains.
|
||||
|
||||
* Fixture F — two wrappers W1 / W2 share identical className ``f13b-cell``. W1
|
||||
contains an overflowing inline-block child (no ``<table>``) → W1 self-overflows
|
||||
and registers in ``clippedWrapperMap`` (emits ``inner clipped``). W2 contains
|
||||
only a self-overflowing ``<table>``; W2's own scrollWidth equals its clientWidth
|
||||
(the table's ``overflow:hidden`` keeps W2 itself uncliped). The element-identity
|
||||
ancestor walk MUST resolve the W2 table's ``wrapper_clipped_index`` to ``None``
|
||||
(W2 ≠ W1 by DOM reference, despite identical class string). A class-string
|
||||
lookup would have falsely resolved the W2 table → W1 and suppressed the fail —
|
||||
the test thereby proves ``Map<Element, int>`` distinguishes by node identity.
|
||||
|
||||
Chromedriver resolution mirrors the pipeline order
|
||||
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH → Selenium Manager). When no driver
|
||||
is resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1``
|
||||
the tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
PROJECT_ROOT,
|
||||
TABLE_SCROLL_TOL_PX,
|
||||
run_overflow_check,
|
||||
)
|
||||
|
||||
|
||||
# ─── chromedriver skip / xfail guard ─────────────────────────────────
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
|
||||
|
||||
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
|
||||
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
|
||||
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
|
||||
for driver auto-resolution. The test resolver must mirror that fallback
|
||||
or ``PHASE_Z_REQUIRE_SELENIUM=1`` produces spurious strict-XPASS failures
|
||||
on machines where Selenium Manager can satisfy the pipeline at runtime.
|
||||
"""
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── HTML fixture helpers ────────────────────────────────────────────
|
||||
|
||||
_SLIDE_CSS = """
|
||||
html, body { margin: 0; padding: 0; }
|
||||
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
|
||||
.zone { display: block; }
|
||||
"""
|
||||
|
||||
|
||||
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
f"<style>{_SLIDE_CSS}</style></head><body>"
|
||||
'<div class="slide" data-page="1">'
|
||||
f"{body_inner}"
|
||||
"</div></body></html>"
|
||||
)
|
||||
path = tmp_path / name
|
||||
path.write_text(html, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ─── tests ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_fixture_d_standalone_table_overflow(tmp_path: Path) -> None:
|
||||
"""Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper.
|
||||
|
||||
The table is forced into block layout with a fixed clientWidth (100px) and
|
||||
``overflow: hidden``; the inner cell is 600px wide with ``white-space:nowrap``,
|
||||
so the table's scrollWidth exceeds clientWidth by well over ``TABLE_SCROLL_TOL_PX``.
|
||||
No ancestor carries an ``f13b/f29b/f16b`` class, so the element-identity walk
|
||||
must report ``wrapper_clipped_index = None``. Python aggregation then emits a
|
||||
``table self-overflow`` fail_reason and flips ``result["passed"]`` to ``False``.
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_table">'
|
||||
'<table style="display:block; width:100px; height:30px; overflow:hidden; '
|
||||
'box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:600px; white-space:nowrap;">'
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_d.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
assert "table_events" in result, "run_overflow_check must expose table_events"
|
||||
table_events = result["table_events"]
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
assert ev["zone_position"] == "primary", ev
|
||||
assert ev["zone_template_id"] == "t_table", ev
|
||||
assert ev["wrapper_clipped_index"] is None, (
|
||||
f"standalone table must have null wrapper_clipped_index; got {ev['wrapper_clipped_index']}"
|
||||
)
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"expected excess_x > {TABLE_SCROLL_TOL_PX}; got {ev['excess_x']} "
|
||||
f"(clientWidth={ev['clientWidth']}, scrollWidth={ev['scrollWidth']})"
|
||||
)
|
||||
|
||||
# Python aggregation: emitted fail_reason + passed flipped to False.
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert len(table_fails) == 1, (
|
||||
f"expected exactly one 'table self-overflow' fail_reason; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert "zone--primary" in table_fails[0], table_fails[0]
|
||||
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
|
||||
assert result["passed"] is False, (
|
||||
f"table self-overflow must flip passed=False; got result={result}"
|
||||
)
|
||||
|
||||
|
||||
def test_fixture_e_table_in_clipped_wrapper_dedup(tmp_path: Path) -> None:
|
||||
"""Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper (dedup honored).
|
||||
|
||||
The wrapper (clientWidth=300, ``overflow:hidden``) contains a ``display:block``
|
||||
table forced to width=500px → wrapper.scrollWidth (≈500) − clientWidth (300) > 5px,
|
||||
so the wrapper is registered in ``clippedWrapperMap`` (emits ``inner clipped`` fail).
|
||||
The inner table is itself self-overflowing (clientWidth=500, content nowrap-cell
|
||||
width=900 → scrollWidth ≈ 900). The element-identity ancestor walk MUST resolve
|
||||
the table's ``wrapper_clipped_index`` to the wrapper's integer map index, and the
|
||||
Python aggregation MUST then SKIP emitting a ``table self-overflow`` fail_reason
|
||||
(the clipped wrapper already accounts for this).
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_table_wrap">'
|
||||
'<div class="f13b-cell" style="width:300px; height:60px; overflow:hidden; '
|
||||
'box-sizing:border-box; position:relative;">'
|
||||
'<table style="display:block; width:500px; height:40px; overflow:hidden; '
|
||||
'box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:900px; white-space:nowrap;">'
|
||||
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_e.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
table_events = result.get("table_events", [])
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
# Dedup signal: ancestor walk must hit the f13b wrapper via Map.has(node).
|
||||
assert ev["wrapper_clipped_index"] is not None, (
|
||||
f"table inside clipped wrapper must inherit wrapper index; got ev={ev}"
|
||||
)
|
||||
assert isinstance(ev["wrapper_clipped_index"], int), ev
|
||||
# The inner table is itself overflowing — proves the dedup is the only thing
|
||||
# suppressing the table_self_overflow fail (not absence of overflow).
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"inner table must be self-overflowing for this test to be meaningful; ev={ev}"
|
||||
)
|
||||
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert table_fails == [], (
|
||||
f"dedup must suppress table self-overflow fail when wrapper is clipped; "
|
||||
f"got table_fails={table_fails} fail_reasons={fail_reasons}"
|
||||
)
|
||||
# Wrapper's clipped_inner fail line must still be present.
|
||||
clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
|
||||
assert len(clipped_fails) >= 1, (
|
||||
f"wrapper clipped_inner fail must remain; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert result["passed"] is False, result
|
||||
|
||||
|
||||
def test_fixture_f_two_same_class_wrappers_element_identity(tmp_path: Path) -> None:
|
||||
"""Fixture F (F1 acceptance) — two same-class wrappers, element-identity dedup.
|
||||
|
||||
W1 and W2 share the identical className ``f13b-cell``. W1 (clientWidth=300,
|
||||
``overflow:hidden``) contains an inline-block ``<div>`` of width 600px →
|
||||
W1.scrollWidth − clientWidth ≈ 300 > 5; W1 is registered in
|
||||
``clippedWrapperMap`` and emits an ``inner clipped`` fail line. W2
|
||||
(clientWidth=600, ``overflow:hidden``) contains a 500px-wide block-display
|
||||
``<table>`` (matching the Fixture E table shape so the table is itself
|
||||
self-overflowing with excess_x > 5). W2's clientWidth (600) is larger than
|
||||
the table's outer width (500), so W2's own scrollWidth ≈ 500 < clientWidth
|
||||
and W2 is NOT registered in ``clippedWrapperMap``.
|
||||
|
||||
The element-identity ancestor walk in the pipeline (L2298–L2304) walks from
|
||||
the W2 table upward via ``parentElement`` and queries
|
||||
``clippedWrapperMap.has(node)`` — keyed by DOM node, NOT className. W2 is
|
||||
a different ``Element`` reference from W1 despite identical class string,
|
||||
so the lookup returns false at W2 and the walk terminates at ``.slide`` with
|
||||
``wrapper_clipped_index = null``. A class-substring keyed map (the F1
|
||||
regression scenario described in issue #46) would have resolved any
|
||||
``[class*="f13b"]`` ancestor of the W2 table → W1's index and falsely
|
||||
suppressed the W2 table_self_overflow fail.
|
||||
|
||||
Asserts:
|
||||
* Exactly ONE ``inner clipped`` fail line (for W1) — proves W1 is in the map.
|
||||
* Exactly ONE ``table self-overflow`` fail line (for W2's table) — proves
|
||||
the W2 table is NOT suppressed by W1's identical class string.
|
||||
* W2 table's ``table_events`` entry reports ``wrapper_clipped_index = None``
|
||||
(element-identity contract) and ``excess_x > TABLE_SCROLL_TOL_PX``.
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" '
|
||||
'data-template-id="t_table_same_class">'
|
||||
# W1 — same className, overflowing non-table child.
|
||||
'<div class="f13b-cell" id="w1" style="width:300px; height:60px; '
|
||||
'overflow:hidden; box-sizing:border-box; position:relative; '
|
||||
'margin-bottom:8px;">'
|
||||
'<div style="display:inline-block; width:600px; white-space:nowrap;">'
|
||||
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
|
||||
'</div>'
|
||||
'</div>'
|
||||
# W2 — same className, NOT clipped (W2.clientWidth=600 > table.outer=500),
|
||||
# but the inner table itself self-overflows (table width=500, td width=900).
|
||||
'<div class="f13b-cell" id="w2" style="width:600px; height:60px; '
|
||||
'overflow:hidden; box-sizing:border-box; position:relative;">'
|
||||
'<table style="display:block; width:500px; height:40px; '
|
||||
'overflow:hidden; box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:900px; white-space:nowrap;">'
|
||||
'YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_f.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
|
||||
# Exactly one table_events entry (the W2 table — W1 has no <table>).
|
||||
table_events = result.get("table_events", [])
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
# Element-identity contract: W2 ≠ W1, so the ancestor walk MUST NOT inherit
|
||||
# W1's wrapper index merely because W2 shares W1's class string.
|
||||
assert ev["wrapper_clipped_index"] is None, (
|
||||
f"W2 (not itself clipped) must NOT inherit W1's index via class string; "
|
||||
f"got wrapper_clipped_index={ev['wrapper_clipped_index']}. "
|
||||
"This is the F1 regression — a class-substring map would have failed here."
|
||||
)
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"W2's inner table must self-overflow for this test to be meaningful; ev={ev}"
|
||||
)
|
||||
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
|
||||
# W1: inner clipped fail emitted (W1 is in clippedWrapperMap, has overflowing inner div).
|
||||
w1_clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
|
||||
assert len(w1_clipped_fails) == 1, (
|
||||
f"expected exactly one W1 'inner clipped' fail; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
|
||||
# W2: table self-overflow fail emitted because element-identity dedup correctly
|
||||
# reports wrapper_clipped_index=None for the W2 table (W2 ≠ W1 by DOM ref).
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert len(table_fails) == 1, (
|
||||
f"expected exactly one W2 'table self-overflow' fail (element-identity dedup); "
|
||||
f"got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert "zone--primary" in table_fails[0], table_fails[0]
|
||||
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
|
||||
|
||||
assert result["passed"] is False, result
|
||||
@@ -0,0 +1,248 @@
|
||||
"""IMP-12 u15 — End-to-end test of `_attempt_salvage_chain` (Step 17 deterministic salvage cascade).
|
||||
|
||||
Three Stage 2 cases against `src.phase_z2_pipeline._attempt_salvage_chain`:
|
||||
(a) zone_ratio fail + cross_zone pass → final.html promoted, salvage_passed=True
|
||||
(b) cross_zone fail + glue pass → 2nd cascade step promoted, salvage_passed=True
|
||||
(c) all 3 fail → (b)-revert preserved, original final.html intact, salvage_passed=False
|
||||
|
||||
`render_slide` and `run_overflow_check` are monkey-patched so the test stays deterministic
|
||||
(no Selenium / Jinja2 template files). The patches only stand in for the rendering / overflow
|
||||
oracles — the planners (`plan_cross_zone_redistribute`, `plan_glue_compression`,
|
||||
`plan_font_step_compression`) and the cascade router (`route_retry_failure` /
|
||||
`SALVAGE_FAIL_BY_ACTION`) all run unmocked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_pipeline as _pz_pipeline
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
from src.phase_z2_pipeline import _attempt_salvage_chain
|
||||
|
||||
|
||||
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_tmp(tmp_path_factory):
|
||||
"""Temp dir under PROJECT_ROOT so _attempt_salvage_chain can call
|
||||
candidate_path.relative_to(PROJECT_ROOT) without ValueError on a
|
||||
cross-drive system tmp path (pytest's default tmp_path is under
|
||||
%LOCALAPPDATA% on Windows, which lives on a different drive from
|
||||
the project root in this repo)."""
|
||||
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
d = Path(tempfile.mkdtemp(prefix="u15_salvage_", dir=str(base)))
|
||||
try:
|
||||
yield d
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
_LAYOUT_CSS_GATE_PASS = {
|
||||
"areas": '"top" "bottom"',
|
||||
"cols": "1fr",
|
||||
"rows": "1fr 1fr",
|
||||
"heights_px": [300, 290],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.508, 0.491],
|
||||
"width_ratios": [1.0],
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
|
||||
|
||||
def _patch_render(monkeypatch):
|
||||
"""Stub render_slide → deterministic HTML envelope so the cascade does not
|
||||
need real Jinja2 templates. Returns a counter so tests can assert how many
|
||||
times it was invoked (one per CSS-feasible cascade step)."""
|
||||
counter = {"n": 0}
|
||||
|
||||
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
|
||||
counter["n"] += 1
|
||||
return (
|
||||
f"<html><head><meta charset='utf-8'></head>"
|
||||
f"<body><div data-slide-title='{slide_title}'></div></body></html>"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
|
||||
return counter
|
||||
|
||||
|
||||
def _kwargs(*, run_dir: Path, out_path: Path, cascade_inputs: dict,
|
||||
initial_failure_type: str = "donor_slack_insufficient") -> dict:
|
||||
return {
|
||||
"run_dir": run_dir,
|
||||
"out_path": out_path,
|
||||
"slide_title": "u15-test",
|
||||
"slide_footer": None,
|
||||
"zones_data": [],
|
||||
"layout_preset": "horizontal-2",
|
||||
"layout_css": _LAYOUT_CSS_GATE_PASS,
|
||||
"cascade_inputs": cascade_inputs,
|
||||
"initial_failure_type": initial_failure_type,
|
||||
"gap_px": 14,
|
||||
}
|
||||
|
||||
|
||||
def test_case_a_cross_zone_passes_final_html_promoted(project_tmp, monkeypatch):
|
||||
"""(a) cross_zone_redistribute is feasible + run_overflow_check returns
|
||||
passed=True → out_path overwritten with the cross_zone candidate HTML and
|
||||
salvage_passed=True after the very first cascade iteration."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
# Multi-role same-zone FitAnalysis: top +30 deficit, bottom_l -50 surplus.
|
||||
fit_analysis = FitAnalysis(roles={
|
||||
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||
"bottom_l": RoleFit(role="bottom_l", allocated_px=300, shortfall_px=-50.0),
|
||||
})
|
||||
containers = {
|
||||
"top": {"zone": "slide_body", "height_px": 200},
|
||||
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||
}
|
||||
cascade_inputs = {
|
||||
"fit_analysis": fit_analysis,
|
||||
"containers": containers,
|
||||
"min_margin_px": 10,
|
||||
"excess_px": 30.0, "excess_after_glue_px": 30.0,
|
||||
"block_count": 3, "zone_position": "top",
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
_patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 1
|
||||
step0 = trace["salvage_steps"][0]
|
||||
assert step0["action"] == "cross_zone_redistribute"
|
||||
assert step0["passed"] is True
|
||||
assert step0["plan"]["feasible"] is True
|
||||
assert step0["css_override"] and '[data-role=' in step0["css_override"]
|
||||
# out_path was overwritten with the salvage candidate.
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||
assert "u15-test" in promoted
|
||||
|
||||
|
||||
def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkeypatch):
|
||||
"""(b) cross_zone is infeasible (single-role zone) → glue_compression CSS
|
||||
emitted + run_overflow_check passes → out_path overwritten with the glue
|
||||
candidate (2nd cascade step). salvage_passed=True; salvage_steps[0]
|
||||
records the infeasible cross_zone attempt."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
# Single-role zone → fit_verifier.redistribute returns can_redistribute=False
|
||||
# (peer required, none present).
|
||||
fit_analysis = FitAnalysis(roles={
|
||||
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||
})
|
||||
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||
# Glue envelope at block_count=3 = 12*(3-1)+8*3+4*3+8*2 = 76 px → 40 px is feasible.
|
||||
cascade_inputs = {
|
||||
"fit_analysis": fit_analysis,
|
||||
"containers": containers,
|
||||
"min_margin_px": 10,
|
||||
"excess_px": 40.0, "excess_after_glue_px": 40.0,
|
||||
"block_count": 3, "zone_position": "bottom_l",
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
render_counter = _patch_render(monkeypatch)
|
||||
# cross_zone is infeasible → no CSS → no rerender / no overflow call. Glue is
|
||||
# feasible → exactly one rerender + overflow call → return passed=True.
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 2
|
||||
|
||||
s0 = trace["salvage_steps"][0]
|
||||
assert s0["action"] == "cross_zone_redistribute"
|
||||
assert s0["passed"] is False
|
||||
assert s0["plan"]["feasible"] is False
|
||||
assert s0["css_override"] is None
|
||||
assert "single-role zone" in (s0["plan"].get("failure_reason") or "")
|
||||
|
||||
s1 = trace["salvage_steps"][1]
|
||||
assert s1["action"] == "glue_compression"
|
||||
assert s1["passed"] is True
|
||||
assert s1["plan"]["feasible"] is True
|
||||
assert s1["css_override"] and '[data-zone-position="bottom_l"]' in s1["css_override"]
|
||||
# render_slide was invoked exactly once (only the glue branch emitted CSS).
|
||||
assert render_counter["n"] == 1
|
||||
# out_path was overwritten with the glue candidate.
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||
|
||||
|
||||
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
||||
"""(c) All three cascade actions are infeasible (no CSS emitted by any
|
||||
planner) → run_overflow_check is never invoked, salvage_passed=False,
|
||||
salvage_steps has three failed entries, and out_path is unchanged
|
||||
(original final.html intact — (b)-revert preserved)."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
cascade_inputs = {
|
||||
# cross_zone: fit_analysis missing → plan returns feasible=False with reason
|
||||
# `cascade_inputs.fit_analysis missing` (see _attempt_salvage_chain branch).
|
||||
"fit_analysis": None,
|
||||
"containers": {},
|
||||
"min_margin_px": 10,
|
||||
# glue: excess_px (200) > envelope max at block_count=1 (28) → infeasible.
|
||||
"excess_px": 200.0, "excess_after_glue_px": 200.0,
|
||||
"block_count": 1, "zone_position": "top",
|
||||
# font_step: current_font_px=15.2 cannot absorb 200px even at 8px floor
|
||||
# → find_fitting_font_size returns None → feasible=False.
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
render_counter = _patch_render(monkeypatch)
|
||||
# Guard: if run_overflow_check is ever called, the test fails loudly.
|
||||
def _must_not_call(_p): # pragma: no cover — intentional sentinel
|
||||
raise AssertionError("run_overflow_check must not run when no CSS is emitted")
|
||||
monkeypatch.setattr(_pz_pipeline, "run_overflow_check", _must_not_call)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is False
|
||||
assert len(trace["salvage_steps"]) == 3
|
||||
actions = [s["action"] for s in trace["salvage_steps"]]
|
||||
assert actions == [
|
||||
"cross_zone_redistribute",
|
||||
"glue_compression",
|
||||
"font_step_compression",
|
||||
]
|
||||
for step in trace["salvage_steps"]:
|
||||
assert step["passed"] is False
|
||||
assert step["css_override"] is None
|
||||
assert step["failure_reason"]
|
||||
# No CSS emitted anywhere → no render_slide calls either.
|
||||
assert render_counter["n"] == 0
|
||||
# (b) revert: out_path is untouched.
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""IMP-15 실행-3 (Gitea issue #47) — classifier consumer pure-dict tests.
|
||||
|
||||
`classify_visual_runtime_check` was widened to consume the new
|
||||
``image_events[]`` / ``table_events[]`` arrays produced by ``run_overflow_check``
|
||||
(IMP-15 실행-1/2). The consumer must:
|
||||
|
||||
* emit ``image_aspect_mismatch`` when ``|delta| > IMAGE_ASPECT_DELTA_TOL`` and
|
||||
skip when ``delta is None`` or ``|delta| <= IMAGE_ASPECT_DELTA_TOL``;
|
||||
* emit ``tabular_overflow`` when a table self-overflows beyond
|
||||
``TABLE_SCROLL_TOL_PX`` and ``wrapper_clipped_index is None`` — and dedupe
|
||||
when the table sits under a wrapper already on the clipped-wrapper map
|
||||
(``wrapper_clipped_index`` non-null);
|
||||
* flip ``visual_check_passed`` to False whenever any classification fires, even
|
||||
if zone-level overflow was clean (``overflow["passed"]=True``).
|
||||
|
||||
All four cases are pure-dict — no Selenium / chromedriver dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_classifier import classify_visual_runtime_check
|
||||
from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX
|
||||
|
||||
|
||||
def _base_overflow(**overrides) -> dict:
|
||||
"""Minimal clean overflow result; tests overlay image/table events."""
|
||||
base = {
|
||||
"passed": True,
|
||||
"slide": {"overflowed": False},
|
||||
"slide_body": {"overflowed": False},
|
||||
"zones": [],
|
||||
"image_events": [],
|
||||
"table_events": [],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ─── image_events scan ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_image_aspect_mismatch_emits_classification():
|
||||
"""|delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit + flip visual_check_passed."""
|
||||
delta = IMAGE_ASPECT_DELTA_TOL + 0.05
|
||||
overflow = _base_overflow(image_events=[{
|
||||
"zone_position": "top",
|
||||
"zone_template_id": "f1b",
|
||||
"src": "img/sample.png",
|
||||
"natural_ratio": 2.0,
|
||||
"rendered_ratio": 2.0 * (1.0 + delta),
|
||||
"delta": delta,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is False
|
||||
assert result["categories_seen"] == ["image_aspect_mismatch"]
|
||||
assert len(result["classifications"]) == 1
|
||||
cls = result["classifications"][0]
|
||||
assert cls["category"] == "image_aspect_mismatch"
|
||||
assert cls["source"] == "image_event"
|
||||
assert cls["zone_position"] == "top"
|
||||
assert cls["delta"] == delta
|
||||
|
||||
|
||||
def test_image_aspect_delta_below_tol_no_classification():
|
||||
"""|delta| <= IMAGE_ASPECT_DELTA_TOL ⇒ skip (no false positive)."""
|
||||
delta = IMAGE_ASPECT_DELTA_TOL / 2.0
|
||||
overflow = _base_overflow(image_events=[{
|
||||
"zone_position": "top",
|
||||
"zone_template_id": "f1b",
|
||||
"src": "img/sample.png",
|
||||
"natural_ratio": 2.0,
|
||||
"rendered_ratio": 2.0 * (1.0 + delta),
|
||||
"delta": delta,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is True
|
||||
assert result["categories_seen"] == []
|
||||
assert result["classifications"] == []
|
||||
|
||||
|
||||
# ─── table_events scan ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_standalone_table_overflow_emits_classification():
|
||||
"""wrapper_clipped_index=None AND excess > TOL ⇒ emit tabular_overflow."""
|
||||
excess = TABLE_SCROLL_TOL_PX + 10
|
||||
overflow = _base_overflow(table_events=[{
|
||||
"zone_position": "bottom_l",
|
||||
"zone_template_id": "f13b",
|
||||
"wrapper_clipped_index": None,
|
||||
"excess_x": 0,
|
||||
"excess_y": excess,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is False
|
||||
assert result["categories_seen"] == ["tabular_overflow"]
|
||||
assert len(result["classifications"]) == 1
|
||||
cls = result["classifications"][0]
|
||||
assert cls["category"] == "tabular_overflow"
|
||||
assert cls["source"] == "table_event"
|
||||
assert cls["zone_position"] == "bottom_l"
|
||||
assert cls["excess_y"] == excess
|
||||
|
||||
|
||||
def test_table_dedup_when_wrapper_clipped():
|
||||
"""wrapper_clipped_index non-null ⇒ skip (dedupe with clipped_inner cascade)."""
|
||||
overflow = _base_overflow(table_events=[{
|
||||
"zone_position": "bottom_l",
|
||||
"zone_template_id": "f13b",
|
||||
"wrapper_clipped_index": 0,
|
||||
"excess_x": 0,
|
||||
"excess_y": TABLE_SCROLL_TOL_PX + 50,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is True
|
||||
assert result["categories_seen"] == []
|
||||
assert result["classifications"] == []
|
||||
@@ -0,0 +1,59 @@
|
||||
"""u1 — VerificationResult dataclass surface (IMP-16-U1).
|
||||
|
||||
Locks the Phase Z verification utility module anchor and the
|
||||
VerificationResult shape so downstream units (u2~u10) can rely on it
|
||||
without importing src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_module_importable_without_content_verifier():
|
||||
mod = importlib.import_module("src.phase_z2_verification_utils")
|
||||
tree = ast.parse(open(mod.__file__, encoding="utf-8").read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
assert "content_verifier" not in alias.name, (
|
||||
"Phase Z verification utility must not import "
|
||||
"src.content_verifier"
|
||||
)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
assert node.module is None or "content_verifier" not in node.module, (
|
||||
"Phase Z verification utility must not import "
|
||||
"src.content_verifier"
|
||||
)
|
||||
|
||||
|
||||
def test_verification_result_defaults():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
r = VerificationResult(passed=True, area_name="zone_test")
|
||||
assert r.passed is True
|
||||
assert r.area_name == "zone_test"
|
||||
assert r.checks == {}
|
||||
assert r.score == 0.0
|
||||
assert r.errors == []
|
||||
assert r.warnings == []
|
||||
|
||||
|
||||
def test_verification_result_independent_default_collections():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
a = VerificationResult(passed=False, area_name="a")
|
||||
b = VerificationResult(passed=False, area_name="b")
|
||||
a.checks["x"] = True
|
||||
a.errors.append("e")
|
||||
a.warnings.append("w")
|
||||
assert b.checks == {} and b.errors == [] and b.warnings == []
|
||||
|
||||
|
||||
def test_verification_result_required_fields():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
VerificationResult() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""u2 — pure HTML text extraction surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic visible-text extraction contract:
|
||||
- <style> / <script> contents are excluded.
|
||||
- Whitespace-only chunks are dropped; surviving chunks are stripped.
|
||||
- Order of visible-text fragments is preserved.
|
||||
- No import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_extract_plain_text_fragments_in_order():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<p>first</p><p>second</p><p>third</p>"
|
||||
assert extract_text_from_html(html) == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_extract_skips_style_and_script_bodies():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = (
|
||||
"<html><head>"
|
||||
"<style>body { color: red; } .x { font-size: 12px; }</style>"
|
||||
"<script>var keep_out = 1;</script>"
|
||||
"</head><body><p>visible</p></body></html>"
|
||||
)
|
||||
out = extract_text_from_html(html)
|
||||
assert "visible" in out
|
||||
joined = " ".join(out)
|
||||
assert "color: red" not in joined
|
||||
assert "keep_out" not in joined
|
||||
|
||||
|
||||
def test_extract_drops_whitespace_only_chunks_and_strips_survivors():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<div> \n\n </div><div> hello </div><span> world\t</span>"
|
||||
out = extract_text_from_html(html)
|
||||
assert out == ["hello", "world"]
|
||||
|
||||
|
||||
def test_extract_preserves_korean_and_inline_markup_text():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<p>설계 <strong>방식</strong>의 왜곡</p>"
|
||||
out = extract_text_from_html(html)
|
||||
assert out == ["설계", "방식", "의 왜곡"]
|
||||
|
||||
|
||||
def test_extract_empty_input_returns_empty_list():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
assert extract_text_from_html("") == []
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for IMP-16-U1 unit u10: sample-backed smoke without pipeline import.
|
||||
|
||||
End-to-end smoke of the deterministic chain (extract_text_from_html ∘
|
||||
normalize_for_comparison ∘ split_into_sentences ∘ _sentence_matches_html
|
||||
→ verify_text_preservation / detect_invented_text) on a real
|
||||
``samples/mdx_batch`` MDX file. Per Stage 2 rationale: smoke coverage
|
||||
uses the sample but does NOT hardcode a sample-specific pass.
|
||||
|
||||
Also locks the AI-isolation contract for the verification axis: this
|
||||
test and the production module MUST NOT import orchestrator /
|
||||
phase_z2_pipeline / Phase Q content_verifier / Kei client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
VerificationResult,
|
||||
detect_invented_text,
|
||||
verify_text_preservation,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SAMPLE_MDX_PATH = _REPO_ROOT / "samples" / "mdx_batch" / "02.mdx"
|
||||
_FORBIDDEN_IMPORT_ROOTS = (
|
||||
"orchestrator",
|
||||
"src.phase_z2_pipeline",
|
||||
"src.content_verifier",
|
||||
"src.kei_client",
|
||||
)
|
||||
|
||||
|
||||
def _module_imports(path: Path) -> set[str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
names.add(alias.name)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
names.add(node.module)
|
||||
return names
|
||||
|
||||
|
||||
def test_integration_sample_mdx_exists():
|
||||
# Smoke fixture availability gate; explicit so a missing sample
|
||||
# surfaces as a fixture problem, not a downstream assertion failure.
|
||||
assert _SAMPLE_MDX_PATH.exists(), f"sample missing: {_SAMPLE_MDX_PATH}"
|
||||
|
||||
|
||||
def test_integration_full_chain_runs_on_real_sample():
|
||||
# Locks API contract over the full chain on a real MDX: returns a
|
||||
# VerificationResult, area_name passthrough works, score within
|
||||
# [0.0, 1.0], and detect_invented_text returns a list. No assertion
|
||||
# is made about a specific score so the sample is not hardcoded as
|
||||
# the pipeline's pass rule (Stage 2 u10 rationale).
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
html = f"<div>{mdx}</div>"
|
||||
result = verify_text_preservation(mdx, html, "smoke")
|
||||
assert isinstance(result, VerificationResult)
|
||||
assert result.area_name == "smoke"
|
||||
assert 0.0 <= result.score <= 1.0
|
||||
assert isinstance(detect_invented_text(mdx, html), list)
|
||||
|
||||
|
||||
def test_integration_mirrored_html_passes_default_threshold():
|
||||
# When the HTML side mirrors the MDX text verbatim, the deterministic
|
||||
# preservation check must pass the Phase Q-default threshold (0.70).
|
||||
# This is the integration-level guarantee for the B-2 reverse path:
|
||||
# round-tripped HTML that preserves the MDX text must verify.
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
html = f"<div>{mdx}</div>"
|
||||
result = verify_text_preservation(mdx, html, "smoke")
|
||||
assert result.passed is True
|
||||
|
||||
|
||||
def test_integration_fabricated_html_flags_invented_text():
|
||||
# Locks the hallucination-guard end-to-end: HTML text that has no
|
||||
# keyword anchor in the source MDX must be flagged. Synthetic
|
||||
# sentence chosen so its keywords (완전히, 만들어낸, 원본, 등장 …)
|
||||
# do not appear in samples/mdx_batch/02.mdx.
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
fabricated_html = (
|
||||
"<p>완전히 새로 만들어낸 문장으로 원본에는 전혀 등장하지 않는 내용입니다.</p>"
|
||||
)
|
||||
invented = detect_invented_text(mdx, fabricated_html)
|
||||
assert isinstance(invented, list)
|
||||
assert len(invented) >= 1
|
||||
|
||||
|
||||
def test_integration_no_forbidden_imports():
|
||||
# AI-isolation + Phase Z scope-lock guard. Production module and
|
||||
# this test file must not import orchestrator / phase_z2_pipeline /
|
||||
# Phase Q content_verifier / Kei client. AST scan of the on-disk
|
||||
# source (not the imported module) so re-exports cannot mask a leak.
|
||||
for path in (
|
||||
_REPO_ROOT / "src" / "phase_z2_verification_utils.py",
|
||||
Path(__file__).resolve(),
|
||||
):
|
||||
modules = _module_imports(path)
|
||||
for module in modules:
|
||||
for forbidden in _FORBIDDEN_IMPORT_ROOTS:
|
||||
assert not (module == forbidden or module.startswith(forbidden + ".")), (
|
||||
f"{path.name} imports forbidden module: {module}"
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for IMP-16-U1 unit u9: ``detect_invented_text``.
|
||||
|
||||
Locks the Phase Z port of the deterministic hallucination guard
|
||||
(Phase Q reference: ``src/content_verifier.py:276-315``). The function
|
||||
is pure and composes u2 (extract_text_from_html), u3
|
||||
(normalize_for_comparison), and u4 (extract_keywords). No Phase Q
|
||||
import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_INVENTED_TEXT_ALLOWED_LABELS,
|
||||
_INVENTED_TEXT_CSS_NUMBER_PATTERN,
|
||||
_INVENTED_TEXT_KEYWORD_THRESHOLD,
|
||||
_INVENTED_TEXT_MIN_LENGTH,
|
||||
_INVENTED_TEXT_TRUNCATE_LEN,
|
||||
detect_invented_text,
|
||||
)
|
||||
|
||||
|
||||
def test_detect_invented_text_constants_locked() -> None:
|
||||
"""Lock the five named module constants ported from Phase Q literals."""
|
||||
assert _INVENTED_TEXT_MIN_LENGTH == 15
|
||||
assert _INVENTED_TEXT_ALLOWED_LABELS == frozenset(
|
||||
{"용어 정의", "핵심 메시지", "상세 비교"}
|
||||
)
|
||||
assert _INVENTED_TEXT_CSS_NUMBER_PATTERN.pattern == r"^[\d\s.,%px#rgb()]+$"
|
||||
assert _INVENTED_TEXT_KEYWORD_THRESHOLD == 0.4
|
||||
assert _INVENTED_TEXT_TRUNCATE_LEN == 80
|
||||
|
||||
|
||||
def test_detect_invented_text_returns_empty_when_html_is_in_mdx() -> None:
|
||||
"""Text whose keywords fully appear in MDX is NOT flagged."""
|
||||
mdx = "원본 콘텐츠는 분석에 관한 것입니다."
|
||||
html = "<p>원본 콘텐츠는 분석에 관한 것입니다.</p>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_flags_text_with_low_keyword_overlap() -> None:
|
||||
"""Text whose keywords do not appear in MDX is flagged as invented."""
|
||||
mdx = "원본 콘텐츠는 분석에 관한 것입니다."
|
||||
html = "<p>완전히 다른 발명된 텍스트가 여기 있습니다 일반적이지 않은</p>"
|
||||
result = detect_invented_text(mdx, html)
|
||||
assert len(result) == 1
|
||||
assert "발명된" in result[0]
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_short_text() -> None:
|
||||
"""Text shorter than ``min_length`` is not even considered."""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<p>짧은 텍스트</p>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_allowed_structural_labels() -> None:
|
||||
"""Allowed labels are skipped even when keyword overlap is zero.
|
||||
|
||||
Phase Q default ``min_length=15`` makes the allowed-label gate
|
||||
unreachable for the bundled labels (all < 15 chars). The Phase Z
|
||||
port preserves the gate verbatim — exercised here with
|
||||
``min_length=0`` so the structural-label short-circuit is
|
||||
actually observable.
|
||||
"""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<h2>용어 정의</h2><h2>핵심 메시지</h2><h2>상세 비교</h2>"
|
||||
assert detect_invented_text(mdx, html, min_length=0) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_css_number_pattern_fragments() -> None:
|
||||
"""CSS/numeric fragments (e.g. ``100px 200px 300px``) are skipped."""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<style>.x { padding: 100px; }</style><div>100px 200px 300px</div>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_truncates_flagged_value_to_80_chars() -> None:
|
||||
"""A flagged fragment longer than 80 chars is truncated for reporting."""
|
||||
mdx = "원본 콘텐츠"
|
||||
invented = "발명" * 50
|
||||
html = f"<p>{invented}</p>"
|
||||
result = detect_invented_text(mdx, html)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 80
|
||||
assert result[0] == invented[:80]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for Phase Z2 IMP-16-U1 unit u4: extract_keywords.
|
||||
|
||||
Locks the deterministic surface: 3+ character tokens on the Phase Z H3
|
||||
character class, longest-match trailing particle strip with a length>=2
|
||||
stem guard, and no Phase Q content_verifier import.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import _PARTICLES, extract_keywords
|
||||
|
||||
|
||||
def test_extract_keywords_drops_short_tokens() -> None:
|
||||
# "AI" (2 chars) and "X" (1 char) are dropped; "기술" (2 chars) is dropped too.
|
||||
# "데이터" (3 chars) survives; "분석함" (3 chars) survives.
|
||||
assert extract_keywords("AI 기술 X 데이터 분석함") == ["데이터", "분석함"]
|
||||
|
||||
|
||||
def test_extract_keywords_strips_trailing_particle_when_stem_ge_2() -> None:
|
||||
# "설계의" (3 chars) → particle "의" stripped, stem "설계" (2 chars) kept.
|
||||
# "방식은" → particle "은" stripped → "방식".
|
||||
assert extract_keywords("설계의 방식은") == ["설계", "방식"]
|
||||
|
||||
|
||||
def test_extract_keywords_keeps_token_when_stem_would_be_too_short() -> None:
|
||||
# "에서" guard: a 3-char token whose 2-char suffix is a particle
|
||||
# but whose stem (1 char) is < 2 must keep the original token.
|
||||
# "안에서" → suffix "에서" len 2, stem "안" len 1 → guard fires,
|
||||
# falls through, then next particle "서" is NOT in _PARTICLES,
|
||||
# so the whole token "안에서" remains.
|
||||
assert extract_keywords("안에서") == ["안에서"]
|
||||
|
||||
|
||||
def test_extract_keywords_longest_match_particle_wins() -> None:
|
||||
# "_PARTICLES" is sorted longest-first, so "에서" wins over "서"/"에".
|
||||
# "현장에서" → "에서" stripped → "현장".
|
||||
assert "에서" in _PARTICLES
|
||||
assert extract_keywords("현장에서") == ["현장"]
|
||||
|
||||
|
||||
def test_extract_keywords_tokenises_korean_alnum_and_parens() -> None:
|
||||
# The Phase Z H3 character class is [가-힣a-zA-Z0-9()]+.
|
||||
# "프로젝트(2024)" is one token; "Hello!" splits into "Hello" only.
|
||||
# Punctuation outside the class acts as a delimiter.
|
||||
result = extract_keywords("프로젝트(2024) Hello! World123")
|
||||
assert "프로젝트(2024)" in result
|
||||
assert "Hello" in result
|
||||
assert "World123" in result
|
||||
assert "!" not in "".join(result)
|
||||
|
||||
|
||||
def test_extract_keywords_empty_returns_empty() -> None:
|
||||
assert extract_keywords("") == []
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for IMP-16-U1 unit u7: ``_sentence_matches_html``.
|
||||
|
||||
Locks the Phase Z port of the deterministic per-sentence match
|
||||
helper (Phase Q reference: inline body of ``verify_text_preservation``
|
||||
at src/content_verifier.py:232-251). The helper is pure; no Phase Q
|
||||
import is exercised. Thresholds are locked as named constants so the
|
||||
0.6 / 0.65 surface cannot drift silently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_SENTENCE_KEYWORD_MATCH_THRESHOLD,
|
||||
_SENTENCE_SEQUENCE_MATCH_THRESHOLD,
|
||||
_sentence_matches_html,
|
||||
)
|
||||
|
||||
|
||||
def test_match_helper_thresholds_locked():
|
||||
assert _SENTENCE_KEYWORD_MATCH_THRESHOLD == 0.6
|
||||
assert _SENTENCE_SEQUENCE_MATCH_THRESHOLD == 0.65
|
||||
|
||||
|
||||
def test_match_helper_returns_true_when_no_keywords():
|
||||
# "AI" tokenises to a single 2-char token which extract_keywords drops
|
||||
# (len < 3 gate). Empty keyword list -> helper returns True regardless
|
||||
# of HTML side. Phase Q parity: matched += 1; continue on empty keywords.
|
||||
assert _sentence_matches_html("AI", "", []) is True
|
||||
|
||||
|
||||
def test_match_helper_keyword_ratio_meets_threshold():
|
||||
# Sentence "데이터 분석의 핵심" -> keywords = ["데이터", "분석"]:
|
||||
# "데이터" (len 3, no particle ending) kept;
|
||||
# "분석의" (len 3, ends with "의", stem "분석" len 2) -> "분석" kept;
|
||||
# "핵심" (len 2 < 3) dropped.
|
||||
# Both keywords are substrings of the html_combined string, so
|
||||
# kw_ratio = 2 / 2 = 1.0 >= 0.6 -> True via keyword axis.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석의 핵심",
|
||||
"데이터 분석을 수행합니다",
|
||||
["데이터 분석을 수행합니다"],
|
||||
) is True
|
||||
|
||||
|
||||
def test_match_helper_sequence_ratio_fallback():
|
||||
# Sentence "데이터 분석" -> keywords = ["데이터"] (the 2-char "분석"
|
||||
# is dropped by the len<3 gate). "데이터" is NOT in html_combined,
|
||||
# so kw_ratio = 0. The SequenceMatcher fallback compares the
|
||||
# normalized sentence against each normalized html_text; the second
|
||||
# fragment matches verbatim, yielding ratio 1.0 >= 0.65 -> True.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석",
|
||||
"abc xyz",
|
||||
["abc xyz", "데이터 분석"],
|
||||
) is True
|
||||
|
||||
|
||||
def test_match_helper_below_both_thresholds_returns_false():
|
||||
# No keyword overlap and no high-similarity html fragment:
|
||||
# kw_ratio = 0, best SequenceMatcher ratio is far below 0.65.
|
||||
# Helper must return False so verify_text_preservation (u8)
|
||||
# records the sentence as missing.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석",
|
||||
"abc xyz",
|
||||
["abc xyz"],
|
||||
) is False
|
||||
@@ -0,0 +1,73 @@
|
||||
"""u5 — meta-line stripping surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic meta-line filter contract:
|
||||
- lines whose stripped form starts with any ``_META_PREFIXES`` entry
|
||||
are dropped (8 prefix surface);
|
||||
- lines containing any ``_META_INLINE_FRAGMENTS`` entry are dropped
|
||||
(3 inline fragment surface);
|
||||
- other lines pass through with original whitespace preserved;
|
||||
- empty input returns the empty string;
|
||||
- no import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_strip_meta_lines_drops_prefix_lines():
|
||||
from src.phase_z2_verification_utils import _META_PREFIXES, strip_meta_lines
|
||||
|
||||
# Exactly the 8-prefix Phase Z surface — locks both content and size.
|
||||
assert _META_PREFIXES == [
|
||||
"제목 라벨:",
|
||||
"표현 의도:",
|
||||
"슬라이드 주인공",
|
||||
"가장 큰 시각적 비중",
|
||||
"시각적으로",
|
||||
"간결하게 제기",
|
||||
"개별 증거로 제시",
|
||||
"계층적으로 시각화",
|
||||
]
|
||||
text = "제목 라벨: 어떤 제목\n본문 한 줄\n표현 의도: 강조"
|
||||
assert strip_meta_lines(text) == "본문 한 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_matches_prefix_on_stripped_line():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
# Leading whitespace must not protect a meta-prefix line.
|
||||
text = " 제목 라벨: indented meta\n실제 본문"
|
||||
assert strip_meta_lines(text) == "실제 본문"
|
||||
|
||||
|
||||
def test_strip_meta_lines_drops_inline_fragment_lines():
|
||||
from src.phase_z2_verification_utils import (
|
||||
_META_INLINE_FRAGMENTS,
|
||||
strip_meta_lines,
|
||||
)
|
||||
|
||||
# Phase Z inline-fragment surface is exactly these three.
|
||||
assert _META_INLINE_FRAGMENTS == (
|
||||
"현상-문제 인과관계",
|
||||
"상위-하위 포함 관계",
|
||||
"독립적 나열",
|
||||
)
|
||||
text = (
|
||||
"구조: 현상-문제 인과관계 로 설계\n"
|
||||
"유형: 상위-하위 포함 관계\n"
|
||||
"패턴: 독립적 나열 형태\n"
|
||||
"그래서 결론은 한 줄"
|
||||
)
|
||||
assert strip_meta_lines(text) == "그래서 결론은 한 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_keeps_unrelated_lines_verbatim():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
# Non-meta lines must pass through with original whitespace preserved.
|
||||
text = " 본문 한 줄\n\n다른 줄"
|
||||
assert strip_meta_lines(text) == " 본문 한 줄\n\n다른 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_empty_input_returns_empty_string():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
assert strip_meta_lines("") == ""
|
||||
@@ -0,0 +1,64 @@
|
||||
"""u3 — Korean text normalization surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic text-normalization contract:
|
||||
- whitespace runs collapse + strip;
|
||||
- bullet markers from the Phase Q surface set are removed;
|
||||
- the small HTML-entity set used by the reverse path is decoded;
|
||||
- a single trailing 개조식 ending is folded to its 서술형 form;
|
||||
- particle list is sorted longest-first (matching the Phase Q surface
|
||||
so downstream keyword stripping is greedy);
|
||||
- no import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_normalize_collapses_whitespace_and_strips():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
assert normalize_for_comparison(" hello\n\n world\t") == "hello world"
|
||||
|
||||
|
||||
def test_normalize_removes_bullet_markers():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
# Each marker from the Phase Q surface set must be stripped.
|
||||
for marker in ["•", "◦", "·", "-", "▪", "▸", "►"]:
|
||||
assert normalize_for_comparison(f"{marker} 항목") == "항목"
|
||||
|
||||
|
||||
def test_normalize_decodes_html_entities():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
text = "A & B <tag> 'q' "d""
|
||||
assert normalize_for_comparison(text) == "A & B <tag> 'q' \"d\""
|
||||
|
||||
|
||||
def test_normalize_folds_trailing_gaejo_endings():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
assert normalize_for_comparison("적용함") == "적용한다"
|
||||
assert normalize_for_comparison("필요됨") == "필요된다"
|
||||
assert normalize_for_comparison("값이 있음") == "값이 있다"
|
||||
assert normalize_for_comparison("자료 없음") == "자료 없다"
|
||||
assert normalize_for_comparison("결과임") == "결과이다"
|
||||
assert normalize_for_comparison("적용되었음") == "적용되었다"
|
||||
assert normalize_for_comparison("적용되었음.") == "적용되었음." # trailing punct blocks fold
|
||||
|
||||
|
||||
def test_normalize_only_folds_one_ending_and_only_at_end():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
# 'break' after first match: only the suffix is folded, mid-string '함' is left alone.
|
||||
assert normalize_for_comparison("함수를 적용함") == "함수를 적용한다"
|
||||
# No fold when the ending is not the last token.
|
||||
assert normalize_for_comparison("적용함 그리고 종료") == "적용함 그리고 종료"
|
||||
|
||||
|
||||
def test_particles_sorted_longest_first():
|
||||
from src.phase_z2_verification_utils import _PARTICLES
|
||||
|
||||
lengths = [len(p) for p in _PARTICLES]
|
||||
assert lengths == sorted(lengths, reverse=True)
|
||||
# Phase Q surface size guard (no values reused from REQUIRED_PATTERNS;
|
||||
# this is the Korean-locale particle inventory).
|
||||
assert "에서" in _PARTICLES and "는" in _PARTICLES
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for IMP-16-U1 unit u8: ``verify_text_preservation``.
|
||||
|
||||
Locks the Phase Z port of the deterministic text-preservation check
|
||||
(Phase Q reference: ``src/content_verifier.py:206-273``). The function
|
||||
is pure and composes u2 (extract_text_from_html), u3
|
||||
(normalize_for_comparison), u6 (split_into_sentences), and u7
|
||||
(_sentence_matches_html). No Phase Q import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
VerificationResult,
|
||||
_MISSING_SENTENCE_REPORT_LIMIT,
|
||||
_MISSING_SENTENCE_TRUNCATE_LEN,
|
||||
_TEXT_PRESERVATION_DEFAULT_THRESHOLD,
|
||||
verify_text_preservation,
|
||||
)
|
||||
|
||||
|
||||
def test_verify_text_preservation_defaults_locked():
|
||||
# Locks the Phase Q caller convention: threshold default = 0.70,
|
||||
# missing-list report cap = 5, per-item truncate length = 60.
|
||||
assert _TEXT_PRESERVATION_DEFAULT_THRESHOLD == 0.70
|
||||
assert _MISSING_SENTENCE_REPORT_LIMIT == 5
|
||||
assert _MISSING_SENTENCE_TRUNCATE_LEN == 60
|
||||
|
||||
|
||||
def test_verify_text_preservation_empty_sentences_returns_passed():
|
||||
# MDX that reduces to zero sentences after split_into_sentences
|
||||
# (e.g. headers only) must return passed=True with score 1.0 and
|
||||
# an empty errors/warnings surface. Phase Q parity: early return
|
||||
# before any HTML extraction.
|
||||
result = verify_text_preservation("# header only", "<p>anything</p>", "core")
|
||||
assert isinstance(result, VerificationResult)
|
||||
assert result.passed is True
|
||||
assert result.area_name == "core"
|
||||
assert result.checks == {"text_preservation": True}
|
||||
assert result.score == 1.0
|
||||
assert result.errors == []
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
def test_verify_text_preservation_full_match_passes():
|
||||
# All MDX sentences preserved in HTML -> score 1.0, passed True,
|
||||
# no warnings (warnings only attached when score < 1.0), no errors.
|
||||
mdx = "데이터 분석은 핵심 과정입니다. 시각화로 의사 결정을 지원합니다."
|
||||
html = (
|
||||
"<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
"<p>시각화로 의사 결정을 지원합니다.</p>"
|
||||
)
|
||||
result = verify_text_preservation(mdx, html, "body")
|
||||
assert result.passed is True
|
||||
assert result.score == 1.0
|
||||
assert result.warnings == []
|
||||
assert result.errors == []
|
||||
|
||||
|
||||
def test_verify_text_preservation_below_threshold_reports_errors():
|
||||
# Only one of two MDX sentences appears in the HTML -> score 0.5,
|
||||
# below default threshold 0.70 -> passed False, errors list opens
|
||||
# with the "누락 문장 (1/2):" header followed by quoted missing
|
||||
# sentences (truncation gate not crossed).
|
||||
mdx = (
|
||||
"데이터 분석은 핵심 과정입니다.\n"
|
||||
"전혀 다른 문맥의 두 번째 문장입니다."
|
||||
)
|
||||
html = "<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
result = verify_text_preservation(mdx, html, "core")
|
||||
assert result.passed is False
|
||||
assert result.score == 0.5
|
||||
assert result.checks == {"text_preservation": False}
|
||||
assert result.errors[0] == "누락 문장 (1/2):"
|
||||
assert any("두 번째 문장" in line for line in result.errors[1:])
|
||||
assert result.warnings == ["보존율: 50% (1/2 문장)"]
|
||||
|
||||
|
||||
def test_verify_text_preservation_truncates_long_missing_sentence():
|
||||
# A missing sentence longer than 60 chars must be rendered with
|
||||
# the "...\"" tail. Phase Z surface lifts the 60 constant to a
|
||||
# named module value (_MISSING_SENTENCE_TRUNCATE_LEN) so the gate
|
||||
# is auditable.
|
||||
long_sentence = "엄청나게 긴 문장이 들어가서 절단 동작을 검증합니다." + ("끝" * 60)
|
||||
mdx = long_sentence + "."
|
||||
html = "<p>관련 없는 문구</p>"
|
||||
result = verify_text_preservation(mdx, html, "footer", threshold=0.99)
|
||||
assert result.passed is False
|
||||
# Header + at least one missing-line entry; the entry must end with `..."`.
|
||||
assert len(result.errors) >= 2
|
||||
assert result.errors[-1].endswith("...\"")
|
||||
truncated_body = result.errors[-1].split('"', 2)[1].rstrip(".")
|
||||
assert len(truncated_body) == _MISSING_SENTENCE_TRUNCATE_LEN
|
||||
|
||||
|
||||
def test_verify_text_preservation_caps_missing_report_at_limit():
|
||||
# Generate seven MDX-only sentences with no HTML coverage.
|
||||
# passed=False, errors list = 1 header + at most 5 missing entries
|
||||
# (_MISSING_SENTENCE_REPORT_LIMIT). The header reports the true
|
||||
# missing/total counts even though only 5 are surfaced.
|
||||
mdx_lines = [f"전혀 다른 문맥의 문장 번호 {i} 입니다." for i in range(7)]
|
||||
mdx = "\n".join(mdx_lines)
|
||||
html = "<p>관련 없는 문구</p>"
|
||||
result = verify_text_preservation(mdx, html, "core")
|
||||
assert result.passed is False
|
||||
assert result.errors[0] == "누락 문장 (7/7):"
|
||||
assert len(result.errors) == 1 + _MISSING_SENTENCE_REPORT_LIMIT
|
||||
|
||||
|
||||
def test_verify_text_preservation_custom_threshold_passes_at_50_percent():
|
||||
# Lowering the threshold to 0.50 makes a 50% preservation pass.
|
||||
mdx = (
|
||||
"데이터 분석은 핵심 과정입니다.\n"
|
||||
"전혀 다른 문맥의 두 번째 문장입니다."
|
||||
)
|
||||
html = "<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
result = verify_text_preservation(mdx, html, "core", threshold=0.50)
|
||||
assert result.passed is True
|
||||
assert result.score == 0.5
|
||||
# Score < 1.0 so the 보존율 warning is still attached for trace surface.
|
||||
assert result.warnings == ["보존율: 50% (1/2 문장)"]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for IMP-16-U1 unit u6: split_into_sentences.
|
||||
|
||||
Locks the Phase Z port of the H3 deterministic sentence-splitter
|
||||
surface (Phase Q reference: src/content_verifier.py:174-199). The
|
||||
function is deterministic, pure, and composes ``strip_meta_lines``;
|
||||
no Phase Q import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_BULLET_MARKER_PATTERN,
|
||||
_MIN_SENTENCE_LEN,
|
||||
_SENTENCE_SPLIT_PATTERN,
|
||||
split_into_sentences,
|
||||
)
|
||||
|
||||
|
||||
def test_split_into_sentences_applies_strip_meta_lines_first():
|
||||
text = (
|
||||
"제목 라벨: 설계 방식의 왜곡\n"
|
||||
"본문 첫 문장입니다.\n"
|
||||
"본문 둘째 문장입니다."
|
||||
)
|
||||
result = split_into_sentences(text)
|
||||
assert result == ["본문 첫 문장입니다.", "본문 둘째 문장입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_skips_empty_and_header_lines():
|
||||
text = "\n# 대목차\n## 소목차\n실제 본문 문장입니다.\n"
|
||||
assert split_into_sentences(text) == ["실제 본문 문장입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_strips_numeric_and_punctuated_markers():
|
||||
assert _BULLET_MARKER_PATTERN.match("1. 첫 단계입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("2) 둘째 단계입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("-. 첫 항목입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("•. 둘째 항목입니다.")
|
||||
text = (
|
||||
"1. 첫 단계입니다.\n"
|
||||
"2) 둘째 단계입니다.\n"
|
||||
"-. 셋째 항목입니다."
|
||||
)
|
||||
assert split_into_sentences(text) == [
|
||||
"첫 단계입니다.",
|
||||
"둘째 단계입니다.",
|
||||
"셋째 항목입니다.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_into_sentences_keeps_bare_dash_bullet_unstripped():
|
||||
assert _BULLET_MARKER_PATTERN.match("- 항목 하나입니다.") is None
|
||||
text = "- 항목 하나입니다."
|
||||
assert split_into_sentences(text) == ["- 항목 하나입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_splits_on_period_boundary():
|
||||
assert _SENTENCE_SPLIT_PATTERN.pattern == r"(?<=\.)\s+"
|
||||
text = "첫 문장입니다. 둘째 문장입니다. 셋째 문장입니다."
|
||||
assert split_into_sentences(text) == [
|
||||
"첫 문장입니다.",
|
||||
"둘째 문장입니다.",
|
||||
"셋째 문장입니다.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_into_sentences_drops_parts_shorter_than_min_len():
|
||||
assert _MIN_SENTENCE_LEN == 5
|
||||
text = "OK. 충분히 긴 문장입니다."
|
||||
assert split_into_sentences(text) == ["충분히 긴 문장입니다."]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""IMP-09 PR 1 — retry gate tests (_attempt_zone_ratio_retry early exit).
|
||||
|
||||
Stage 3 round 4 lock §2-A: row-axis retry must skip when layout has
|
||||
dynamic_cols=True (2-D topology) OR dynamic_rows=False (fr_default
|
||||
sink). The horizontal-2 path (dynamic_rows=True, dynamic_cols=False)
|
||||
must still proceed through the gate.
|
||||
|
||||
These tests exercise the gate by routing the request through
|
||||
_attempt_zone_ratio_retry with router_active=True + proposed
|
||||
zone_ratio_retry — but with layout_css fields that should trip the
|
||||
gate. We confirm the early skip by asserting retry_attempted==False
|
||||
and retry_skipped_reason content.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _attempt_zone_ratio_retry
|
||||
|
||||
|
||||
_ROUTER_ACTIVE = {
|
||||
"router_active": True,
|
||||
"proposed_actions_summary": ["zone_ratio_retry"],
|
||||
}
|
||||
|
||||
|
||||
def _dummy_kwargs(layout_css: dict, tmp_path: Path) -> dict:
|
||||
"""All params required by _attempt_zone_ratio_retry. Only
|
||||
`layout_css` and `router_decision` matter pre-gate."""
|
||||
return {
|
||||
"run_dir": tmp_path,
|
||||
"out_path": tmp_path / "final.html",
|
||||
"slide_title": "test",
|
||||
"slide_footer": None,
|
||||
"zones_data": [],
|
||||
"debug_zones": [],
|
||||
"layout_preset": "horizontal-2",
|
||||
"layout_css": layout_css,
|
||||
"overflow": {},
|
||||
"fit_classification": {},
|
||||
"router_decision": _ROUTER_ACTIVE,
|
||||
"gap_px": 14,
|
||||
}
|
||||
|
||||
|
||||
def test_vertical_2_dynamic_cols_skips_retry(tmp_path):
|
||||
layout_css = {
|
||||
"areas": '"left right"',
|
||||
"cols": "583px 583px",
|
||||
"rows": "1fr",
|
||||
"heights_px": [585],
|
||||
"widths_px": [583, 583],
|
||||
"ratios": [1.0],
|
||||
"width_ratios": [0.494, 0.494],
|
||||
"dynamic_rows": False,
|
||||
"dynamic_cols": True,
|
||||
}
|
||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
||||
assert trace["retry_attempted"] is False
|
||||
assert "dynamic_cols" in trace["retry_skipped_reason"]
|
||||
assert "IMP-09" in trace["retry_skipped_reason"]
|
||||
|
||||
|
||||
def test_fr_default_sink_skips_retry(tmp_path):
|
||||
# PR 1 single / T-shape / 2x2 fall through to fr_default and must
|
||||
# not enter row-only retry plan.
|
||||
layout_css = {
|
||||
"areas": '"top top" "bottom-left bottom-right"',
|
||||
"cols": "1fr 1fr",
|
||||
"rows": "1fr 1fr",
|
||||
"heights_px": [285, 286],
|
||||
"widths_px": [583, 583],
|
||||
"ratios": [0.487, 0.489],
|
||||
"width_ratios": [0.494, 0.494],
|
||||
"dynamic_rows": False,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
||||
assert trace["retry_attempted"] is False
|
||||
assert "fr_default_from_preset" in trace["retry_skipped_reason"]
|
||||
|
||||
|
||||
def test_horizontal_2_dynamic_rows_passes_gate(tmp_path):
|
||||
"""horizontal-2 with dynamic_rows=True must pass the gate. The
|
||||
test does not need plan_zone_ratio_retry to succeed; it only
|
||||
asserts the gate did not early-skip with one of the new
|
||||
skip reasons."""
|
||||
layout_css = {
|
||||
"areas": '"top" "bottom"',
|
||||
"cols": "1fr",
|
||||
"rows": "333px 238px",
|
||||
"heights_px": [333, 238],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.569, 0.407],
|
||||
"width_ratios": [1.0],
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
# plan_zone_ratio_retry will return None because debug_zones is
|
||||
# empty, so retry_attempted=True but plan==None.
|
||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
||||
assert trace["retry_attempted"] is True
|
||||
# The gate was passed; skip reason (if any) is the legacy
|
||||
# plan-failure reason, not the new gate reasons.
|
||||
skip_reason = trace.get("retry_skipped_reason")
|
||||
if skip_reason is not None:
|
||||
assert "dynamic_cols" not in skip_reason
|
||||
assert "fr_default_from_preset" not in skip_reason
|
||||
|
||||
|
||||
def test_router_inactive_skips_before_gate(tmp_path):
|
||||
"""When router_active=False, the early skip happens before the
|
||||
new IMP-09 gate. Verify the existing behavior is unchanged."""
|
||||
layout_css = {
|
||||
"areas": '"left right"',
|
||||
"dynamic_rows": False,
|
||||
"dynamic_cols": True,
|
||||
"heights_px": [585],
|
||||
"widths_px": [583, 583],
|
||||
"ratios": [1.0],
|
||||
"width_ratios": [0.5, 0.5],
|
||||
}
|
||||
kwargs = _dummy_kwargs(layout_css, tmp_path)
|
||||
kwargs["router_decision"] = {"router_active": False}
|
||||
trace = _attempt_zone_ratio_retry(**kwargs)
|
||||
assert trace["retry_attempted"] is False
|
||||
assert "router_active=False" in trace["retry_skipped_reason"]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""IMP-14 A-4 — slide_base.html embedded_mode contract tests.
|
||||
|
||||
Asserts the three-valued enum (auto / embedded / standalone) round-trips
|
||||
through render_slide -> slide_base.html, that the additive html.embedded
|
||||
CSS reset and the auto-mode detection <script> are emitted under the
|
||||
correct modes, that the invalid-mode guard raises ValueError, and that
|
||||
Jinja2 rendering is byte-deterministic across calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
def _zone() -> dict:
|
||||
return {"position": "primary", "template_id": "__empty__", "slot_payload": {}}
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _render(embedded_mode: str = "auto") -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=[_zone()],
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode=embedded_mode,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_script_present():
|
||||
html = _render("auto")
|
||||
assert "params.get('embedded')" in html
|
||||
assert "window.self !== window.top" in html
|
||||
assert "classList.add('embedded')" in html
|
||||
|
||||
|
||||
def test_css_rules_present():
|
||||
html = _render("auto")
|
||||
assert "html.embedded body" in html
|
||||
assert "html.embedded .slide" in html
|
||||
|
||||
|
||||
def test_embedded_mode_explicit():
|
||||
html = _render("embedded")
|
||||
assert '<html lang="ko" class="embedded">' in html
|
||||
assert "params.get('embedded')" not in html
|
||||
|
||||
|
||||
def test_standalone_mode_explicit():
|
||||
html = _render("standalone")
|
||||
assert '<html lang="ko">' in html
|
||||
assert 'class="embedded"' not in html.split("</head>")[0]
|
||||
assert "params.get('embedded')" not in html
|
||||
|
||||
|
||||
def test_deterministic():
|
||||
assert _render("embedded") == _render("embedded")
|
||||
assert _render("auto") == _render("auto")
|
||||
|
||||
|
||||
def test_invalid_mode_raises():
|
||||
with pytest.raises(ValueError, match="invalid embedded_mode"):
|
||||
_render("bogus")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Spec lint: PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 taxonomy must declare
|
||||
the `image_aspect_mismatch` row (IMP-15 실행-4, issue #48 u2).
|
||||
|
||||
The row encodes a post-render `fail_reasons` signal surfaced by Step 14
|
||||
visual_runtime_check, not a router-routed fit_classifier output. It is
|
||||
intentionally placed inside §3.1 to keep the taxonomy vocabulary aligned
|
||||
with the event streams now exposed at debug.json top level (u1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SPEC_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md"
|
||||
)
|
||||
|
||||
|
||||
def _read_spec_text() -> str:
|
||||
return SPEC_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_section_3_1(text: str) -> str:
|
||||
start_match = re.search(r"^###\s+3\.1\b", text, flags=re.MULTILINE)
|
||||
assert start_match, "§3.1 heading missing from spec"
|
||||
after_3_1 = text[start_match.end():]
|
||||
end_match = re.search(r"^###\s+3\.2\b", after_3_1, flags=re.MULTILINE)
|
||||
assert end_match, "§3.2 heading missing from spec"
|
||||
return after_3_1[: end_match.start()]
|
||||
|
||||
|
||||
def test_spec_section_3_1_contains_image_aspect_mismatch_row():
|
||||
section = _extract_section_3_1(_read_spec_text())
|
||||
row_pattern = re.compile(r"^\|\s*`image_aspect_mismatch`\s*\|", re.MULTILINE)
|
||||
matches = row_pattern.findall(section)
|
||||
assert len(matches) == 1, (
|
||||
"Expected exactly 1 `image_aspect_mismatch` row inside §3.1 taxonomy, "
|
||||
f"found {len(matches)}"
|
||||
)
|
||||
|
||||
|
||||
def test_image_aspect_mismatch_row_reflects_post_render_semantic():
|
||||
section = _extract_section_3_1(_read_spec_text())
|
||||
row_line = next(
|
||||
(
|
||||
line
|
||||
for line in section.splitlines()
|
||||
if line.lstrip().startswith("| `image_aspect_mismatch`")
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert row_line is not None, "image_aspect_mismatch row not found"
|
||||
assert "Post-render" in row_line or "post-render" in row_line, (
|
||||
"Row must mark the signal as post-render (Stage 1 guardrail)"
|
||||
)
|
||||
assert "fail_reasons" in row_line, (
|
||||
"Row must reference `fail_reasons` so the vocabulary mirrors the "
|
||||
"visual_runtime_check output"
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""IMP-33 u10 — AST isolation guard for the AI fallback package.
|
||||
|
||||
Structural defence: parse every ``*.py`` file under
|
||||
``src/phase_z2_ai_fallback/`` and assert that none of them imports a
|
||||
Phase Q runtime module, the Kei API client, or any ``phase_z2_*`` runtime
|
||||
module (e.g. ``phase_z2_pipeline``). Even if a future patch wires such a
|
||||
module by accident, this AST scan catches it before runtime and protects
|
||||
the PZ-1 invariant (normal-path AI call count = 0).
|
||||
|
||||
Allowed imports inside the fallback package:
|
||||
|
||||
* Standard library modules.
|
||||
* ``anthropic`` (u4 client) and ``pydantic`` (u2 schema).
|
||||
* ``src.config`` (u1 settings — single source of truth for policy knobs).
|
||||
* Other modules inside ``src.phase_z2_ai_fallback`` (intra-package).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
PACKAGE_ROOT = pathlib.Path(__file__).resolve().parents[2] / "src" / "phase_z2_ai_fallback"
|
||||
|
||||
_ALLOWED_SRC_PREFIXES: tuple[str, ...] = (
|
||||
"src.config",
|
||||
"src.phase_z2_ai_fallback",
|
||||
)
|
||||
|
||||
_ALLOWED_TOP_LEVEL: frozenset[str] = frozenset(
|
||||
{
|
||||
"anthropic",
|
||||
"pydantic",
|
||||
"__future__",
|
||||
"ast",
|
||||
"dataclasses",
|
||||
"enum",
|
||||
"json",
|
||||
"pathlib",
|
||||
"random",
|
||||
"time",
|
||||
"typing",
|
||||
}
|
||||
)
|
||||
|
||||
_FORBIDDEN_PHASE_Q_MODULES: frozenset[str] = frozenset(
|
||||
{
|
||||
"src.pipeline",
|
||||
"src.pipeline_v2",
|
||||
"src.block_assembler",
|
||||
"src.block_assembler_b2",
|
||||
"src.block_matcher_tfidf",
|
||||
"src.block_reference",
|
||||
"src.block_search",
|
||||
"src.block_selector",
|
||||
"src.content_editor",
|
||||
"src.design_director",
|
||||
"src.html_generator",
|
||||
"src.html_validator",
|
||||
"src.renderer",
|
||||
"src.mdx_normalizer",
|
||||
"src.fit_verifier",
|
||||
"src.slide_measurer",
|
||||
"src.space_allocator",
|
||||
"src.kei_client",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _module_files() -> list[pathlib.Path]:
|
||||
return sorted(p for p in PACKAGE_ROOT.glob("*.py") if p.name != "__pycache__")
|
||||
|
||||
|
||||
def _imported_names(tree: ast.AST) -> list[str]:
|
||||
names: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
names.append(alias.name)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module is not None:
|
||||
names.append(node.module)
|
||||
return names
|
||||
|
||||
|
||||
def _parse(path: pathlib.Path) -> ast.AST:
|
||||
return ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
|
||||
|
||||
def _is_allowed(name: str) -> bool:
|
||||
for prefix in _ALLOWED_SRC_PREFIXES:
|
||||
if name == prefix or name.startswith(prefix + "."):
|
||||
return True
|
||||
top = name.split(".", 1)[0]
|
||||
return top in _ALLOWED_TOP_LEVEL
|
||||
|
||||
|
||||
def test_fallback_package_root_exists() -> None:
|
||||
assert PACKAGE_ROOT.is_dir(), (
|
||||
f"fallback package root not found at {PACKAGE_ROOT!s}; module path "
|
||||
"is locked by IMP-31-GATE-AUDIT (src/phase_z2_ai_fallback/)."
|
||||
)
|
||||
files = _module_files()
|
||||
assert files, f"no .py modules found under {PACKAGE_ROOT!s}"
|
||||
|
||||
|
||||
def test_fallback_package_imports_are_whitelisted() -> None:
|
||||
violations: list[tuple[str, str]] = []
|
||||
for path in _module_files():
|
||||
for name in _imported_names(_parse(path)):
|
||||
if not _is_allowed(name):
|
||||
violations.append((path.name, name))
|
||||
assert not violations, (
|
||||
"fallback package imports outside the IMP-33 whitelist "
|
||||
f"(Phase Q / Kei / phase_z2_* runtime forbidden): {violations}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("forbidden_module", sorted(_FORBIDDEN_PHASE_Q_MODULES))
|
||||
def test_fallback_package_forbids_phase_q_and_kei_imports(forbidden_module: str) -> None:
|
||||
for path in _module_files():
|
||||
for name in _imported_names(_parse(path)):
|
||||
top2 = ".".join(name.split(".")[:2])
|
||||
assert top2 != forbidden_module and name != forbidden_module, (
|
||||
f"{path.name} imports forbidden module {name!r}; "
|
||||
f"{forbidden_module!r} is a Phase Q / Kei runtime module and "
|
||||
"must not be reachable from the AI fallback package."
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_package_forbids_phase_z2_pipeline_imports() -> None:
|
||||
for path in _module_files():
|
||||
for name in _imported_names(_parse(path)):
|
||||
assert not name.startswith("src.phase_z2_pipeline"), (
|
||||
f"{path.name} imports {name!r}; the Phase Z2 pipeline runtime "
|
||||
"module must not be reachable from the AI fallback package "
|
||||
"(PZ-1: normal-path AI=0)."
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_package_forbids_other_phase_z2_runtime_imports() -> None:
|
||||
violations: list[tuple[str, str]] = []
|
||||
for path in _module_files():
|
||||
for name in _imported_names(_parse(path)):
|
||||
if name.startswith("src.phase_z2_") and not name.startswith(
|
||||
"src.phase_z2_ai_fallback"
|
||||
):
|
||||
violations.append((path.name, name))
|
||||
assert not violations, (
|
||||
"fallback package imports another phase_z2_* runtime module; "
|
||||
f"violations: {violations}"
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""IMP-33 u6 — AI fallback cache gate tests.
|
||||
|
||||
Verifies the IMP-46 gate contract:
|
||||
* ``read_proposal`` is a stub (returns None until IMP-46).
|
||||
* ``save_proposal`` enforces both gates before any write attempt.
|
||||
* Storage itself raises NotImplementedError (IMP-46 marker).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_ai_fallback.cache import (
|
||||
AiFallbackCacheGateError,
|
||||
read_proposal,
|
||||
save_proposal,
|
||||
)
|
||||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal, ProposalKind
|
||||
|
||||
|
||||
def _proposal() -> AiFallbackProposal:
|
||||
return AiFallbackProposal(
|
||||
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||
payload={"item_parser": "bullet_v2"},
|
||||
rationale="u6-test",
|
||||
)
|
||||
|
||||
|
||||
def test_read_proposal_returns_none_for_any_key():
|
||||
assert read_proposal("frame=foo|cardinality=3") is None
|
||||
|
||||
|
||||
def test_read_proposal_rejects_empty_key():
|
||||
with pytest.raises(ValueError):
|
||||
read_proposal("")
|
||||
|
||||
|
||||
def test_save_rejects_when_visual_check_failed():
|
||||
with pytest.raises(AiFallbackCacheGateError) as exc:
|
||||
save_proposal(
|
||||
"k", _proposal(), visual_check_passed=False, user_approved=True
|
||||
)
|
||||
assert "visual_check_passed" in str(exc.value)
|
||||
|
||||
|
||||
def test_save_rejects_when_user_not_approved():
|
||||
with pytest.raises(AiFallbackCacheGateError) as exc:
|
||||
save_proposal(
|
||||
"k", _proposal(), visual_check_passed=True, user_approved=False
|
||||
)
|
||||
assert "user_approved" in str(exc.value)
|
||||
|
||||
|
||||
def test_save_rejects_when_both_gates_false():
|
||||
with pytest.raises(AiFallbackCacheGateError):
|
||||
save_proposal(
|
||||
"k", _proposal(), visual_check_passed=False, user_approved=False
|
||||
)
|
||||
|
||||
|
||||
def test_save_raises_not_implemented_when_both_gates_pass():
|
||||
with pytest.raises(NotImplementedError) as exc:
|
||||
save_proposal(
|
||||
"k", _proposal(), visual_check_passed=True, user_approved=True
|
||||
)
|
||||
assert "IMP-46" in str(exc.value)
|
||||
|
||||
|
||||
def test_save_rejects_empty_key():
|
||||
with pytest.raises(ValueError):
|
||||
save_proposal(
|
||||
"", _proposal(), visual_check_passed=True, user_approved=True
|
||||
)
|
||||
|
||||
|
||||
def test_save_rejects_non_proposal_object():
|
||||
with pytest.raises(TypeError):
|
||||
save_proposal(
|
||||
"k",
|
||||
{"proposal_kind": "builder_options_patch"}, # type: ignore[arg-type]
|
||||
visual_check_passed=True,
|
||||
user_approved=True,
|
||||
)
|
||||
|
||||
|
||||
def test_gate_error_is_not_notimplementederror():
|
||||
with pytest.raises(AiFallbackCacheGateError):
|
||||
save_proposal(
|
||||
"k", _proposal(), visual_check_passed=False, user_approved=True
|
||||
)
|
||||
assert not issubclass(AiFallbackCacheGateError, NotImplementedError)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""IMP-33 u4 — fallback client mock tests.
|
||||
|
||||
Scope (Stage 2 plan, u4):
|
||||
- Success path returns a validated ``AiFallbackProposal`` (u2 schema).
|
||||
- Transient errors (timeout / connection / 429 / 5xx) are retried.
|
||||
- Retries exhausted → last transient error propagates + consec-fail bumps.
|
||||
- Non-transient errors are NOT retried.
|
||||
- Per-run budget exhaustion raises ``AiFallbackBudgetExceeded``.
|
||||
- Circuit breaker opens after consecutive-failure threshold reached.
|
||||
- Policy values are sourced from ``settings`` (no inline literals).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from src.config import settings
|
||||
from src.phase_z2_ai_fallback.client import (
|
||||
AiFallbackBudgetExceeded,
|
||||
AiFallbackCircuitOpen,
|
||||
AiFallbackClient,
|
||||
)
|
||||
|
||||
|
||||
class _NonTransient(Exception):
|
||||
"""Stand-in for any anthropic error not in the transient whitelist."""
|
||||
|
||||
|
||||
def _ok_response() -> SimpleNamespace:
|
||||
block = SimpleNamespace(
|
||||
text=json.dumps(
|
||||
{
|
||||
"proposal_kind": "builder_options_patch",
|
||||
"payload": {"k": 1},
|
||||
"rationale": "ok",
|
||||
}
|
||||
)
|
||||
)
|
||||
return SimpleNamespace(content=[block])
|
||||
|
||||
|
||||
def _timeout_err() -> anthropic.APITimeoutError:
|
||||
return anthropic.APITimeoutError(request=httpx.Request("POST", "https://x"))
|
||||
|
||||
|
||||
def _connection_err() -> anthropic.APIConnectionError:
|
||||
return anthropic.APIConnectionError(request=httpx.Request("POST", "https://x"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(time, "sleep", lambda _s: None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_settings():
|
||||
snapshot = settings.model_dump()
|
||||
yield
|
||||
for key, value in snapshot.items():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def _client_with(side_effect=None, return_value=None) -> AiFallbackClient:
|
||||
fake = MagicMock()
|
||||
if side_effect is not None:
|
||||
fake.messages.create.side_effect = side_effect
|
||||
else:
|
||||
fake.messages.create.return_value = return_value or _ok_response()
|
||||
return AiFallbackClient(client=fake)
|
||||
|
||||
|
||||
def test_success_returns_validated_proposal() -> None:
|
||||
out = _client_with().request_proposal({"system": "s", "user": "u"})
|
||||
assert out.proposal_kind.value == "builder_options_patch"
|
||||
assert out.payload == {"k": 1}
|
||||
|
||||
|
||||
def test_call_uses_settings_model() -> None:
|
||||
fake = MagicMock()
|
||||
fake.messages.create.return_value = _ok_response()
|
||||
AiFallbackClient(client=fake).request_proposal({"system": "s", "user": "u"})
|
||||
kwargs = fake.messages.create.call_args.kwargs
|
||||
assert kwargs["model"] == settings.ai_fallback_model
|
||||
|
||||
|
||||
def test_transient_retries_then_succeeds() -> None:
|
||||
fake = MagicMock()
|
||||
fake.messages.create.side_effect = [_timeout_err(), _connection_err(), _ok_response()]
|
||||
AiFallbackClient(client=fake).request_proposal({"system": "s", "user": "u"})
|
||||
assert fake.messages.create.call_count == 3
|
||||
|
||||
|
||||
def test_retries_exhausted_raises_last_transient(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "ai_fallback_max_retries", 1)
|
||||
fake = MagicMock()
|
||||
fake.messages.create.side_effect = [_timeout_err(), _timeout_err()]
|
||||
c = AiFallbackClient(client=fake)
|
||||
with pytest.raises(anthropic.APITimeoutError):
|
||||
c.request_proposal({"system": "s", "user": "u"})
|
||||
assert fake.messages.create.call_count == 2
|
||||
assert c._consecutive_failures == 1
|
||||
|
||||
|
||||
def test_non_transient_not_retried() -> None:
|
||||
fake = MagicMock()
|
||||
fake.messages.create.side_effect = _NonTransient("boom")
|
||||
c = AiFallbackClient(client=fake)
|
||||
with pytest.raises(_NonTransient):
|
||||
c.request_proposal({"system": "s", "user": "u"})
|
||||
assert fake.messages.create.call_count == 1
|
||||
|
||||
|
||||
def test_budget_exceeded(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "ai_fallback_budget_per_run", 1)
|
||||
c = _client_with()
|
||||
c.request_proposal({"system": "s", "user": "u"})
|
||||
with pytest.raises(AiFallbackBudgetExceeded):
|
||||
c.request_proposal({"system": "s", "user": "u"})
|
||||
|
||||
|
||||
def test_circuit_breaker_opens(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "ai_fallback_circuit_breaker_threshold", 1)
|
||||
monkeypatch.setattr(settings, "ai_fallback_max_retries", 0)
|
||||
fake = MagicMock()
|
||||
fake.messages.create.side_effect = _timeout_err()
|
||||
c = AiFallbackClient(client=fake)
|
||||
with pytest.raises(anthropic.APITimeoutError):
|
||||
c.request_proposal({"system": "s", "user": "u"})
|
||||
with pytest.raises(AiFallbackCircuitOpen):
|
||||
c.request_proposal({"system": "s", "user": "u"})
|
||||
|
||||
|
||||
def test_backoff_uses_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sleep delay must be derived from settings (no inline literals)."""
|
||||
monkeypatch.setattr(settings, "ai_fallback_max_retries", 1)
|
||||
monkeypatch.setattr(settings, "ai_fallback_backoff_base_s", 0.25)
|
||||
monkeypatch.setattr(settings, "ai_fallback_backoff_cap_s", 0.5)
|
||||
monkeypatch.setattr(settings, "ai_fallback_backoff_jitter", 0.0)
|
||||
sleeps: list[float] = []
|
||||
monkeypatch.setattr(time, "sleep", lambda s: sleeps.append(s))
|
||||
fake = MagicMock()
|
||||
fake.messages.create.side_effect = [_timeout_err(), _ok_response()]
|
||||
AiFallbackClient(client=fake).request_proposal({"system": "s", "user": "u"})
|
||||
# attempt 0 transient → sleep(min(cap, base * 2**0) + jitter==0) = 0.25
|
||||
assert sleeps == [0.25]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""IMP-33 u11 — docs sync verification.
|
||||
|
||||
Verifies that the binding architecture docs reference the IMP-33 runtime
|
||||
module surface introduced by u1~u10. Scope is intentionally narrow per the
|
||||
Stage 2 plan: module path, Step 12 entry, Step 17 entry, cascade order, and
|
||||
the IMP-46 cache gate. Failure here means the docs and the code have
|
||||
drifted — fix the docs (or the code) before merging.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
DOCS_ROOT = Path(__file__).resolve().parents[2] / "docs" / "architecture"
|
||||
CARVE_OUT_DOC = DOCS_ROOT / "IMP-17-CARVE-OUT.md"
|
||||
GATE_AUDIT_DOC = DOCS_ROOT / "IMP-31-GATE-AUDIT.md"
|
||||
|
||||
|
||||
def _read(doc: Path) -> str:
|
||||
assert doc.is_file(), f"binding doc missing: {doc}"
|
||||
return doc.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"needle",
|
||||
[
|
||||
# Module path lock.
|
||||
"src/phase_z2_ai_fallback/",
|
||||
# Step 12 entry.
|
||||
"gather_step12_ai_repair_proposals",
|
||||
# Step 17 entry + blocked-reason sentinel.
|
||||
"gather_step17_ai_repair_proposals",
|
||||
"step17_ai_blocked_imp_34_35_prerequisites_missing",
|
||||
# Cascade order single source of truth.
|
||||
"OVERFLOW_CASCADE_ORDER",
|
||||
"(DETERMINISTIC, POPUP, AI_REPAIR, USER_OVERRIDE)",
|
||||
# IMP-46 cache gate.
|
||||
"visual_check_passed",
|
||||
"user_approved",
|
||||
"AiFallbackCacheGateError",
|
||||
# PZ-1 normal-path AI=0 invariant.
|
||||
"ai_fallback_enabled",
|
||||
],
|
||||
)
|
||||
def test_carve_out_doc_references_runtime_surface(needle: str) -> None:
|
||||
assert needle in _read(CARVE_OUT_DOC), (
|
||||
f"IMP-17-CARVE-OUT.md missing binding reference: {needle!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_gate_audit_reflects_scaffolded_module() -> None:
|
||||
body = _read(GATE_AUDIT_DOC)
|
||||
assert "scaffolded under IMP-33" in body, (
|
||||
"IMP-31-GATE-AUDIT.md must record that the fallback module path is "
|
||||
"scaffolded (not 'not created this cycle')."
|
||||
)
|
||||
assert "ai_fallback_enabled" in body, (
|
||||
"IMP-31-GATE-AUDIT.md must record the flag default that keeps PZ-1 "
|
||||
"(normal-path AI=0) intact while the 3-condition gate is open."
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
"""IMP-33 u3 — fallback prompt builder tests.
|
||||
|
||||
Scope (Stage 2 plan, u3):
|
||||
- Prompt is built only when V4 route == 'ai_adaptation_required'.
|
||||
- System prompt declares MDX READ-ONLY and pins the u2 whitelist.
|
||||
- System prompt forbids the u2 forbidden kinds + frame_id swap.
|
||||
- User payload carries all 6 declared inputs and labels MDX READ_ONLY.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_ai_fallback.prompts import (
|
||||
SYSTEM_PROMPT,
|
||||
V4_ROUTE_AI_ADAPTATION,
|
||||
build_ai_fallback_prompt,
|
||||
)
|
||||
from src.phase_z2_ai_fallback.schema import FORBIDDEN_KINDS, ProposalKind
|
||||
|
||||
|
||||
def _v4(route: str = V4_ROUTE_AI_ADAPTATION) -> dict:
|
||||
return {
|
||||
"route": route,
|
||||
"cardinality": {"strict": 3},
|
||||
"label": "restructure",
|
||||
"frame_id": 1171281190,
|
||||
"rank": 1,
|
||||
}
|
||||
|
||||
|
||||
def _inputs(route: str = V4_ROUTE_AI_ADAPTATION) -> dict:
|
||||
return {
|
||||
"v4_result": _v4(route),
|
||||
"frame_contract": {"template_id": "three_parallel_requirements"},
|
||||
"frame_visual_html": "<section class='f13b'/>",
|
||||
"figma_partial_json": {"nodes": []},
|
||||
"internal_region": {"id": "region_top", "bbox": [0, 0, 1200, 320]},
|
||||
"mdx_text": "# 대목차\n- 항목 1\n- 항목 2\n- 항목 3",
|
||||
}
|
||||
|
||||
|
||||
def test_system_prompt_declares_mdx_read_only() -> None:
|
||||
assert "READ-ONLY" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_lists_all_whitelisted_kinds() -> None:
|
||||
for kind in ProposalKind:
|
||||
assert kind.value in SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_forbids_all_forbidden_kinds() -> None:
|
||||
for forbidden in FORBIDDEN_KINDS:
|
||||
assert forbidden in SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_locks_frame_id_swap() -> None:
|
||||
assert "frame_id" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_build_prompt_returns_system_and_user() -> None:
|
||||
prompt = build_ai_fallback_prompt(**_inputs())
|
||||
assert set(prompt.keys()) == {"system", "user"}
|
||||
assert prompt["system"] == SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_user_payload_carries_all_inputs_and_marks_mdx_read_only() -> None:
|
||||
prompt = build_ai_fallback_prompt(**_inputs())
|
||||
payload = json.loads(prompt["user"])
|
||||
assert payload["v4"]["route"] == V4_ROUTE_AI_ADAPTATION
|
||||
assert payload["v4"]["cardinality"] == {"strict": 3}
|
||||
assert payload["v4"]["frame_id"] == 1171281190
|
||||
assert payload["frame_contract"]["template_id"] == "three_parallel_requirements"
|
||||
assert payload["frame_visual_html"] == "<section class='f13b'/>"
|
||||
assert payload["figma_partial_json"] == {"nodes": []}
|
||||
assert payload["internal_region"]["id"] == "region_top"
|
||||
assert "mdx_text_READ_ONLY" in payload
|
||||
assert payload["mdx_text_READ_ONLY"].startswith("# 대목차")
|
||||
assert "mdx_text" not in payload # only the READ_ONLY key, not a writable alias
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route", ["direct_render", "deterministic_minor_adjustment", "design_reference_only", None]
|
||||
)
|
||||
def test_non_ai_route_rejected(route) -> None:
|
||||
inputs = _inputs(route=route) if route is not None else _inputs()
|
||||
if route is None:
|
||||
inputs["v4_result"].pop("route")
|
||||
with pytest.raises(ValueError, match=V4_ROUTE_AI_ADAPTATION):
|
||||
build_ai_fallback_prompt(**inputs)
|
||||
|
||||
|
||||
def test_cardinality_signature_alias_accepted() -> None:
|
||||
"""Some V4 callers expose ``cardinality_signature``; both keys must resolve."""
|
||||
inputs = _inputs()
|
||||
inputs["v4_result"].pop("cardinality")
|
||||
inputs["v4_result"]["cardinality_signature"] = {"strict": 4}
|
||||
payload = json.loads(build_ai_fallback_prompt(**inputs)["user"])
|
||||
assert payload["v4"]["cardinality"] == {"strict": 4}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""IMP-33 u7 — AI fallback router tests.
|
||||
|
||||
Scope (Stage 2 plan, u7):
|
||||
- flag-off gate returns None and does NOT touch the client / prompt
|
||||
- route-mismatch gate returns None and does NOT touch the client / prompt
|
||||
- cache-hit short-circuits the client and still re-validates against the
|
||||
current frame contract (defence-in-depth)
|
||||
- cache-miss calls the client and validates the returned proposal
|
||||
- validation errors propagate
|
||||
- budget / circuit exceptions from u4 propagate
|
||||
- router never imports ``save_proposal`` (cache save is caller-driven
|
||||
after visual_check + user_approved per u6 IMP-46 gate)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_ai_fallback import AiFallbackProposal, ProposalKind
|
||||
from src.phase_z2_ai_fallback import router as router_mod
|
||||
from src.phase_z2_ai_fallback.client import (
|
||||
AiFallbackBudgetExceeded,
|
||||
AiFallbackCircuitOpen,
|
||||
AiFallbackClient,
|
||||
)
|
||||
from src.phase_z2_ai_fallback.router import route_ai_fallback
|
||||
from src.phase_z2_ai_fallback.validate import AiFallbackValidationError
|
||||
|
||||
|
||||
_FRAME_CONTRACT = {
|
||||
"frame_id": 1171281190,
|
||||
"sub_zones": [{"id": "pillar_1", "accepts": ["text_block"]}],
|
||||
"payload": {"builder_options": {"item_parser": "pillar_item"}},
|
||||
}
|
||||
_REGION = {"id": "zone_top.region_a"}
|
||||
_V4_AI = {
|
||||
"route": "ai_adaptation_required",
|
||||
"cardinality": "many",
|
||||
"frame_id": 1171281190,
|
||||
"rank": 1,
|
||||
}
|
||||
_V4_NOT_AI = {"route": "light_edit", "cardinality": "many"}
|
||||
|
||||
|
||||
def _make_proposal(
|
||||
kind: ProposalKind = ProposalKind.PARTIAL_OVERRIDES,
|
||||
payload: dict | None = None,
|
||||
) -> AiFallbackProposal:
|
||||
return AiFallbackProposal(
|
||||
proposal_kind=kind,
|
||||
payload=payload if payload is not None else {"slots": {"pillar_1": "a"}},
|
||||
)
|
||||
|
||||
|
||||
def _call_kwargs() -> dict:
|
||||
return dict(
|
||||
cache_key="frame:1171281190:cardinality:many",
|
||||
v4_result=_V4_AI,
|
||||
frame_contract=_FRAME_CONTRACT,
|
||||
frame_visual_html="<div></div>",
|
||||
figma_partial_json={},
|
||||
internal_region=_REGION,
|
||||
mdx_text="# example\n- a\n- b",
|
||||
)
|
||||
|
||||
|
||||
def test_router_returns_none_when_flag_off(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", False)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
result = route_ai_fallback(**_call_kwargs(), client=client)
|
||||
assert result is None
|
||||
client.request_proposal.assert_not_called()
|
||||
|
||||
|
||||
def test_router_returns_none_when_route_not_ai_adaptation(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
kwargs = _call_kwargs()
|
||||
kwargs["v4_result"] = _V4_NOT_AI
|
||||
result = route_ai_fallback(**kwargs, client=client)
|
||||
assert result is None
|
||||
client.request_proposal.assert_not_called()
|
||||
|
||||
|
||||
def test_router_returns_cached_when_cache_hit(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
cached = _make_proposal()
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: cached)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
result = route_ai_fallback(**_call_kwargs(), client=client)
|
||||
assert result is cached
|
||||
client.request_proposal.assert_not_called()
|
||||
|
||||
|
||||
def test_router_validates_cached_proposal(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
bad_cached = AiFallbackProposal(
|
||||
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||
payload={"unknown_key": "x"},
|
||||
)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: bad_cached)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
with pytest.raises(AiFallbackValidationError):
|
||||
route_ai_fallback(**_call_kwargs(), client=client)
|
||||
client.request_proposal.assert_not_called()
|
||||
|
||||
|
||||
def test_router_calls_client_and_returns_validated_proposal(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
||||
proposal = _make_proposal()
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
client.request_proposal.return_value = proposal
|
||||
result = route_ai_fallback(**_call_kwargs(), client=client)
|
||||
assert result is proposal
|
||||
client.request_proposal.assert_called_once()
|
||||
sent_prompt = client.request_proposal.call_args.args[0]
|
||||
assert set(sent_prompt.keys()) == {"system", "user"}
|
||||
|
||||
|
||||
def test_router_propagates_validation_error(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
||||
bad = AiFallbackProposal(
|
||||
proposal_kind=ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||
payload={"unknown_key": "x"},
|
||||
)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
client.request_proposal.return_value = bad
|
||||
with pytest.raises(AiFallbackValidationError):
|
||||
route_ai_fallback(**_call_kwargs(), client=client)
|
||||
|
||||
|
||||
def test_router_propagates_budget_exceeded(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
client.request_proposal.side_effect = AiFallbackBudgetExceeded("over")
|
||||
with pytest.raises(AiFallbackBudgetExceeded):
|
||||
route_ai_fallback(**_call_kwargs(), client=client)
|
||||
|
||||
|
||||
def test_router_propagates_circuit_open(monkeypatch):
|
||||
monkeypatch.setattr(router_mod.settings, "ai_fallback_enabled", True)
|
||||
monkeypatch.setattr(router_mod, "read_proposal", lambda key: None)
|
||||
client = MagicMock(spec=AiFallbackClient)
|
||||
client.request_proposal.side_effect = AiFallbackCircuitOpen("tripped")
|
||||
with pytest.raises(AiFallbackCircuitOpen):
|
||||
route_ai_fallback(**_call_kwargs(), client=client)
|
||||
|
||||
|
||||
def test_router_does_not_import_save_proposal():
|
||||
"""Cache save is caller-driven AFTER visual_check + user_approved (u6 IMP-46
|
||||
gate); structurally guaranteed by NOT importing save_proposal in the router."""
|
||||
assert not hasattr(router_mod, "save_proposal")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""IMP-33 u2 — AiFallbackProposal schema tests.
|
||||
|
||||
Scope (Stage 2 plan, u2):
|
||||
- Whitelisted proposal_kind values are accepted.
|
||||
- Forbidden output forms are rejected: mdx_text / frame_id_change / raw_html / raw_css.
|
||||
- extra fields outside the declared schema are rejected (MDX read-only signal).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.phase_z2_ai_fallback import AiFallbackProposal, ProposalKind
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind_value",
|
||||
[
|
||||
"builder_options_patch",
|
||||
"partial_overrides",
|
||||
"slot_mapping_proposal",
|
||||
],
|
||||
)
|
||||
def test_whitelisted_proposal_kinds_accepted(kind_value: str) -> None:
|
||||
proposal = AiFallbackProposal(proposal_kind=kind_value)
|
||||
assert proposal.proposal_kind == ProposalKind(kind_value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"forbidden",
|
||||
["mdx_text", "frame_id_change", "raw_html", "raw_css"],
|
||||
)
|
||||
def test_forbidden_proposal_kinds_rejected(forbidden: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
AiFallbackProposal(proposal_kind=forbidden)
|
||||
|
||||
|
||||
def test_unknown_proposal_kind_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
AiFallbackProposal(proposal_kind="something_else")
|
||||
|
||||
|
||||
def test_extra_fields_rejected() -> None:
|
||||
"""`extra=forbid` keeps the AI from smuggling raw_html/mdx_text alongside a valid kind."""
|
||||
with pytest.raises(ValidationError):
|
||||
AiFallbackProposal(proposal_kind="partial_overrides", raw_html="<div/>")
|
||||
@@ -0,0 +1,193 @@
|
||||
"""IMP-33 u8 — Step 12 AI repair wiring tests.
|
||||
|
||||
Covers the two structural gates layered on top of the u7 router:
|
||||
* IMP-30 provisional gate (only provisional units may invoke AI repair)
|
||||
* Reject gate (route_hint=design_reference_only NEVER calls AI)
|
||||
Plus the record-shape contract returned for downstream Step 12 artifacts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.phase_z2_ai_fallback import step12 as step12_mod
|
||||
from src.phase_z2_ai_fallback.schema import AiFallbackProposal, ProposalKind
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUnit:
|
||||
label: str | None
|
||||
provisional: bool
|
||||
frame_template_id: str = "tmpl"
|
||||
frame_id: str = "fid"
|
||||
source_section_ids: list[str] = field(default_factory=lambda: ["s1"])
|
||||
raw_content: str = "raw"
|
||||
v4_rank: int | None = 1
|
||||
|
||||
|
||||
_ROUTE_HINTS: dict[str | None, str | None] = {
|
||||
"use_as_is": "direct_render",
|
||||
"light_edit": "deterministic_minor_adjustment",
|
||||
"restructure": "ai_adaptation_required",
|
||||
"reject": "design_reference_only",
|
||||
None: None,
|
||||
}
|
||||
|
||||
|
||||
def _route_for_label(label: str | None) -> str | None:
|
||||
return _ROUTE_HINTS.get(label)
|
||||
|
||||
|
||||
def _get_contract(_tid: str) -> dict[str, Any]:
|
||||
return {"frame_id": "fid", "payload": {"builder_options": {}}, "sub_zones": []}
|
||||
|
||||
|
||||
def _frame_visual(_tid: str) -> str:
|
||||
return "<html></html>"
|
||||
|
||||
|
||||
def _call(
|
||||
units: list[FakeUnit],
|
||||
*,
|
||||
route_ai_fallback: Any | None = None,
|
||||
**overrides: Any,
|
||||
) -> list[dict]:
|
||||
if route_ai_fallback is not None:
|
||||
step12_mod.route_ai_fallback = route_ai_fallback # type: ignore[assignment]
|
||||
kwargs: dict[str, Any] = dict(
|
||||
route_for_label=_route_for_label,
|
||||
get_contract_fn=_get_contract,
|
||||
frame_visual_loader=_frame_visual,
|
||||
)
|
||||
kwargs.update(overrides)
|
||||
return step12_mod.gather_step12_ai_repair_proposals(units, **kwargs)
|
||||
|
||||
|
||||
def test_non_provisional_unit_is_skipped_without_ai_call(monkeypatch):
|
||||
router = MagicMock()
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [FakeUnit(label="restructure", provisional=False)]
|
||||
records = _call(units)
|
||||
assert records[0]["ai_called"] is False
|
||||
assert records[0]["skip_reason"] == "not_provisional"
|
||||
assert records[0]["provisional"] is False
|
||||
router.assert_not_called()
|
||||
|
||||
|
||||
def test_reject_route_is_skipped_without_ai_call(monkeypatch):
|
||||
router = MagicMock()
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [FakeUnit(label="reject", provisional=True)]
|
||||
records = _call(units)
|
||||
assert records[0]["ai_called"] is False
|
||||
assert records[0]["skip_reason"] == "design_reference_only_no_ai"
|
||||
assert records[0]["route_hint"] == "design_reference_only"
|
||||
router.assert_not_called()
|
||||
|
||||
|
||||
def test_non_ai_route_is_skipped_with_reason(monkeypatch):
|
||||
router = MagicMock()
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [FakeUnit(label="light_edit", provisional=True)]
|
||||
records = _call(units)
|
||||
assert records[0]["ai_called"] is False
|
||||
assert records[0]["skip_reason"] == (
|
||||
"route_not_ai_adaptation:deterministic_minor_adjustment"
|
||||
)
|
||||
router.assert_not_called()
|
||||
|
||||
|
||||
def test_router_short_circuit_returns_none_skip_reason(monkeypatch):
|
||||
router = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
records = _call(units)
|
||||
assert records[0]["ai_called"] is False
|
||||
assert records[0]["skip_reason"] == "router_short_circuit"
|
||||
assert records[0]["proposal"] is None
|
||||
router.assert_called_once()
|
||||
|
||||
|
||||
def test_ai_adaptation_call_records_proposal(monkeypatch):
|
||||
proposal = AiFallbackProposal(
|
||||
proposal_kind=ProposalKind.PARTIAL_OVERRIDES,
|
||||
payload={"slots": {"s_text": "x"}},
|
||||
rationale="r",
|
||||
)
|
||||
router = MagicMock(return_value=proposal)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
records = _call(units)
|
||||
rec = records[0]
|
||||
assert rec["ai_called"] is True
|
||||
assert rec["skip_reason"] is None
|
||||
assert rec["proposal"]["proposal_kind"] == "partial_overrides"
|
||||
router.assert_called_once()
|
||||
kwargs = router.call_args.kwargs
|
||||
assert kwargs["v4_result"]["route"] == "ai_adaptation_required"
|
||||
assert kwargs["v4_result"]["label"] == "restructure"
|
||||
|
||||
|
||||
def test_router_exception_is_captured_per_record(monkeypatch):
|
||||
router = MagicMock(side_effect=RuntimeError("transient_boom"))
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
records = _call(units)
|
||||
rec = records[0]
|
||||
assert rec["ai_called"] is True
|
||||
assert rec["proposal"] is None
|
||||
assert rec["error"] == "RuntimeError: transient_boom"
|
||||
router.assert_called_once()
|
||||
|
||||
|
||||
def test_mixed_units_each_independently_classified(monkeypatch):
|
||||
router = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [
|
||||
FakeUnit(label="use_as_is", provisional=False),
|
||||
FakeUnit(label="reject", provisional=True),
|
||||
FakeUnit(label="restructure", provisional=True),
|
||||
FakeUnit(label="restructure", provisional=False),
|
||||
]
|
||||
records = _call(units)
|
||||
assert [r["skip_reason"] for r in records] == [
|
||||
"not_provisional",
|
||||
"design_reference_only_no_ai",
|
||||
"router_short_circuit",
|
||||
"not_provisional",
|
||||
]
|
||||
assert router.call_count == 1
|
||||
|
||||
|
||||
def test_cache_key_includes_template_and_section_ids(monkeypatch):
|
||||
router = MagicMock(return_value=None)
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", router)
|
||||
units = [
|
||||
FakeUnit(
|
||||
label="restructure",
|
||||
provisional=True,
|
||||
frame_template_id="tmpl_abc",
|
||||
source_section_ids=["02-1", "02-2"],
|
||||
)
|
||||
]
|
||||
_call(units)
|
||||
assert router.call_args.kwargs["cache_key"] == "tmpl_abc::02-1,02-2"
|
||||
|
||||
|
||||
def test_record_shape_contract_is_stable(monkeypatch):
|
||||
monkeypatch.setattr(step12_mod, "route_ai_fallback", MagicMock(return_value=None))
|
||||
units = [FakeUnit(label="reject", provisional=True)]
|
||||
rec = _call(units)[0]
|
||||
assert set(rec.keys()) == {
|
||||
"unit_index",
|
||||
"source_section_ids",
|
||||
"frame_template_id",
|
||||
"label",
|
||||
"route_hint",
|
||||
"provisional",
|
||||
"ai_called",
|
||||
"skip_reason",
|
||||
"proposal",
|
||||
"error",
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"""IMP-33 u9 — Step 17 AI repair wiring tests (BLOCKED until IMP-34 + IMP-35).
|
||||
|
||||
Covers:
|
||||
* :data:`OVERFLOW_CASCADE_ORDER` canonical order (4 stages).
|
||||
* :class:`OverflowCascadeStage` member values.
|
||||
* :data:`STEP17_AI_REPAIR_BLOCKED_REASON` constant value.
|
||||
* :func:`gather_step17_ai_repair_proposals` BLOCKED contract — every unit
|
||||
returns ``ai_called=False`` + ``skip_reason=STEP17_AI_REPAIR_BLOCKED_REASON``
|
||||
+ ``proposal=None`` regardless of provisional / label / route_hint.
|
||||
* Structural guarantee — the u9 module does NOT import
|
||||
:func:`src.phase_z2_ai_fallback.router.route_ai_fallback` or the
|
||||
``anthropic`` SDK. Step 17 AI repair stays structurally blocked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_ai_fallback import step17 as step17_mod
|
||||
from src.phase_z2_ai_fallback.step17 import (
|
||||
OVERFLOW_CASCADE_ORDER,
|
||||
STEP17_AI_REPAIR_BLOCKED_REASON,
|
||||
OverflowCascadeStage,
|
||||
gather_step17_ai_repair_proposals,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUnit:
|
||||
label: str | None
|
||||
provisional: bool
|
||||
frame_template_id: str = "tmpl"
|
||||
frame_id: str = "fid"
|
||||
source_section_ids: list[str] = field(default_factory=lambda: ["s1"])
|
||||
raw_content: str = "raw"
|
||||
v4_rank: int | None = 1
|
||||
|
||||
|
||||
_ROUTE_HINTS: dict[str | None, str | None] = {
|
||||
"use_as_is": "direct_render",
|
||||
"light_edit": "deterministic_minor_adjustment",
|
||||
"restructure": "ai_adaptation_required",
|
||||
"reject": "design_reference_only",
|
||||
None: None,
|
||||
}
|
||||
|
||||
|
||||
def _route_for_label(label: str | None) -> str | None:
|
||||
return _ROUTE_HINTS.get(label)
|
||||
|
||||
|
||||
# ─── Stage / order constants ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_overflow_cascade_order_is_canonical():
|
||||
assert OVERFLOW_CASCADE_ORDER == (
|
||||
OverflowCascadeStage.DETERMINISTIC,
|
||||
OverflowCascadeStage.POPUP,
|
||||
OverflowCascadeStage.AI_REPAIR,
|
||||
OverflowCascadeStage.USER_OVERRIDE,
|
||||
)
|
||||
|
||||
|
||||
def test_overflow_cascade_stage_string_values():
|
||||
assert OverflowCascadeStage.DETERMINISTIC.value == "deterministic"
|
||||
assert OverflowCascadeStage.POPUP.value == "popup"
|
||||
assert OverflowCascadeStage.AI_REPAIR.value == "ai_repair"
|
||||
assert OverflowCascadeStage.USER_OVERRIDE.value == "user_override"
|
||||
|
||||
|
||||
def test_step17_blocked_reason_constant_value():
|
||||
assert (
|
||||
STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
== "step17_ai_blocked_imp_34_35_prerequisites_missing"
|
||||
)
|
||||
|
||||
|
||||
# ─── BLOCKED contract: every unit returns blocked record ─────────────
|
||||
|
||||
|
||||
def test_gather_returns_one_record_per_unit():
|
||||
units = [
|
||||
FakeUnit(label="restructure", provisional=True),
|
||||
FakeUnit(label="reject", provisional=False),
|
||||
FakeUnit(label="use_as_is", provisional=True),
|
||||
]
|
||||
records = gather_step17_ai_repair_proposals(units, route_for_label=_route_for_label)
|
||||
assert len(records) == 3
|
||||
|
||||
|
||||
def test_gather_records_blocked_skip_reason():
|
||||
"""Every record must carry the IMP-34/IMP-35 prerequisite block reason."""
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
records = gather_step17_ai_repair_proposals(units, route_for_label=_route_for_label)
|
||||
assert records[0]["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
|
||||
|
||||
def test_gather_blocks_even_when_route_is_ai_adaptation_required():
|
||||
"""Provisional + ai_adaptation_required must NOT bypass the u9 block.
|
||||
|
||||
Stage 2 contract: AI repair at Step 17 is blocked behind IMP-34 + IMP-35
|
||||
regardless of V4 route hint. Only u8 (Step 12) is allowed to invoke AI today.
|
||||
"""
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["route_hint"] == "ai_adaptation_required"
|
||||
assert record["ai_called"] is False
|
||||
assert record["proposal"] is None
|
||||
assert record["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
|
||||
|
||||
def test_gather_blocks_reject_units_too():
|
||||
"""Reject units (design_reference_only) are also blocked at u9 — same reason."""
|
||||
units = [FakeUnit(label="reject", provisional=False)]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["ai_called"] is False
|
||||
assert record["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
|
||||
|
||||
def test_gather_records_proposal_none_and_no_error():
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["proposal"] is None
|
||||
assert record["error"] is None
|
||||
|
||||
|
||||
def test_gather_records_cascade_stage_is_ai_repair():
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["cascade_stage"] == OverflowCascadeStage.AI_REPAIR.value
|
||||
|
||||
|
||||
def test_gather_preserves_unit_metadata():
|
||||
units = [
|
||||
FakeUnit(
|
||||
label="restructure",
|
||||
provisional=True,
|
||||
frame_template_id="frame_05_overview",
|
||||
source_section_ids=["s1", "s2"],
|
||||
)
|
||||
]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["unit_index"] == 0
|
||||
assert record["frame_template_id"] == "frame_05_overview"
|
||||
assert record["source_section_ids"] == ["s1", "s2"]
|
||||
assert record["label"] == "restructure"
|
||||
assert record["provisional"] is True
|
||||
|
||||
|
||||
def test_gather_with_empty_units_returns_empty_list():
|
||||
records = gather_step17_ai_repair_proposals([], route_for_label=_route_for_label)
|
||||
assert records == []
|
||||
|
||||
|
||||
# ─── Structural guarantee: u9 must NOT import route_ai_fallback / anthropic ─
|
||||
|
||||
|
||||
def _u9_imports() -> list[str]:
|
||||
src_path = Path(step17_mod.__file__)
|
||||
tree = ast.parse(src_path.read_text(encoding="utf-8"))
|
||||
imports: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
module = node.module or ""
|
||||
for alias in node.names:
|
||||
imports.append(f"{module}.{alias.name}")
|
||||
return imports
|
||||
|
||||
|
||||
def test_step17_module_does_not_import_route_ai_fallback():
|
||||
"""u9 must not be able to reach the u7 router — structural block."""
|
||||
imports = _u9_imports()
|
||||
forbidden = {
|
||||
"src.phase_z2_ai_fallback.router.route_ai_fallback",
|
||||
"src.phase_z2_ai_fallback.router",
|
||||
}
|
||||
assert not any(imp in forbidden for imp in imports), imports
|
||||
assert not hasattr(step17_mod, "route_ai_fallback")
|
||||
|
||||
|
||||
def test_step17_module_does_not_import_anthropic():
|
||||
"""u9 must not reach the Anthropic SDK directly — AI=0 in this layer."""
|
||||
imports = _u9_imports()
|
||||
leaked = [imp for imp in imports if imp.split(".", 1)[0] == "anthropic"]
|
||||
assert leaked == [], leaked
|
||||
|
||||
|
||||
def test_step17_module_does_not_import_ai_fallback_client():
|
||||
"""u9 must not instantiate the u4 client either."""
|
||||
imports = _u9_imports()
|
||||
forbidden_prefixes = ("src.phase_z2_ai_fallback.client",)
|
||||
leaked = [
|
||||
imp for imp in imports if imp.startswith(forbidden_prefixes)
|
||||
]
|
||||
assert leaked == [], leaked
|
||||
@@ -0,0 +1,144 @@
|
||||
"""IMP-33 u5 — AI fallback validator tests.
|
||||
|
||||
Scope (Stage 2 plan, u5):
|
||||
- schema re-validation (defence-in-depth)
|
||||
- builder whitelist (BUILDER_OPTIONS_PATCH)
|
||||
- dropped-slot guard (PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL must keep
|
||||
every declared sub_zone slot present)
|
||||
- frame-swap guard (no payload.frame_id mutation; V4 rank-1 protected)
|
||||
- Internal Region containment (payload.region_id must match declared id)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_ai_fallback import AiFallbackProposal, ProposalKind
|
||||
from src.phase_z2_ai_fallback.validate import (
|
||||
AiFallbackValidationError,
|
||||
validate_proposal,
|
||||
)
|
||||
|
||||
|
||||
_FRAME_CONTRACT = {
|
||||
"frame_id": 1171281190,
|
||||
"sub_zones": [
|
||||
{"id": "pillar_1", "accepts": ["text_block"]},
|
||||
{"id": "pillar_2", "accepts": ["text_block"]},
|
||||
{"id": "pillar_3", "accepts": ["text_block"]},
|
||||
],
|
||||
"payload": {
|
||||
"builder_options": {
|
||||
"item_parser": "pillar_item",
|
||||
"array_root": "pillars",
|
||||
"role_field": "color_class",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_REGION = {"id": "zone_top.region_a"}
|
||||
|
||||
|
||||
def _make(kind: ProposalKind, payload: dict) -> AiFallbackProposal:
|
||||
return AiFallbackProposal(proposal_kind=kind, payload=payload)
|
||||
|
||||
|
||||
def test_builder_options_patch_accepts_whitelisted_keys() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||
{"item_parser": "alt_pillar_item"},
|
||||
)
|
||||
validate_proposal(proposal, frame_contract=_FRAME_CONTRACT)
|
||||
|
||||
|
||||
def test_builder_options_patch_rejects_unknown_key() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||
{"item_parser": "x", "padding_px": 10},
|
||||
)
|
||||
with pytest.raises(AiFallbackValidationError, match="builder whitelist"):
|
||||
validate_proposal(proposal, frame_contract=_FRAME_CONTRACT)
|
||||
|
||||
|
||||
def test_partial_overrides_requires_all_declared_slots() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.PARTIAL_OVERRIDES,
|
||||
{"slots": {"pillar_1": "a", "pillar_2": "b"}},
|
||||
)
|
||||
with pytest.raises(AiFallbackValidationError, match="dropped-slot guard"):
|
||||
validate_proposal(proposal, frame_contract=_FRAME_CONTRACT)
|
||||
|
||||
|
||||
def test_partial_overrides_with_all_slots_passes() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.PARTIAL_OVERRIDES,
|
||||
{"slots": {"pillar_1": "a", "pillar_2": "b", "pillar_3": "c"}},
|
||||
)
|
||||
validate_proposal(proposal, frame_contract=_FRAME_CONTRACT)
|
||||
|
||||
|
||||
def test_slot_mapping_proposal_requires_slots_dict() -> None:
|
||||
proposal = _make(ProposalKind.SLOT_MAPPING_PROPOSAL, {"slots": []})
|
||||
with pytest.raises(AiFallbackValidationError, match="dropped-slot guard"):
|
||||
validate_proposal(proposal, frame_contract=_FRAME_CONTRACT)
|
||||
|
||||
|
||||
def test_frame_swap_guard_rejects_mismatched_frame_id() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.BUILDER_OPTIONS_PATCH,
|
||||
{"frame_id": 9999, "item_parser": "x"},
|
||||
)
|
||||
with pytest.raises(AiFallbackValidationError, match="frame-swap guard"):
|
||||
validate_proposal(proposal, frame_contract=_FRAME_CONTRACT)
|
||||
|
||||
|
||||
def test_frame_swap_guard_accepts_matching_frame_id() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.PARTIAL_OVERRIDES,
|
||||
{
|
||||
"frame_id": 1171281190,
|
||||
"slots": {"pillar_1": "a", "pillar_2": "b", "pillar_3": "c"},
|
||||
},
|
||||
)
|
||||
validate_proposal(proposal, frame_contract=_FRAME_CONTRACT)
|
||||
|
||||
|
||||
def test_internal_region_containment_rejects_mismatch() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.PARTIAL_OVERRIDES,
|
||||
{
|
||||
"slots": {"pillar_1": "a", "pillar_2": "b", "pillar_3": "c"},
|
||||
"region_id": "zone_bottom.region_x",
|
||||
},
|
||||
)
|
||||
with pytest.raises(AiFallbackValidationError, match="Internal Region"):
|
||||
validate_proposal(
|
||||
proposal,
|
||||
frame_contract=_FRAME_CONTRACT,
|
||||
internal_region=_REGION,
|
||||
)
|
||||
|
||||
|
||||
def test_internal_region_containment_accepts_match() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.PARTIAL_OVERRIDES,
|
||||
{
|
||||
"slots": {"pillar_1": "a", "pillar_2": "b", "pillar_3": "c"},
|
||||
"region_id": "zone_top.region_a",
|
||||
},
|
||||
)
|
||||
validate_proposal(
|
||||
proposal,
|
||||
frame_contract=_FRAME_CONTRACT,
|
||||
internal_region=_REGION,
|
||||
)
|
||||
|
||||
|
||||
def test_internal_region_check_skipped_when_no_region_supplied() -> None:
|
||||
proposal = _make(
|
||||
ProposalKind.PARTIAL_OVERRIDES,
|
||||
{
|
||||
"slots": {"pillar_1": "a", "pillar_2": "b", "pillar_3": "c"},
|
||||
"region_id": "zone_top.region_a",
|
||||
},
|
||||
)
|
||||
validate_proposal(proposal, frame_contract=_FRAME_CONTRACT)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Phase Z catalog invariant test — real `frame_contracts.yaml` 1:1 mapping verify.
|
||||
|
||||
IMP-05 L4 lock per Claude #13 §3 :
|
||||
- real catalog read (purpose 자체 = real catalog 검증)
|
||||
- template_id ↔ frame_id 1:1 mapping (Codex #6 terminology — 2 reference keys for same entry)
|
||||
- fail fast with explicit message if catalog policy changes
|
||||
|
||||
Codex #5 verified : 11 templates / 11 frames, all unique = 1:1 mapping confirm (2026-05-13).
|
||||
Codex #7 generalization guardrail : real catalog OK (purpose 자체) — NOT sample-hardcoding.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
|
||||
|
||||
def _load_catalog() -> dict:
|
||||
with CATALOG_PATH.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def test_catalog_template_id_to_frame_id_one_to_one():
|
||||
"""Verify each catalog entry has unique template_id + unique frame_id (1:1 reference keys).
|
||||
|
||||
Fails fast if the catalog policy ever drifts from this assumption — IMP-05 dedup
|
||||
relies on `template_id` as the runtime key and assumes one frame per template.
|
||||
"""
|
||||
catalog = _load_catalog()
|
||||
|
||||
template_ids = []
|
||||
frame_ids = []
|
||||
for entry_key, entry in catalog.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
tid = entry.get("template_id")
|
||||
fid = entry.get("frame_id")
|
||||
assert tid is not None, f"entry {entry_key} missing template_id"
|
||||
assert fid is not None, f"entry {entry_key} missing frame_id"
|
||||
template_ids.append(tid)
|
||||
frame_ids.append(str(fid))
|
||||
|
||||
duplicate_templates = [t for t in template_ids if template_ids.count(t) > 1]
|
||||
duplicate_frames = [f for f in frame_ids if frame_ids.count(f) > 1]
|
||||
|
||||
assert not duplicate_templates, (
|
||||
"Phase Z catalog currently expects one template_id per frame_id; "
|
||||
"update dedup policy if this changes. "
|
||||
f"Duplicate template_ids found: {set(duplicate_templates)}"
|
||||
)
|
||||
assert not duplicate_frames, (
|
||||
"Phase Z catalog currently expects one template_id per frame_id; "
|
||||
"update dedup policy if this changes. "
|
||||
f"Duplicate frame_ids found: {set(duplicate_frames)}"
|
||||
)
|
||||
assert len(template_ids) == len(frame_ids), (
|
||||
"Phase Z catalog template_id count must equal frame_id count "
|
||||
f"(templates={len(template_ids)}, frames={len(frame_ids)})."
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_entry_count_matches_frame_count():
|
||||
"""Sanity guard — each entry contributes one template_id + one frame_id."""
|
||||
catalog = _load_catalog()
|
||||
entry_count = sum(1 for v in catalog.values() if isinstance(v, dict))
|
||||
template_count = sum(
|
||||
1 for v in catalog.values()
|
||||
if isinstance(v, dict) and v.get("template_id") is not None
|
||||
)
|
||||
frame_count = sum(
|
||||
1 for v in catalog.values()
|
||||
if isinstance(v, dict) and v.get("frame_id") is not None
|
||||
)
|
||||
assert entry_count == template_count == frame_count, (
|
||||
f"catalog shape inconsistent: entries={entry_count} "
|
||||
f"templates={template_count} frames={frame_count}"
|
||||
)
|
||||
@@ -0,0 +1,421 @@
|
||||
"""IMP-27: Shared catalog loader tests (u1).
|
||||
|
||||
Validates that ``src.catalog`` provides a single file-read + mtime cache and
|
||||
that its four public functions honor the documented contracts.
|
||||
|
||||
Note: ``templates/catalog.yaml`` was deleted in cc2f434 (legacy block library
|
||||
cleanup). The shared loader still preserves the loader contract for any
|
||||
remaining Phase Q call sites; tests use a fixture catalog via monkeypatch so
|
||||
they are independent of the deleted production file.
|
||||
|
||||
Delegation tests for block_reference / block_selector / renderer wrappers are
|
||||
added in u2 / u3 / u4.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
FIXTURE_CATALOG = {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "fixture-block-a",
|
||||
"category": "emphasis",
|
||||
"template": "blocks/emphasis/fixture-block-a.html",
|
||||
},
|
||||
{
|
||||
"id": "fixture-block-b",
|
||||
"category": "cards",
|
||||
"template": "blocks/cards/fixture-block-b.html",
|
||||
"variants": [
|
||||
{"id": "default", "template": "blocks/cards/fixture-block-b.html"},
|
||||
{"id": "compact", "template": "blocks/cards/fixture-block-b--compact.html"},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _reset_catalog_module():
|
||||
import src.catalog as catalog_mod
|
||||
catalog_mod._catalog_cache = None
|
||||
catalog_mod._catalog_mtime = 0.0
|
||||
|
||||
|
||||
def _reset_renderer_projection_cache():
|
||||
"""IMP-27 u4: clear renderer-local projection caches between tests."""
|
||||
import src.renderer as renderer_mod
|
||||
renderer_mod._CATALOG_MAP = None
|
||||
renderer_mod._CATALOG_MAP_MTIME = 0.0
|
||||
renderer_mod._CATALOG_VARIANT_MAP = None
|
||||
renderer_mod._CATALOG_VARIANT_MAP_MTIME = 0.0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture_catalog_path(tmp_path, monkeypatch):
|
||||
"""Point src.catalog at a tmp catalog.yaml fixture and reset its cache."""
|
||||
import src.catalog as catalog_mod
|
||||
|
||||
fixture_path = tmp_path / "catalog.yaml"
|
||||
fixture_path.write_text(yaml.safe_dump(FIXTURE_CATALOG), encoding="utf-8")
|
||||
monkeypatch.setattr(catalog_mod, "CATALOG_PATH", fixture_path)
|
||||
_reset_catalog_module()
|
||||
_reset_renderer_projection_cache()
|
||||
return fixture_path
|
||||
|
||||
|
||||
def test_load_root_catalog_returns_root_dict(fixture_catalog_path):
|
||||
from src import catalog
|
||||
|
||||
root = catalog.load_root_catalog()
|
||||
assert isinstance(root, dict)
|
||||
assert "blocks" in root
|
||||
assert len(root["blocks"]) == 2
|
||||
|
||||
|
||||
def test_load_blocks_returns_list_of_block_dicts(fixture_catalog_path):
|
||||
from src import catalog
|
||||
|
||||
blocks = catalog.load_blocks()
|
||||
assert isinstance(blocks, list)
|
||||
assert len(blocks) == 2
|
||||
assert all(isinstance(b, dict) for b in blocks)
|
||||
assert all("id" in b for b in blocks)
|
||||
|
||||
|
||||
def test_get_block_by_id_without_catalog_arg(fixture_catalog_path):
|
||||
from src import catalog
|
||||
|
||||
found = catalog.get_block_by_id("fixture-block-a")
|
||||
assert found is not None
|
||||
assert found["id"] == "fixture-block-a"
|
||||
assert found["category"] == "emphasis"
|
||||
|
||||
|
||||
def test_get_block_by_id_with_catalog_arg_preserves_block_selector_contract(fixture_catalog_path):
|
||||
from src import catalog
|
||||
|
||||
root = catalog.load_root_catalog()
|
||||
found = catalog.get_block_by_id("fixture-block-b", catalog=root)
|
||||
assert found is not None
|
||||
assert found["id"] == "fixture-block-b"
|
||||
|
||||
|
||||
def test_get_block_by_id_unknown_returns_none(fixture_catalog_path):
|
||||
from src import catalog
|
||||
|
||||
assert catalog.get_block_by_id("__nonexistent_block_id__") is None
|
||||
|
||||
|
||||
def test_mtime_cache_does_single_file_read(fixture_catalog_path, monkeypatch):
|
||||
import src.catalog as catalog_mod
|
||||
|
||||
read_count = {"n": 0}
|
||||
real_safe_load = catalog_mod.yaml.safe_load
|
||||
|
||||
def counting_safe_load(stream):
|
||||
read_count["n"] += 1
|
||||
return real_safe_load(stream)
|
||||
|
||||
monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load)
|
||||
|
||||
catalog_mod.load_root_catalog()
|
||||
catalog_mod.load_root_catalog()
|
||||
catalog_mod.load_blocks()
|
||||
catalog_mod.get_block_by_id("fixture-block-a")
|
||||
|
||||
assert read_count["n"] == 1, (
|
||||
f"Expected exactly one yaml.safe_load call on cold cache, "
|
||||
f"got {read_count['n']}"
|
||||
)
|
||||
|
||||
|
||||
def test_mtime_change_triggers_reload(fixture_catalog_path, monkeypatch):
|
||||
import os
|
||||
import src.catalog as catalog_mod
|
||||
|
||||
read_count = {"n": 0}
|
||||
real_safe_load = catalog_mod.yaml.safe_load
|
||||
|
||||
def counting_safe_load(stream):
|
||||
read_count["n"] += 1
|
||||
return real_safe_load(stream)
|
||||
|
||||
monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load)
|
||||
|
||||
catalog_mod.load_root_catalog()
|
||||
assert read_count["n"] == 1
|
||||
|
||||
# Forcibly advance file mtime and verify cache invalidates.
|
||||
original_mtime = fixture_catalog_path.stat().st_mtime
|
||||
os.utime(fixture_catalog_path, (original_mtime + 10, original_mtime + 10))
|
||||
|
||||
catalog_mod.load_root_catalog()
|
||||
assert read_count["n"] == 2
|
||||
|
||||
|
||||
def test_get_catalog_mtime_matches_file_after_load(fixture_catalog_path):
|
||||
from src import catalog
|
||||
|
||||
catalog.load_root_catalog()
|
||||
actual = fixture_catalog_path.stat().st_mtime
|
||||
assert catalog.get_catalog_mtime() == actual
|
||||
|
||||
|
||||
def test_get_catalog_mtime_is_zero_before_first_load(monkeypatch, tmp_path):
|
||||
import src.catalog as catalog_mod
|
||||
monkeypatch.setattr(catalog_mod, "CATALOG_PATH", tmp_path / "unused.yaml")
|
||||
_reset_catalog_module()
|
||||
|
||||
assert catalog_mod.get_catalog_mtime() == 0.0
|
||||
|
||||
|
||||
def test_load_root_catalog_missing_file_returns_empty_blocks(monkeypatch, tmp_path):
|
||||
import src.catalog as catalog_mod
|
||||
|
||||
missing_path = tmp_path / "no_such_catalog.yaml"
|
||||
monkeypatch.setattr(catalog_mod, "CATALOG_PATH", missing_path)
|
||||
_reset_catalog_module()
|
||||
|
||||
root = catalog_mod.load_root_catalog()
|
||||
assert root == {"blocks": []}
|
||||
assert catalog_mod.load_blocks() == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# u2: block_reference delegation tests
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_reference_load_catalog_returns_list_of_blocks(fixture_catalog_path):
|
||||
"""block_reference._load_catalog preserves list[dict] contract via delegation."""
|
||||
from src import block_reference
|
||||
|
||||
blocks = block_reference._load_catalog()
|
||||
assert isinstance(blocks, list)
|
||||
assert len(blocks) == 2
|
||||
assert all(isinstance(b, dict) for b in blocks)
|
||||
assert {b["id"] for b in blocks} == {"fixture-block-a", "fixture-block-b"}
|
||||
|
||||
|
||||
def test_block_reference_get_block_by_id_no_arg_signature(fixture_catalog_path):
|
||||
"""block_reference._get_block_by_id preserves no-catalog-argument contract."""
|
||||
from src import block_reference
|
||||
|
||||
found = block_reference._get_block_by_id("fixture-block-a")
|
||||
assert found is not None
|
||||
assert found["id"] == "fixture-block-a"
|
||||
assert found["category"] == "emphasis"
|
||||
|
||||
assert block_reference._get_block_by_id("__nonexistent__") is None
|
||||
|
||||
|
||||
def test_block_reference_shares_cache_with_shared_loader(fixture_catalog_path, monkeypatch):
|
||||
"""block_reference wrappers must hit the shared mtime cache, not a private copy."""
|
||||
import src.catalog as catalog_mod
|
||||
from src import block_reference
|
||||
|
||||
read_count = {"n": 0}
|
||||
real_safe_load = catalog_mod.yaml.safe_load
|
||||
|
||||
def counting_safe_load(stream):
|
||||
read_count["n"] += 1
|
||||
return real_safe_load(stream)
|
||||
|
||||
monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load)
|
||||
|
||||
block_reference._load_catalog()
|
||||
block_reference._get_block_by_id("fixture-block-a")
|
||||
catalog_mod.load_root_catalog()
|
||||
|
||||
assert read_count["n"] == 1, (
|
||||
f"block_reference wrappers should share the catalog cache; "
|
||||
f"got {read_count['n']} reads"
|
||||
)
|
||||
|
||||
|
||||
def test_block_reference_has_no_private_catalog_cache(fixture_catalog_path):
|
||||
"""IMP-27 u2 guard: block_reference must not retain a module-level _catalog_cache."""
|
||||
from src import block_reference
|
||||
|
||||
assert not hasattr(block_reference, "_catalog_cache"), (
|
||||
"block_reference._catalog_cache must be removed by u2 — "
|
||||
"loader is delegated to src.catalog"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# u3: block_selector delegation tests
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_block_selector_load_catalog_returns_root_dict(fixture_catalog_path):
|
||||
"""block_selector.load_catalog preserves root-dict contract via delegation."""
|
||||
from src import block_selector
|
||||
|
||||
root = block_selector.load_catalog()
|
||||
assert isinstance(root, dict)
|
||||
assert "blocks" in root
|
||||
assert isinstance(root["blocks"], list)
|
||||
assert {b["id"] for b in root["blocks"]} == {"fixture-block-a", "fixture-block-b"}
|
||||
|
||||
|
||||
def test_block_selector_get_block_by_id_catalog_injected_signature(fixture_catalog_path):
|
||||
"""block_selector._get_block_by_id preserves catalog-injected signature."""
|
||||
from src import block_selector
|
||||
|
||||
catalog_dict = block_selector.load_catalog()
|
||||
found = block_selector._get_block_by_id("fixture-block-b", catalog_dict)
|
||||
assert found is not None
|
||||
assert found["id"] == "fixture-block-b"
|
||||
assert found["category"] == "cards"
|
||||
|
||||
assert block_selector._get_block_by_id("__nonexistent__", catalog_dict) is None
|
||||
|
||||
|
||||
def test_block_selector_shares_cache_with_shared_loader(fixture_catalog_path, monkeypatch):
|
||||
"""block_selector wrappers must hit the shared mtime cache, not a private copy."""
|
||||
import src.catalog as catalog_mod
|
||||
from src import block_selector
|
||||
|
||||
read_count = {"n": 0}
|
||||
real_safe_load = catalog_mod.yaml.safe_load
|
||||
|
||||
def counting_safe_load(stream):
|
||||
read_count["n"] += 1
|
||||
return real_safe_load(stream)
|
||||
|
||||
monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load)
|
||||
|
||||
block_selector.load_catalog()
|
||||
block_selector._get_block_by_id("fixture-block-a", block_selector.load_catalog())
|
||||
catalog_mod.load_root_catalog()
|
||||
|
||||
assert read_count["n"] == 1, (
|
||||
f"block_selector wrappers should share the catalog cache; "
|
||||
f"got {read_count['n']} reads"
|
||||
)
|
||||
|
||||
|
||||
def test_block_selector_has_no_private_catalog_cache(fixture_catalog_path):
|
||||
"""IMP-27 u3 guard: block_selector must not retain module-level cache/mtime/CATALOG_PATH."""
|
||||
from src import block_selector
|
||||
|
||||
assert not hasattr(block_selector, "_catalog_cache"), (
|
||||
"block_selector._catalog_cache must be removed by u3 — "
|
||||
"loader is delegated to src.catalog"
|
||||
)
|
||||
assert not hasattr(block_selector, "_catalog_mtime"), (
|
||||
"block_selector._catalog_mtime must be removed by u3"
|
||||
)
|
||||
assert not hasattr(block_selector, "CATALOG_PATH"), (
|
||||
"block_selector.CATALOG_PATH must be removed by u3 — "
|
||||
"path lives in src.catalog only"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# u4: renderer delegation tests
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_renderer_load_catalog_map_returns_id_to_template_dict(fixture_catalog_path):
|
||||
"""renderer._load_catalog_map preserves id → template projection contract."""
|
||||
from src import renderer
|
||||
|
||||
mapping = renderer._load_catalog_map()
|
||||
assert isinstance(mapping, dict)
|
||||
assert mapping["fixture-block-a"] == "blocks/emphasis/fixture-block-a.html"
|
||||
assert mapping["fixture-block-b"] == "blocks/cards/fixture-block-b.html"
|
||||
|
||||
|
||||
def test_renderer_load_catalog_map_with_variants_returns_compound_key_dict(fixture_catalog_path):
|
||||
"""renderer._load_catalog_map_with_variants preserves 'id--variant' → template projection."""
|
||||
from src import renderer
|
||||
|
||||
vmap = renderer._load_catalog_map_with_variants()
|
||||
assert isinstance(vmap, dict)
|
||||
# 'default' variants must be excluded (preserves pre-IMP-27 behavior).
|
||||
assert "fixture-block-b--default" not in vmap
|
||||
# Non-default variants must be present with the compound key.
|
||||
assert vmap["fixture-block-b--compact"] == "blocks/cards/fixture-block-b--compact.html"
|
||||
|
||||
|
||||
def test_renderer_shares_cache_with_shared_loader(fixture_catalog_path, monkeypatch):
|
||||
"""renderer projections must read through src.catalog, never opening the file directly."""
|
||||
import src.catalog as catalog_mod
|
||||
from src import renderer
|
||||
|
||||
read_count = {"n": 0}
|
||||
real_safe_load = catalog_mod.yaml.safe_load
|
||||
|
||||
def counting_safe_load(stream):
|
||||
read_count["n"] += 1
|
||||
return real_safe_load(stream)
|
||||
|
||||
monkeypatch.setattr(catalog_mod.yaml, "safe_load", counting_safe_load)
|
||||
|
||||
renderer._load_catalog_map()
|
||||
renderer._load_catalog_map_with_variants()
|
||||
catalog_mod.load_root_catalog()
|
||||
|
||||
assert read_count["n"] == 1, (
|
||||
f"renderer projections should share the catalog cache; "
|
||||
f"got {read_count['n']} reads"
|
||||
)
|
||||
|
||||
|
||||
def test_renderer_projection_invalidates_when_shared_mtime_changes(
|
||||
fixture_catalog_path, monkeypatch
|
||||
):
|
||||
"""IMP-27 u4 contract: renderer projection cache keyed off src.catalog.get_catalog_mtime."""
|
||||
import os
|
||||
|
||||
import src.catalog as catalog_mod
|
||||
from src import renderer
|
||||
|
||||
first_map = renderer._load_catalog_map()
|
||||
first_id_to_path = dict(first_map)
|
||||
|
||||
# Rewrite the fixture file with a different block id and bump mtime so the
|
||||
# shared cache (and therefore the renderer projection) must rebuild.
|
||||
new_catalog = {
|
||||
"blocks": [
|
||||
{
|
||||
"id": "fixture-block-c",
|
||||
"category": "headers",
|
||||
"template": "blocks/headers/fixture-block-c.html",
|
||||
},
|
||||
]
|
||||
}
|
||||
fixture_catalog_path.write_text(yaml.safe_dump(new_catalog), encoding="utf-8")
|
||||
original_mtime = fixture_catalog_path.stat().st_mtime
|
||||
os.utime(fixture_catalog_path, (original_mtime + 10, original_mtime + 10))
|
||||
# Force src.catalog to drop its in-memory cache so the next call re-reads.
|
||||
catalog_mod._catalog_cache = None
|
||||
catalog_mod._catalog_mtime = 0.0
|
||||
|
||||
second_map = renderer._load_catalog_map()
|
||||
assert "fixture-block-c" in second_map
|
||||
assert "fixture-block-a" not in second_map
|
||||
assert first_id_to_path != second_map
|
||||
|
||||
|
||||
def test_renderer_has_no_private_catalog_path_or_yaml(fixture_catalog_path):
|
||||
"""IMP-27 u4 guard: renderer must not retain its own file-read state."""
|
||||
from src import renderer
|
||||
|
||||
assert not hasattr(renderer, "CATALOG_PATH"), (
|
||||
"renderer.CATALOG_PATH must be removed by u4 — path lives in src.catalog only"
|
||||
)
|
||||
assert not hasattr(renderer, "yaml"), (
|
||||
"renderer.yaml import must be removed by u4 — file-read is delegated to src.catalog"
|
||||
)
|
||||
assert not hasattr(renderer, "_CATALOG_MTIME"), (
|
||||
"renderer._CATALOG_MTIME (legacy single mtime) must be removed by u4 — "
|
||||
"projection caches now key off src.catalog.get_catalog_mtime() via "
|
||||
"_CATALOG_MAP_MTIME / _CATALOG_VARIANT_MAP_MTIME"
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Phase Z family-template ↔ frame_contracts.yaml baseline invariant.
|
||||
|
||||
#52 F-2 option (c) lock (2026-05-19) — locks active contracted family count
|
||||
at 11/11 with a WIP allowlist for the 2 untracked WIP family templates
|
||||
documented in `templates/phase_z2/families/_WIP_FILES.md`. Any drift after
|
||||
this point (new family file on disk without a contract entry, or a contract
|
||||
entry pointing to a missing file) fails fast in CI.
|
||||
|
||||
References:
|
||||
- docs/architecture/INTEGRATION-AUDIT-01-REPORT.md §10.2 F-2
|
||||
- docs/architecture/IMP-18-SVG-GAP-REPORT.md L28/L30/L51
|
||||
- templates/phase_z2/families/_WIP_FILES.md (WIP allowlist source)
|
||||
- Gitea #52 (this reconciliation), #42 (promote/remove gate)
|
||||
|
||||
Pattern mirrors `tests/test_catalog_invariant.py` — fail fast with explicit
|
||||
diff message if family ↔ contract surfaces drift.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
FAMILIES_DIR = PROJECT_ROOT / "templates" / "phase_z2" / "families"
|
||||
CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
|
||||
WIP_DOC_PATH = FAMILIES_DIR / "_WIP_FILES.md"
|
||||
|
||||
|
||||
def _load_contract_keys() -> set[str]:
|
||||
with CATALOG_PATH.open(encoding="utf-8") as f:
|
||||
catalog = yaml.safe_load(f)
|
||||
return {k for k, v in catalog.items() if isinstance(v, dict)}
|
||||
|
||||
|
||||
def _load_disk_family_stems() -> set[str]:
|
||||
return {p.stem for p in FAMILIES_DIR.glob("*.html")}
|
||||
|
||||
|
||||
def _load_wip_allowlist() -> set[str]:
|
||||
text = WIP_DOC_PATH.read_text(encoding="utf-8")
|
||||
return {m.group(1) for m in re.finditer(r"`([A-Za-z0-9_\-]+)\.html`", text)}
|
||||
|
||||
|
||||
def test_contracts_set_equals_disk_families_minus_wip():
|
||||
"""`frame_contracts.yaml` keys ↔ disk family stems minus WIP allowlist."""
|
||||
contracts = _load_contract_keys()
|
||||
disk = _load_disk_family_stems()
|
||||
wip = _load_wip_allowlist()
|
||||
expected = disk - wip
|
||||
missing = expected - contracts
|
||||
extra = contracts - expected
|
||||
assert not missing, (
|
||||
f"Family files on disk without frame_contracts.yaml entry "
|
||||
f"(and not in _WIP_FILES.md): {sorted(missing)}. "
|
||||
"Add a contract entry, or list the file in "
|
||||
"templates/phase_z2/families/_WIP_FILES.md as WIP."
|
||||
)
|
||||
assert not extra, (
|
||||
f"frame_contracts.yaml has entries with no matching family file: "
|
||||
f"{sorted(extra)}."
|
||||
)
|
||||
|
||||
|
||||
def test_wip_allowlist_is_disk_only_and_uncontracted():
|
||||
"""WIP allowlist names must exist on disk AND have no contract entry."""
|
||||
contracts = _load_contract_keys()
|
||||
disk = _load_disk_family_stems()
|
||||
wip = _load_wip_allowlist()
|
||||
missing_on_disk = wip - disk
|
||||
leaked_into_contracts = wip & contracts
|
||||
assert not missing_on_disk, (
|
||||
f"_WIP_FILES.md names files not on disk: {sorted(missing_on_disk)}."
|
||||
)
|
||||
assert not leaked_into_contracts, (
|
||||
f"_WIP_FILES.md names files that already have a contract entry: "
|
||||
f"{sorted(leaked_into_contracts)}. Promote via #42 instead — "
|
||||
"WIP allowlist must be disk-only / uncontracted."
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""IMP-13 u7 smoke — discovery, source invariants, dry-run, idempotency, manifest schema."""
|
||||
from __future__ import annotations
|
||||
import json, os, re, sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT_PATH = REPO_ROOT / "scripts" / "generate_frame_previews.py"
|
||||
sys.path.insert(0, str(SCRIPT_PATH.parent))
|
||||
import generate_frame_previews as gfp # noqa: E402
|
||||
|
||||
def _fixture(root: Path) -> Path:
|
||||
blocks = root / "blocks"
|
||||
(blocks / "FRAME_A").mkdir(parents=True)
|
||||
(blocks / "FRAME_A" / "index.html").write_text("<html><body class=slide></body></html>", encoding="utf-8")
|
||||
(blocks / "FRAME_A" / "preview.png").write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
(blocks / "FRAME_B").mkdir()
|
||||
(blocks / "ORPHAN").mkdir()
|
||||
(blocks / "ORPHAN" / "preview.png").write_bytes(b"x")
|
||||
return blocks
|
||||
|
||||
def test_discover_counts(tmp_path: Path) -> None:
|
||||
rows = gfp.discover(_fixture(tmp_path))
|
||||
assert [r.frame_id for r in rows] == ["FRAME_A", "FRAME_B", "ORPHAN"]
|
||||
assert sum(r.has_index for r in rows) == 1 and sum(r.has_preview for r in rows) == 2
|
||||
|
||||
def test_source_invariants() -> None:
|
||||
src = SCRIPT_PATH.read_text(encoding="utf-8")
|
||||
for t in ("anthropic", "openai", "jinja", "phase_z2", "slide_measurer"): assert t not in src, t
|
||||
for lit in ("1280", "720", "1400", "900"): assert not re.search(rf"(?<!\d){lit}(?!\d)", src), lit
|
||||
|
||||
def test_dry_run_prints_counts(tmp_path: Path, capsys) -> None:
|
||||
rc = gfp.main(["--blocks-dir", str(_fixture(tmp_path)), "--manifest", str(tmp_path / "m.json"), "--dry-run"])
|
||||
assert rc == 0 and "discovered: total=3 with_index_html=1 with_preview_png=2" in capsys.readouterr().out
|
||||
|
||||
def test_idempotency_unchanged(tmp_path: Path) -> None:
|
||||
row = gfp.discover(_fixture(tmp_path))[0]
|
||||
mt = row.index_html_path.stat().st_mtime
|
||||
os.utime(row.preview_png_path, (mt + 1, mt + 1))
|
||||
sha = gfp._sha256_file(row.index_html_path)
|
||||
assert gfp.is_unchanged(row, {"index_sha256": sha}) is True
|
||||
assert gfp.is_unchanged(row, {"index_sha256": "x"}) is False
|
||||
assert gfp.is_unchanged(row, None) is False
|
||||
|
||||
def test_manifest_schema(tmp_path: Path) -> None:
|
||||
blocks = tmp_path / "blocks"; (blocks / "F").mkdir(parents=True); (blocks / "F" / "preview.png").write_bytes(b"x")
|
||||
mf = tmp_path / "m.json"
|
||||
assert gfp.main(["--blocks-dir", str(blocks), "--manifest", str(mf)]) == 0
|
||||
data = json.loads(mf.read_text(encoding="utf-8"))
|
||||
assert set(data) >= {"schema", "generated_at", "blocks_dir", "summary", "frames"} and data["schema"] == 1
|
||||
assert set(data["summary"]) >= {"total", "renderable", "missing_index_html", "orphan", "rendered", "skipped_unchanged", "error"} and data["summary"]["orphan"] == 1 and data["frames"]["F"]["status"] == "orphan"
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Unit tests for ``src.json_utils.parse_json``.
|
||||
|
||||
IMP-28 L4 — `_parse_json` dedup unit u2.
|
||||
|
||||
Pins the shared helper semantics that previously lived in
|
||||
content_editor.py / design_director.py / kei_client.py (fuller form) and
|
||||
pipeline.py (simple form). The fuller form is a strict superset of the
|
||||
simple form; these tests cover both axes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.json_utils import parse_json
|
||||
|
||||
|
||||
def test_parse_json_fenced_json_block():
|
||||
text = 'prefix\n```json\n{"a": 1, "b": "x"}\n```\nsuffix'
|
||||
assert parse_json(text) == {"a": 1, "b": "x"}
|
||||
|
||||
|
||||
def test_parse_json_plain_fenced_block():
|
||||
text = 'prefix\n```\n{"x": 2}\n```\nsuffix'
|
||||
assert parse_json(text) == {"x": 2}
|
||||
|
||||
|
||||
def test_parse_json_bare_braces():
|
||||
text = 'noise before {"y": 3, "z": [1, 2]} noise after'
|
||||
assert parse_json(text) == {"y": 3, "z": [1, 2]}
|
||||
|
||||
|
||||
def test_parse_json_list_prefix_dash_cleanup():
|
||||
text = '- {"b": 4}'
|
||||
assert parse_json(text) == {"b": 4}
|
||||
|
||||
|
||||
def test_parse_json_list_prefix_star_cleanup():
|
||||
text = '* {"c": 5}'
|
||||
assert parse_json(text) == {"c": 5}
|
||||
|
||||
|
||||
def test_parse_json_no_json_returns_none():
|
||||
assert parse_json("no json here at all") is None
|
||||
|
||||
|
||||
def test_parse_json_malformed_returns_none():
|
||||
assert parse_json("{ invalid json") is None
|
||||
|
||||
|
||||
def test_parse_json_prefix_free_no_op():
|
||||
text = '{"d": 6}'
|
||||
assert parse_json(text) == {"d": 6}
|
||||
|
||||
|
||||
def test_parse_json_fenced_preferred_over_bare_braces():
|
||||
text = 'outer {"outer": true} ```json\n{"inner": 1}\n```'
|
||||
assert parse_json(text) == {"inner": 1}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""IMP-33 u1 — AI fallback Settings defaults (locked).
|
||||
|
||||
These defaults are the binding contract from Stage 2 plan (per-unit u1):
|
||||
- ai_fallback_enabled = False (master flag OFF; fallback path only)
|
||||
- ai_fallback_model = "claude-opus-4-6-20250415"
|
||||
- ai_fallback_timeout_s = 60.0
|
||||
- ai_fallback_max_retries = 3
|
||||
- ai_fallback_backoff_base_s = 1.0
|
||||
- ai_fallback_backoff_cap_s = 8.0
|
||||
- ai_fallback_backoff_jitter = 0.3
|
||||
- ai_fallback_budget_per_run = 10
|
||||
- ai_fallback_circuit_breaker_threshold = 5
|
||||
|
||||
Downstream u4 (client) MUST source timeout/retry/backoff/budget/circuit from
|
||||
Settings; inline literals are forbidden by Stage 2 plan.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.config import Settings
|
||||
|
||||
|
||||
def test_ai_fallback_master_flag_default_off() -> None:
|
||||
s = Settings()
|
||||
assert s.ai_fallback_enabled is False, (
|
||||
"AI fallback master flag MUST default OFF (normal path AI=0 contract)."
|
||||
)
|
||||
|
||||
|
||||
def test_ai_fallback_model_default_locked() -> None:
|
||||
s = Settings()
|
||||
assert s.ai_fallback_model == "claude-opus-4-6-20250415"
|
||||
|
||||
|
||||
def test_ai_fallback_retry_timeout_backoff_defaults_locked() -> None:
|
||||
s = Settings()
|
||||
assert s.ai_fallback_timeout_s == 60.0
|
||||
assert s.ai_fallback_max_retries == 3
|
||||
assert s.ai_fallback_backoff_base_s == 1.0
|
||||
assert s.ai_fallback_backoff_cap_s == 8.0
|
||||
assert s.ai_fallback_backoff_jitter == 0.3
|
||||
|
||||
|
||||
def test_ai_fallback_budget_and_circuit_defaults_locked() -> None:
|
||||
s = Settings()
|
||||
assert s.ai_fallback_budget_per_run == 10
|
||||
assert s.ai_fallback_circuit_breaker_threshold == 5
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,555 @@
|
||||
"""IMP-06 zone-section assignment override — helper unit tests (synthetic).
|
||||
|
||||
Lock per Claude #6 §4 L13 + Codex #2 R3 6 cases + 자체 catch 7-10 :
|
||||
9 cases covering parse / helper assignment / collision / template ladder.
|
||||
|
||||
Fully synthetic per Codex #7 generalization guardrail (MOCK_ prefix).
|
||||
NO real catalog template_id / frame_id, NO `v4_full32_result.yaml` dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _build_position_assignment_plan
|
||||
|
||||
|
||||
# ─── Synthetic fixtures ──────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeUnit:
|
||||
"""Synthetic CompositionUnit stand-in. Only fields the helper reads."""
|
||||
source_section_ids: list[str]
|
||||
template_id: Optional[str] = None
|
||||
frame_template_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeSection:
|
||||
"""Synthetic MdxSection stand-in. Only fields the helper reads."""
|
||||
section_id: str
|
||||
raw_content: str = "- item A\n- item B\n"
|
||||
|
||||
|
||||
# ─── Case 1 : single override + non-conflicting auto retain ─────────────
|
||||
|
||||
|
||||
def test_single_zone_override_retains_non_conflicting_auto():
|
||||
"""Claude #6 L13 case 4 — single override on `top`, auto units do not overlap.
|
||||
Expected: top = override unit; bottom = auto unit retained.
|
||||
"""
|
||||
units = [
|
||||
_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto_top"),
|
||||
_FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_auto_bottom"),
|
||||
]
|
||||
positions = ["top", "bottom"]
|
||||
overrides = {"top": ["MOCK_S3"]}
|
||||
sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")}
|
||||
override_frames = {"MOCK_S3": "MOCK_T_for_S3"} # ladder step 1
|
||||
|
||||
plan, summary = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
by_pos = {p["position"]: p for p in plan}
|
||||
assert by_pos["top"]["assignment_source"] == "cli_override"
|
||||
assert by_pos["top"]["source_section_ids"] == ["MOCK_S3"]
|
||||
assert by_pos["top"]["template_id"] == "MOCK_T_for_S3"
|
||||
assert by_pos["bottom"]["assignment_source"] == "auto"
|
||||
assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S2"]
|
||||
assert summary["applied_count"] == 1
|
||||
assert summary["skipped_count"] == 0
|
||||
|
||||
|
||||
# ─── Case 2 : collision — override wins, auto whole-skipped ──────────────
|
||||
|
||||
|
||||
def test_override_collision_whole_skip_no_split_uncovered_traced():
|
||||
"""Codex #2 R1 example : override section overlaps an auto merged unit.
|
||||
Expected: override wins; auto [MOCK_S1, MOCK_S2] skipped whole (no split);
|
||||
MOCK_S2 reported as uncovered.
|
||||
"""
|
||||
auto_merged = _FakeUnit(
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
frame_template_id="MOCK_T_merged",
|
||||
)
|
||||
auto_solo = _FakeUnit(source_section_ids=["MOCK_S3"], frame_template_id="MOCK_T_solo")
|
||||
units = [auto_merged, auto_solo]
|
||||
positions = ["top", "bottom"]
|
||||
overrides = {"top": ["MOCK_S1"]}
|
||||
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2", "MOCK_S3"]}
|
||||
override_frames = {"MOCK_S1": "MOCK_T_override_S1"}
|
||||
|
||||
plan, summary = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
by_pos = {p["position"]: p for p in plan}
|
||||
# top : override wins; auto_merged that sat at top is replaced.
|
||||
assert by_pos["top"]["assignment_source"] == "cli_override"
|
||||
assert by_pos["top"]["source_section_ids"] == ["MOCK_S1"]
|
||||
# No split : MOCK_S2 (the other half of the auto merged unit) is NOT
|
||||
# re-extracted into the replaced position; instead it surfaces as uncovered.
|
||||
assert by_pos["top"]["previous_source_section_ids"] == ["MOCK_S1", "MOCK_S2"]
|
||||
assert by_pos["top"]["uncovered_section_ids"] == ["MOCK_S2"]
|
||||
# IMP-06 Stage 4 (Codex #10): replaced_auto_unit populated for same-position
|
||||
# override replacement (previous auto unit had different sections).
|
||||
assert by_pos["top"]["replaced_auto_unit"] is not None
|
||||
assert by_pos["top"]["replaced_auto_unit"]["unit_id"] == "MOCK_S1+MOCK_S2"
|
||||
assert by_pos["top"]["replaced_auto_unit"]["reason"] == "same_position_override_replacement"
|
||||
# bottom : non-overlapping auto_solo is retained (no collision).
|
||||
assert by_pos["bottom"]["assignment_source"] == "auto"
|
||||
assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S3"]
|
||||
assert by_pos["bottom"]["replaced_auto_unit"] is None
|
||||
# Summary aggregates MOCK_S2 as the global uncovered section.
|
||||
assert summary["uncovered_section_ids"] == ["MOCK_S2"]
|
||||
|
||||
|
||||
# ─── Case 3 : template ladder step 1 (override_frames wins) ─────────────
|
||||
|
||||
|
||||
def test_template_resolution_ladder_step1_override_frame_wins():
|
||||
"""Codex #4 T1 ladder step 1 : --override-frame exact unit_id wins."""
|
||||
units = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto")]
|
||||
positions = ["top"]
|
||||
overrides = {"top": ["MOCK_S1"]}
|
||||
sections_by_id = {"MOCK_S1": _FakeSection("MOCK_S1")}
|
||||
override_frames = {"MOCK_S1": "MOCK_T_explicit_override"}
|
||||
|
||||
plan, _ = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
assert plan[0]["template_id"] == "MOCK_T_explicit_override"
|
||||
assert plan[0]["skipped_reason"] is None
|
||||
|
||||
|
||||
# ─── Case 4 : template ladder step 2 (exact auto unit reuse) ────────────
|
||||
|
||||
|
||||
def test_template_resolution_ladder_step2_exact_auto_reuse():
|
||||
"""Codex #4 T1 ladder step 2 : no override_frame; exact existing auto unit -> reuse."""
|
||||
units = [_FakeUnit(source_section_ids=["MOCK_S1", "MOCK_S2"], frame_template_id="MOCK_T_auto_merged")]
|
||||
positions = ["top"]
|
||||
overrides = {"top": ["MOCK_S1", "MOCK_S2"]}
|
||||
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]}
|
||||
|
||||
plan, _ = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=None, # no explicit frame override
|
||||
)
|
||||
|
||||
# ladder step 2 hits : exact auto unit [MOCK_S1, MOCK_S2] reuse
|
||||
assert plan[0]["template_id"] == "MOCK_T_auto_merged"
|
||||
assert plan[0]["skipped_reason"] is None
|
||||
|
||||
|
||||
# ─── Case 5 : template ladder step 4 (ad-hoc multi-section fail) ─────────
|
||||
|
||||
|
||||
def test_template_resolution_ladder_step4_ad_hoc_multi_section_fail():
|
||||
"""Codex #4 Additional lock : ad-hoc multi-section override without exact auto +
|
||||
without explicit --override-frame -> skipped_reason = 'ad_hoc_merged_no_template'.
|
||||
"""
|
||||
units = [
|
||||
_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a"),
|
||||
_FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_b"),
|
||||
]
|
||||
positions = ["top"]
|
||||
overrides = {"top": ["MOCK_S1", "MOCK_S2"]} # ad-hoc merge, no exact auto
|
||||
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]}
|
||||
|
||||
plan, _ = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=None,
|
||||
)
|
||||
|
||||
assert plan[0]["template_id"] is None
|
||||
assert plan[0]["skipped_reason"] == "ad_hoc_merged_no_template"
|
||||
|
||||
|
||||
# ─── Case 6 : unit_id naming convention ─────────────────────────────────
|
||||
|
||||
|
||||
def test_unit_id_naming_convention_consistent_for_auto_and_override():
|
||||
"""Codex T2 + Claude #4 catch 8 : unit_id = '+'.join(source_section_ids)."""
|
||||
units = [
|
||||
_FakeUnit(source_section_ids=["MOCK_S1", "MOCK_S2"], frame_template_id="MOCK_T_merged"),
|
||||
]
|
||||
positions = ["top", "bottom"]
|
||||
overrides = {"bottom": ["MOCK_S3"]}
|
||||
sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")}
|
||||
override_frames = {"MOCK_S3": "MOCK_T_S3"}
|
||||
|
||||
plan, _ = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
by_pos = {p["position"]: p for p in plan}
|
||||
# auto merged unit
|
||||
assert by_pos["top"]["unit_id"] == "MOCK_S1+MOCK_S2"
|
||||
# single-section override
|
||||
assert by_pos["bottom"]["unit_id"] == "MOCK_S3"
|
||||
|
||||
|
||||
# ─── Case 7 : previous_source_section_ids semantics (position history) ──
|
||||
|
||||
|
||||
def test_previous_source_section_ids_records_same_position_auto_history():
|
||||
"""Codex T3 + Claude #4 catch 9 : previous_source_section_ids = the auto
|
||||
assignment that occupied the SAME position before override applied.
|
||||
"""
|
||||
units = [
|
||||
_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a"),
|
||||
_FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_b"),
|
||||
]
|
||||
positions = ["top", "bottom"]
|
||||
overrides = {"top": ["MOCK_S3"]}
|
||||
sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")}
|
||||
override_frames = {"MOCK_S3": "MOCK_T_S3"}
|
||||
|
||||
plan, _ = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
by_pos = {p["position"]: p for p in plan}
|
||||
# top : auto WAS MOCK_S1 before override
|
||||
assert by_pos["top"]["previous_source_section_ids"] == ["MOCK_S1"]
|
||||
|
||||
|
||||
# ─── Case 8 : empty position when no auto unit available ───────────────
|
||||
|
||||
|
||||
def test_position_with_no_auto_unit_marked_empty():
|
||||
"""When `units` has fewer entries than `positions`, extra positions = empty."""
|
||||
units = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a")]
|
||||
positions = ["top", "bottom"] # bottom has no auto unit
|
||||
sections_by_id = {"MOCK_S1": _FakeSection("MOCK_S1")}
|
||||
|
||||
plan, summary = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=None,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=None,
|
||||
)
|
||||
|
||||
by_pos = {p["position"]: p for p in plan}
|
||||
assert by_pos["bottom"]["assignment_source"] == "empty"
|
||||
assert by_pos["bottom"]["skipped_reason"] == "no_auto_unit_available"
|
||||
assert summary["applied_count"] == 0
|
||||
|
||||
|
||||
# ─── Case 9b : replaced_auto_unit distinguishes same-sections vs different ──
|
||||
|
||||
|
||||
def test_replaced_auto_unit_only_when_previous_auto_differs_from_override():
|
||||
"""Codex #10 R1 + Claude #10 Catch L : replaced_auto_unit populated only when
|
||||
the same position previously had an auto unit AND that auto unit had
|
||||
different sections than the override. Same sections (override just swaps
|
||||
template via --override-frame) yields replaced_auto_unit = None.
|
||||
"""
|
||||
# Case A : same sections → replaced_auto_unit None (just template swap)
|
||||
units_a = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto")]
|
||||
plan_a, _ = _build_position_assignment_plan(
|
||||
units=units_a,
|
||||
positions=["top"],
|
||||
override_section_assignments={"top": ["MOCK_S1"]},
|
||||
sections_by_id={"MOCK_S1": _FakeSection("MOCK_S1")},
|
||||
override_frames={"MOCK_S1": "MOCK_T_explicit_swap"},
|
||||
)
|
||||
assert plan_a[0]["replaced_auto_unit"] is None
|
||||
assert plan_a[0]["template_id"] == "MOCK_T_explicit_swap"
|
||||
|
||||
# Case B : different sections → replaced_auto_unit populated
|
||||
units_b = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto")]
|
||||
plan_b, _ = _build_position_assignment_plan(
|
||||
units=units_b,
|
||||
positions=["top"],
|
||||
override_section_assignments={"top": ["MOCK_S2"]},
|
||||
sections_by_id={"MOCK_S2": _FakeSection("MOCK_S2")},
|
||||
override_frames={"MOCK_S2": "MOCK_T_for_S2"},
|
||||
)
|
||||
assert plan_b[0]["replaced_auto_unit"] is not None
|
||||
assert plan_b[0]["replaced_auto_unit"]["unit_id"] == "MOCK_S1"
|
||||
assert plan_b[0]["replaced_auto_unit"]["source_section_ids"] == ["MOCK_S1"]
|
||||
assert plan_b[0]["replaced_auto_unit"]["reason"] == "same_position_override_replacement"
|
||||
|
||||
|
||||
# ─── Case 9 : summary aggregation invariants ───────────────────────────
|
||||
|
||||
|
||||
def test_summary_aggregation_counts_applied_skipped_uncovered():
|
||||
"""Claude #6 L12 single source of truth : summary derives from plan."""
|
||||
auto_merged = _FakeUnit(
|
||||
source_section_ids=["MOCK_S1", "MOCK_S2"],
|
||||
frame_template_id="MOCK_T_merged",
|
||||
)
|
||||
units = [auto_merged]
|
||||
positions = ["top", "bottom"]
|
||||
# Two overrides : top=MOCK_S1 (causes collision with auto_merged at bottom)
|
||||
overrides = {"top": ["MOCK_S1"]}
|
||||
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]}
|
||||
override_frames = {"MOCK_S1": "MOCK_T_override_S1"}
|
||||
|
||||
plan, summary = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
# Summary derives from plan : 1 applied, 1 collision skip, 1 uncovered.
|
||||
assert summary["applied_count"] == 1
|
||||
assert summary["skipped_count"] >= 1 # collision skip
|
||||
assert summary["uncovered_section_ids"] == ["MOCK_S2"]
|
||||
assert summary["section_assignment_overrides_applied"][0]["position"] == "top"
|
||||
|
||||
|
||||
# ─── Section-id exact-id invariant (Codex #14 / #15 / #16 / #17) ─────────
|
||||
|
||||
|
||||
def test_section_id_exact_match_parent_like_does_not_collide_with_child_like():
|
||||
"""Codex #14 explicit clarification : section ids are matched exact-string only.
|
||||
`S3` and `S3-1` are distinct ids — auto plan having a parent-like id `S3` does
|
||||
not implicitly consume / uncover `S3-1` or `S3-2` via prefix matching.
|
||||
"""
|
||||
auto = _FakeUnit(source_section_ids=["MOCK_S3"], frame_template_id="MOCK_T_parent")
|
||||
units = [auto]
|
||||
positions = ["top", "bottom"]
|
||||
# Override places a child-like id S3-1 into bottom; must NOT be treated as
|
||||
# the same section as S3 by prefix.
|
||||
overrides = {"bottom": ["MOCK_S3-1"]}
|
||||
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S3", "MOCK_S3-1"]}
|
||||
override_frames = {"MOCK_S3-1": "MOCK_T_child"}
|
||||
|
||||
plan, summary = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
by_pos = {p["position"]: p for p in plan}
|
||||
# top : auto plan keeps MOCK_S3 intact (NOT consumed/skipped by S3-1 override)
|
||||
assert by_pos["top"]["assignment_source"] == "auto"
|
||||
assert by_pos["top"]["source_section_ids"] == ["MOCK_S3"]
|
||||
assert by_pos["top"]["skipped_reason"] is None
|
||||
# bottom : override applies MOCK_S3-1 as a distinct section
|
||||
assert by_pos["bottom"]["assignment_source"] == "cli_override"
|
||||
assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S3-1"]
|
||||
# No collision, no uncovered MOCK_S3 from "prefix match"
|
||||
assert summary["uncovered_section_ids"] == []
|
||||
|
||||
|
||||
def test_section_id_exact_duplicate_collision_detected():
|
||||
"""Codex #14 invariant : exact `S3-1` colliding with another exact `S3-1`
|
||||
IS detected as a collision (whole-skip auto, uncovered trace).
|
||||
"""
|
||||
# Auto unit with MOCK_S3-1 (exact id) sits at bottom
|
||||
auto_top = _FakeUnit(source_section_ids=["MOCK_other"], frame_template_id="MOCK_T_other")
|
||||
auto_bottom = _FakeUnit(source_section_ids=["MOCK_S3-1", "MOCK_S3-2"], frame_template_id="MOCK_T_pair")
|
||||
units = [auto_top, auto_bottom]
|
||||
positions = ["top", "bottom"]
|
||||
# Override pulls MOCK_S3-1 to top — exact-id collision with auto_bottom's S3-1.
|
||||
overrides = {"top": ["MOCK_S3-1"]}
|
||||
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_other", "MOCK_S3-1", "MOCK_S3-2"]}
|
||||
override_frames = {"MOCK_S3-1": "MOCK_T_for_S3_1"}
|
||||
|
||||
plan, summary = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
by_pos = {p["position"]: p for p in plan}
|
||||
# top : override wins (replaces auto_top's MOCK_other)
|
||||
assert by_pos["top"]["assignment_source"] == "cli_override"
|
||||
assert by_pos["top"]["source_section_ids"] == ["MOCK_S3-1"]
|
||||
# bottom : exact-id collision with override's MOCK_S3-1 → whole skip
|
||||
assert by_pos["bottom"]["assignment_source"] == "empty"
|
||||
assert by_pos["bottom"]["skipped_reason"] == "override_collision"
|
||||
skipped = by_pos["bottom"]["skipped_collided_auto_units"]
|
||||
assert skipped and skipped[0]["unit_id"] == "MOCK_S3-1+MOCK_S3-2"
|
||||
# MOCK_S3-2 is uncovered (exact-id only, no automatic split)
|
||||
assert "MOCK_S3-2" in summary["uncovered_section_ids"]
|
||||
|
||||
|
||||
def test_section_id_distinct_ids_coexist_in_different_positions():
|
||||
"""Codex #14 invariant : S3 and S3-1 (distinct exact ids) can coexist in
|
||||
different positions without collision or false uncoverage."""
|
||||
# Auto plan : S3 at top
|
||||
auto = _FakeUnit(source_section_ids=["MOCK_S3"], frame_template_id="MOCK_T_S3")
|
||||
units = [auto]
|
||||
positions = ["top", "bottom"]
|
||||
# Override : S3-1 into bottom (no overlap with S3 since exact-id only)
|
||||
overrides = {"bottom": ["MOCK_S3-1"]}
|
||||
sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S3", "MOCK_S3-1"]}
|
||||
override_frames = {"MOCK_S3-1": "MOCK_T_S3_1"}
|
||||
|
||||
plan, summary = _build_position_assignment_plan(
|
||||
units=units, positions=positions,
|
||||
override_section_assignments=overrides,
|
||||
sections_by_id=sections_by_id,
|
||||
override_frames=override_frames,
|
||||
)
|
||||
|
||||
by_pos = {p["position"]: p for p in plan}
|
||||
assert by_pos["top"]["source_section_ids"] == ["MOCK_S3"]
|
||||
assert by_pos["top"]["skipped_reason"] is None
|
||||
assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S3-1"]
|
||||
assert summary["uncovered_section_ids"] == [] # no false uncoverage
|
||||
|
||||
|
||||
# ─── Codex #13 Blocker 2 integration proof ───────────────────────────────
|
||||
# End-to-end pipeline run with `--override-section-assignment top=03-2` on
|
||||
# sample 03 MDX. Asserts the override is reflected in the actual render-path
|
||||
# artifacts (zones_data, debug_zones, Step 9 application_plan, Step 20
|
||||
# slide_status, debug.json `zones`) — NOT only in `comp_debug.
|
||||
# section_assignment_plan`. Without this proof, the helper unit tests above
|
||||
# could pass while units/render_records/zones still showed the pre-override
|
||||
# auto plan. Heavy: invokes Selenium overflow check.
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_integration_override_reflects_in_zones_data_step9_step20(tmp_path, monkeypatch):
|
||||
"""Codex #13 Blocker 2 (non-negotiable) — integration proof that
|
||||
`--override-section-assignment top=03-2` changes actual render-path
|
||||
artifacts (zones_data / debug_zones / Step 9 / Step 20 / debug.json),
|
||||
not only `comp_debug.section_assignment_plan`.
|
||||
|
||||
Sample 03 MDX has 2 sections (03-1, 03-2). Auto plan = [top=03-1,
|
||||
bottom=03-2]. Override forces top=03-2 → exact-id collision with auto
|
||||
bottom (whole-skip) + previous auto top (03-1) becomes uncovered.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from src import phase_z2_pipeline as pz2
|
||||
|
||||
PROJECT_ROOT = Path(pz2.__file__).resolve().parent.parent
|
||||
sample_path = PROJECT_ROOT / "samples" / "mdx" / "03. DX 시행을 위한 필수 요건 및 혁신 방안.mdx"
|
||||
if not sample_path.is_file():
|
||||
pytest.skip(f"sample MDX not present: {sample_path}")
|
||||
|
||||
# Isolate run output under tmp_path so we do not pollute data/runs/.
|
||||
monkeypatch.setattr(pz2, "RUNS_DIR", tmp_path / "runs")
|
||||
|
||||
run_id = "test_imp06_override_integration"
|
||||
pz2.run_phase_z2_mvp1(
|
||||
sample_path,
|
||||
run_id=run_id,
|
||||
override_section_assignments={"top": ["03-2"]},
|
||||
)
|
||||
|
||||
run_dir = tmp_path / "runs" / run_id / "phase_z2"
|
||||
debug_path = run_dir / "debug.json"
|
||||
assert debug_path.is_file(), f"debug.json missing at {debug_path}"
|
||||
debug = json.loads(debug_path.read_text(encoding="utf-8"))
|
||||
|
||||
# ── 1) Render-path zones (debug.json `zones` == debug_zones in code) ──
|
||||
zones = {z["position"]: z for z in debug.get("zones", [])}
|
||||
assert "top" in zones, f"top zone missing; got {list(zones)}"
|
||||
assert "bottom" in zones, f"bottom zone missing; got {list(zones)}"
|
||||
|
||||
top = zones["top"]
|
||||
# Override reflected in render-path debug_zones, not only comp_debug.
|
||||
assert top["source_section_ids"] == ["03-2"], (
|
||||
f"top debug_zone did not reflect override; source_section_ids={top.get('source_section_ids')}"
|
||||
)
|
||||
assert top.get("assignment_source") == "cli_override", (
|
||||
f"top assignment_source != cli_override; got {top.get('assignment_source')}"
|
||||
)
|
||||
# Override-flag trace surfaces on the post-override debug_zone.
|
||||
assert top.get("section_assignment_override") is True, (
|
||||
f"top.section_assignment_override flag missing/false; got {top.get('section_assignment_override')}"
|
||||
)
|
||||
|
||||
bottom = zones["bottom"]
|
||||
# Exact-id collision (override 03-2 vs auto bottom 03-2) → whole-skip.
|
||||
# The empty zone record must be present in debug_zones (zone identity preserved).
|
||||
assert bottom.get("v4_template_id") == "__empty__" or bottom.get("merge_type") == "empty", (
|
||||
f"bottom should be empty after collision; got "
|
||||
f"v4_template_id={bottom.get('v4_template_id')}, merge_type={bottom.get('merge_type')}"
|
||||
)
|
||||
assert bottom.get("skipped_reason") == "override_collision", (
|
||||
f"bottom skipped_reason != override_collision; got {bottom.get('skipped_reason')}"
|
||||
)
|
||||
|
||||
# ── 2) Step 20 slide_status — coverage invariant ──
|
||||
step20_path = run_dir / "steps" / "step20_slide_status.json"
|
||||
assert step20_path.is_file(), f"step20 missing at {step20_path}"
|
||||
step20 = json.loads(step20_path.read_text(encoding="utf-8"))
|
||||
payload = step20.get("data") if "data" in step20 else step20
|
||||
filtered_ids = payload.get("filtered_section_ids") or []
|
||||
assert "03-1" in filtered_ids, (
|
||||
f"03-1 (previous auto top displaced by override) must appear in "
|
||||
f"filtered_section_ids; got {filtered_ids}"
|
||||
)
|
||||
# full_mdx_coverage must NOT be True when override displaces a section.
|
||||
assert payload.get("full_mdx_coverage") is not True, (
|
||||
f"full_mdx_coverage should be False after override displaces 03-1; got "
|
||||
f"{payload.get('full_mdx_coverage')}"
|
||||
)
|
||||
# Codex #10 Catch O list-shaped filtered_section_reasons must include the
|
||||
# override-uncovered entry pointing to the position whose plan dropped it.
|
||||
reasons = payload.get("filtered_section_reasons") or []
|
||||
override_uncovered_reasons = [
|
||||
r for r in reasons
|
||||
if isinstance(r, dict)
|
||||
and r.get("source") == "section_assignment_override"
|
||||
and "03-1" in (r.get("section_ids") or [])
|
||||
]
|
||||
assert override_uncovered_reasons, (
|
||||
f"filtered_section_reasons missing override-uncovered entry for 03-1; "
|
||||
f"got {reasons}"
|
||||
)
|
||||
|
||||
# ── 3) Step 9 application_plan — plan-aware additive fields per unit ──
|
||||
step09_path = run_dir / "steps" / "step09_application_plan.json"
|
||||
assert step09_path.is_file(), f"step09 missing at {step09_path}"
|
||||
step09 = json.loads(step09_path.read_text(encoding="utf-8"))
|
||||
step09_data = step09.get("data") if "data" in step09 else step09
|
||||
plan_units = step09_data.get("units") or []
|
||||
# The renderable unit for 03-2 must exist with override-aware fields.
|
||||
override_units = [
|
||||
u for u in plan_units
|
||||
if u.get("unit_id") == "03-2"
|
||||
and u.get("position") == "top"
|
||||
and u.get("assignment_source") == "cli_override"
|
||||
]
|
||||
assert override_units, (
|
||||
f"Step 9 application_plan did not carry plan-aware fields; "
|
||||
f"units={[(u.get('unit_id'), u.get('position'), u.get('assignment_source')) for u in plan_units]}"
|
||||
)
|
||||
assert override_units[0].get("section_assignment_override") is True
|
||||
|
||||
# ── 4) comp_debug — pre-existing plan/summary still present (regression) ──
|
||||
cd = debug.get("composition_planner_debug") or {}
|
||||
sa_summary = cd.get("section_assignment_summary") or {}
|
||||
# uncovered_section_ids carries the displaced auto-top section.
|
||||
assert "03-1" in (sa_summary.get("uncovered_section_ids") or []), (
|
||||
f"comp_debug.section_assignment_summary.uncovered_section_ids "
|
||||
f"missing 03-1; got {sa_summary.get('uncovered_section_ids')}"
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""IMP-11 D-2 (u1) — Step 9 v4_all_judgments[] min_height_px field tests.
|
||||
|
||||
u1 contract:
|
||||
Each v4_all_judgments[] entry MUST expose `min_height_px` sourced from
|
||||
catalog `frame_contracts[template_id].visual_hints.min_height_px`
|
||||
(logical 1280×720 px), with `None` fallback when contract is unregistered.
|
||||
A single `get_contract(c.template_id)` lookup binds both
|
||||
`catalog_registered` and `min_height_px` (no double-lookup cost).
|
||||
|
||||
Production code = inline list builder in `run_phase_z2_mvp1`
|
||||
(`src/phase_z2_pipeline.py`, near v4_all_for_unit loop). These tests follow
|
||||
the same source-string + catalog-shape guard pattern as the existing
|
||||
`test_step9_production_emits_candidate_evidence_and_alias` in
|
||||
`tests/test_phase_z2_v4_fallback.py`, kept until a helper is extracted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from src import phase_z2_pipeline
|
||||
from phase_z2_mapper import get_contract, load_frame_contracts
|
||||
|
||||
|
||||
# ─── Case 1 : u1 production-source guard ────────────────────────────────────
|
||||
|
||||
|
||||
def test_v4_all_judgments_emits_min_height_px_with_none_fallback():
|
||||
"""Source guard — single get_contract bound to `_contract`, then both
|
||||
`catalog_registered` and `min_height_px` derived from that binding.
|
||||
`min_height_px` uses the `(_contract or {})` chain so unregistered
|
||||
contracts propagate `None` (frontend tolerates undefined).
|
||||
"""
|
||||
source = inspect.getsource(phase_z2_pipeline)
|
||||
|
||||
# u1 marker present (locates the builder)
|
||||
assert "IMP-11 D-2 (u1)" in source
|
||||
|
||||
# Single get_contract lookup bound to local var
|
||||
assert "_contract = get_contract(c.template_id)" in source
|
||||
|
||||
# catalog_registered reuses the local binding (no second lookup)
|
||||
assert '"catalog_registered": _contract is not None' in source
|
||||
|
||||
# min_height_px source = visual_hints chain; None when contract is None
|
||||
assert (
|
||||
'"min_height_px": (_contract or {})'
|
||||
'.get("visual_hints", {})'
|
||||
'.get("min_height_px")'
|
||||
) in source
|
||||
|
||||
# v4_all_judgments wires the new builder list
|
||||
assert '"v4_all_judgments": v4_all_judgments_list' in source
|
||||
|
||||
|
||||
# ─── Case 2 : additive guarantee — existing 7 fields preserved ──────────────
|
||||
|
||||
|
||||
def test_v4_all_judgments_preserves_existing_fields():
|
||||
"""u1 is additive only — the existing 7 keys must remain in the per-entry
|
||||
dict alongside the new `min_height_px`.
|
||||
"""
|
||||
source = inspect.getsource(phase_z2_pipeline)
|
||||
|
||||
builder_start = source.find("IMP-11 D-2 (u1)")
|
||||
assert builder_start != -1
|
||||
builder_end = source.find("application_plan_units.append", builder_start)
|
||||
assert builder_end != -1
|
||||
builder = source[builder_start:builder_end]
|
||||
|
||||
for field in (
|
||||
'"template_id": c.template_id',
|
||||
'"frame_id": c.frame_id',
|
||||
'"frame_number": c.frame_number',
|
||||
'"v4_rank": c.v4_rank',
|
||||
'"confidence": c.confidence',
|
||||
'"label": c.label',
|
||||
'"catalog_registered": _contract is not None',
|
||||
'"min_height_px":',
|
||||
):
|
||||
assert field in builder, f"missing field in u1 builder: {field!r}"
|
||||
|
||||
|
||||
# ─── Case 3 : catalog reality — visual_hints.min_height_px shape is real ────
|
||||
|
||||
|
||||
def test_catalog_visual_hints_min_height_px_path_is_real():
|
||||
"""The source-string guard depends on the actual catalog shape having
|
||||
`visual_hints.min_height_px` as a positive int on registered contracts
|
||||
whose `visual_hints` block declares it. Verify against the real
|
||||
`frame_contracts.yaml` so a future catalog schema change cannot silently
|
||||
invalidate the `.get("visual_hints", {}).get("min_height_px")` chain.
|
||||
"""
|
||||
load_frame_contracts()
|
||||
|
||||
# Real registered template_ids that ship with visual_hints.min_height_px
|
||||
# (verified via load_frame_contracts() — see frame_contracts.yaml).
|
||||
sample_template_ids = (
|
||||
"three_parallel_requirements",
|
||||
"process_product_two_way",
|
||||
"construction_goals_three_circle_intersection",
|
||||
"bim_dx_comparison_table",
|
||||
)
|
||||
|
||||
found = 0
|
||||
for tid in sample_template_ids:
|
||||
contract = get_contract(tid)
|
||||
if contract is None:
|
||||
continue # tolerate catalog rename — at least one must remain
|
||||
# The exact .get chain used by the u1 builder
|
||||
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||
assert isinstance(min_h, int), (
|
||||
f"{tid}: visual_hints.min_height_px must be int, "
|
||||
f"got {type(min_h).__name__}={min_h!r}"
|
||||
)
|
||||
assert min_h > 0, f"{tid}: min_height_px must be positive, got {min_h}"
|
||||
found += 1
|
||||
|
||||
assert found > 0, (
|
||||
"no sample registered contract present — catalog audit drift; "
|
||||
"update sample_template_ids to match current frame_contracts.yaml"
|
||||
)
|
||||
|
||||
|
||||
def test_registered_contract_without_min_height_px_propagates_none():
|
||||
"""Registered contract whose `visual_hints` block omits `min_height_px`
|
||||
(or sets it to `null`) must also propagate `None` through the u1 chain.
|
||||
Real example in current catalog: `bim_issues_quadrant_four`.
|
||||
"""
|
||||
load_frame_contracts()
|
||||
|
||||
tid = "bim_issues_quadrant_four"
|
||||
contract = get_contract(tid)
|
||||
if contract is None:
|
||||
import pytest # noqa: PLC0415 — runtime skip only when catalog drifts
|
||||
pytest.skip(f"sample template {tid!r} no longer registered")
|
||||
|
||||
# Exact chain used by u1 builder
|
||||
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||
assert min_h is None, (
|
||||
f"{tid}: expected None when visual_hints.min_height_px is absent/null, "
|
||||
f"got {min_h!r} — chain semantics changed"
|
||||
)
|
||||
# catalog_registered must still be True (additive, independent of value)
|
||||
assert (contract is not None) is True
|
||||
|
||||
|
||||
# ─── Case 4 : None propagation for unregistered template_id ─────────────────
|
||||
|
||||
|
||||
def test_unregistered_template_id_propagates_none():
|
||||
"""When `get_contract(template_id)` returns `None`, the u1 chain
|
||||
`(_contract or {}).get("visual_hints", {}).get("min_height_px")` must
|
||||
yield `None` (frontend tolerates undefined; no KeyError).
|
||||
"""
|
||||
load_frame_contracts()
|
||||
|
||||
# Synthetic template_id guaranteed not to be in the catalog
|
||||
unregistered = "MOCK_template_unregistered_for_u1_test"
|
||||
assert get_contract(unregistered) is None, (
|
||||
"test precondition broken — synthetic template_id leaked into catalog"
|
||||
)
|
||||
|
||||
# Replicate the u1 chain exactly
|
||||
_contract = get_contract(unregistered)
|
||||
min_height = (_contract or {}).get("visual_hints", {}).get("min_height_px")
|
||||
catalog_registered = _contract is not None
|
||||
|
||||
assert min_height is None
|
||||
assert catalog_registered is False
|
||||
@@ -0,0 +1,239 @@
|
||||
"""IMP-08 B-3 sub-section drag/drop — schema + V4 alias resolver tests.
|
||||
|
||||
Fully synthetic per Codex #7 generalization guardrail:
|
||||
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml`` dependency,
|
||||
NO MDX-specific section ids beyond canonical id format.
|
||||
|
||||
Locked scope (Stage 3 R8) :
|
||||
A. ``derive_parent_id`` canonical ordinal recognition + legacy decimal fallback.
|
||||
B. ``_resolve_v4_section_key`` exact > alias > None (no parent/sibling promotion).
|
||||
C. ``align_sections_to_v4_granularity`` canonical ordinal id emit + N-R5
|
||||
decimal-only alias guard + MdxSection default-construction stability.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_composition import derive_parent_id
|
||||
from src.phase_z2_pipeline import (
|
||||
MdxSection,
|
||||
_resolve_v4_section_key,
|
||||
align_sections_to_v4_granularity,
|
||||
)
|
||||
|
||||
|
||||
# ─── A. derive_parent_id ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_derive_parent_id_ordinal_sub():
|
||||
assert derive_parent_id("03-1-sub-2") == "03-1"
|
||||
assert derive_parent_id("04-2-sub-1") == "04-2"
|
||||
|
||||
|
||||
def test_derive_parent_id_decimal_legacy_alias():
|
||||
# Legacy V4 decimal id retains existing behaviour for alias path.
|
||||
assert derive_parent_id("04-2.1") == "04-2"
|
||||
|
||||
|
||||
def test_derive_parent_id_top_level_none():
|
||||
assert derive_parent_id("04-1") is None
|
||||
assert derive_parent_id("04") is None
|
||||
assert derive_parent_id("nonsense") is None
|
||||
|
||||
|
||||
# ─── B. _resolve_v4_section_key ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fake_v4(*keys):
|
||||
return {"mdx_sections": {k: {"judgments_full32": []} for k in keys}}
|
||||
|
||||
|
||||
def test_alias_resolver_exact_match_wins():
|
||||
v4 = _fake_v4("04-2-sub-1", "04-2.1")
|
||||
assert _resolve_v4_section_key(v4, "04-2-sub-1") == "04-2-sub-1"
|
||||
assert (
|
||||
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
|
||||
== "04-2-sub-1"
|
||||
)
|
||||
|
||||
|
||||
def test_alias_resolver_decimal_alias_when_metadata_present():
|
||||
v4 = _fake_v4("04-2.1")
|
||||
assert (
|
||||
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
|
||||
== "04-2.1"
|
||||
)
|
||||
|
||||
|
||||
def test_alias_resolver_no_parent_promotion():
|
||||
# parent V4 entry must not be promoted into a sibling sub-section lookup.
|
||||
v4 = _fake_v4("04-2")
|
||||
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
|
||||
assert (
|
||||
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2"])
|
||||
== "04-2"
|
||||
) # alias is opt-in; only resolves when caller explicitly provides it
|
||||
|
||||
|
||||
def test_alias_resolver_no_sibling_promotion():
|
||||
# sibling sub-section entry must not be auto-promoted without an alias.
|
||||
v4 = _fake_v4("04-2-sub-2")
|
||||
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
|
||||
|
||||
|
||||
def test_alias_resolver_miss_returns_none():
|
||||
v4 = _fake_v4("99-1")
|
||||
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
|
||||
assert (
|
||||
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
# ─── C. align_sections_to_v4_granularity ────────────────────────────────────
|
||||
|
||||
|
||||
def _section(section_id, num, title, raw_content):
|
||||
"""Build an MdxSection with default sub-section schema fields."""
|
||||
return MdxSection(
|
||||
section_id=section_id,
|
||||
section_num=num,
|
||||
title=title,
|
||||
raw_content=raw_content,
|
||||
)
|
||||
|
||||
|
||||
def test_mdx_section_default_construction_preserves_4_positional_callers():
|
||||
# IMP-08 B-3 : MdxSection still accepts the legacy 4-positional shape
|
||||
# (defaults for heading_number / v4_alias_keys / sub_sections).
|
||||
s = MdxSection("04-1", 1, "1. Top", "body")
|
||||
assert s.heading_number is None
|
||||
assert s.v4_alias_keys == []
|
||||
assert s.sub_sections == []
|
||||
|
||||
|
||||
def test_align_passthrough_when_v4_key_exact_match():
|
||||
# Section already aligned to V4 key (no override target): aligner
|
||||
# keeps it untouched. Parent-level V4 evidence flows via exact-match
|
||||
# lookup.
|
||||
sections = [_section("04-1", 1, "1. Top", "body")]
|
||||
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
|
||||
out = align_sections_to_v4_granularity(sections, v4)
|
||||
assert len(out) == 1
|
||||
assert out[0].section_id == "04-1"
|
||||
|
||||
|
||||
def test_align_parent_v4_exact_keeps_section_when_no_override_targets_sub():
|
||||
# Backward-compat axis: when V4 carries the parent exact key and no
|
||||
# drag/drop override targets a sub-id of this section, the aligner
|
||||
# MUST keep the parent (preserves V4 evidence at parent granularity).
|
||||
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
|
||||
sections = [_section("03-2", 2, "2. Parent", raw)]
|
||||
v4 = {"mdx_sections": {"03-2": {"judgments_full32": []}}}
|
||||
out = align_sections_to_v4_granularity(sections, v4)
|
||||
assert [s.section_id for s in out] == ["03-2"]
|
||||
|
||||
|
||||
def test_align_force_drills_when_override_targets_sub_id_with_parent_in_v4():
|
||||
# Stage 5 R2 blocker-fix regression: when V4 has the parent exact key
|
||||
# AND an override targets a sub-id of that section, the aligner MUST
|
||||
# drill regardless of V4 parent presence. This makes drag/drop
|
||||
# addressing deterministic across all V4 yaml shapes.
|
||||
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
|
||||
sections = [_section("04-2", 2, "2. Parent", raw)]
|
||||
v4 = {
|
||||
"mdx_sections": {
|
||||
"04-2": {"judgments_full32": []}, # parent V4 entry present
|
||||
"04-2.1": {"judgments_full32": []}, # plus decimal sub entries
|
||||
"04-2.2": {"judgments_full32": []},
|
||||
}
|
||||
}
|
||||
out = align_sections_to_v4_granularity(
|
||||
sections, v4, override_target_section_ids=["04-2-sub-1"]
|
||||
)
|
||||
# Force-drill: parent id MUST be replaced by canonical sub-ids.
|
||||
assert [s.section_id for s in out] == ["04-2-sub-1", "04-2-sub-2"]
|
||||
# Decimal aliases preserved (N-R5: decimal heading_number).
|
||||
assert out[0].v4_alias_keys == ["04-2.1"]
|
||||
assert out[1].v4_alias_keys == ["04-2.2"]
|
||||
|
||||
|
||||
def test_align_top_level_override_target_does_not_force_drill_other_sections():
|
||||
# Top-level override target ("primary=03-1") has no derive_parent_id,
|
||||
# so it MUST NOT force-drill any section. Only "X-sub-N" targets
|
||||
# trigger force-drill on parent X.
|
||||
raw = "### 2.1 First\nbody1\n"
|
||||
sections = [
|
||||
_section("03-1", 1, "1. Top", "body"),
|
||||
_section("03-2", 2, "2. Parent", raw),
|
||||
]
|
||||
v4 = {
|
||||
"mdx_sections": {
|
||||
"03-1": {"judgments_full32": []},
|
||||
"03-2": {"judgments_full32": []},
|
||||
}
|
||||
}
|
||||
out = align_sections_to_v4_granularity(
|
||||
sections, v4, override_target_section_ids=["03-1"]
|
||||
)
|
||||
# No sub-id target -> both sections kept at parent granularity.
|
||||
assert [s.section_id for s in out] == ["03-1", "03-2"]
|
||||
|
||||
|
||||
def test_align_drill_emits_canonical_ordinal_id_with_decimal_alias():
|
||||
# Decimal H3 headings -> canonical ordinal id + decimal alias (legacy V4 key).
|
||||
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
|
||||
sections = [_section("04-2", 2, "2. Parent", raw)]
|
||||
v4 = {"mdx_sections": {}} # forces drill (no exact key)
|
||||
out = align_sections_to_v4_granularity(sections, v4)
|
||||
assert [s.section_id for s in out] == ["04-2-sub-1", "04-2-sub-2"]
|
||||
assert [s.heading_number for s in out] == ["2.1", "2.2"]
|
||||
# N-R5 : decimal headings -> alias emitted.
|
||||
assert out[0].v4_alias_keys == ["04-2.1"]
|
||||
assert out[1].v4_alias_keys == ["04-2.2"]
|
||||
|
||||
|
||||
def test_align_drill_integer_only_h3_emits_no_alias_n_r5_guard():
|
||||
# N-R5 : integer-only H3 (e.g., "### 1 Title") must NOT generate an alias,
|
||||
# otherwise it would collide with sibling parent V4 entries (`{mdx_id}-1`).
|
||||
raw = "### 1 Alpha\nbody1\n### 2 Beta\nbody2\n"
|
||||
sections = [_section("05-2", 2, "2. Parent", raw)]
|
||||
v4 = {"mdx_sections": {}}
|
||||
out = align_sections_to_v4_granularity(sections, v4)
|
||||
assert [s.section_id for s in out] == ["05-2-sub-1", "05-2-sub-2"]
|
||||
assert [s.heading_number for s in out] == ["1", "2"]
|
||||
assert out[0].v4_alias_keys == []
|
||||
assert out[1].v4_alias_keys == []
|
||||
|
||||
|
||||
def test_align_drill_undecorated_h3_emits_no_alias():
|
||||
# Plain `### Title` without numeric prefix -> heading_number=None, no alias.
|
||||
raw = "### Alpha\nbody1\n### Beta\nbody2\n"
|
||||
sections = [_section("03-3", 3, "3. Parent", raw)]
|
||||
v4 = {"mdx_sections": {}}
|
||||
out = align_sections_to_v4_granularity(sections, v4)
|
||||
assert [s.section_id for s in out] == ["03-3-sub-1", "03-3-sub-2"]
|
||||
assert [s.heading_number for s in out] == [None, None]
|
||||
assert all(s.v4_alias_keys == [] for s in out)
|
||||
|
||||
|
||||
def test_align_no_h3_passes_section_through_unchanged():
|
||||
# No H3 sub-headings in raw_content -> aligner keeps the section.
|
||||
sections = [_section("04-1", 1, "1. Top", "no subheadings here\njust prose")]
|
||||
v4 = {"mdx_sections": {}}
|
||||
out = align_sections_to_v4_granularity(sections, v4)
|
||||
assert len(out) == 1
|
||||
assert out[0].section_id == "04-1"
|
||||
|
||||
|
||||
def test_align_resolver_round_trip_with_legacy_v4_alias():
|
||||
# End-to-end : aligner emits canonical id + alias keys; resolver finds the
|
||||
# legacy decimal key in V4 via alias path (no parent promotion).
|
||||
raw = "### 2.1 First\nbody1\n"
|
||||
sections = [_section("04-2", 2, "2. Parent", raw)]
|
||||
v4 = {"mdx_sections": {"04-2.1": {"judgments_full32": []}}}
|
||||
out = align_sections_to_v4_granularity(sections, v4)
|
||||
sub = out[0]
|
||||
assert sub.section_id == "04-2-sub-1"
|
||||
resolved = _resolve_v4_section_key(
|
||||
v4, sub.section_id, alias_keys=sub.v4_alias_keys
|
||||
)
|
||||
assert resolved == "04-2.1"
|
||||
@@ -0,0 +1,558 @@
|
||||
"""IMP-05 V4 fallback selector behavior tests — fully synthetic per Codex #10 E1 + Claude #13.
|
||||
|
||||
Lock per round 65~73 + Claude #13 §3 L4' :
|
||||
- 6 explicit behavior cases (Codex #10 E4)
|
||||
- fully synthetic MOCK_ IDs (Codex #7 generalization guardrail + Codex #10 E1 naming)
|
||||
- monkeypatch `get_contract` + `compute_capacity_fit` (Codex #10 E3 — selector has no DI)
|
||||
- NO real catalog template_id / frame_id
|
||||
- NO `v4_full32_result.yaml` dependency
|
||||
|
||||
Synthetic naming convention :
|
||||
- `MOCK_` prefix mandatory
|
||||
- `_a` / `_b` / `_c` suffixes = enumeration only (NOT ordering / priority)
|
||||
- rank/order expressed by `v4_full_rank` field, NEVER by ID suffix
|
||||
|
||||
Real-catalog integrity is verified separately in `tests/test_catalog_invariant.py`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
import inspect
|
||||
|
||||
from src import phase_z2_pipeline
|
||||
from src.phase_z2_pipeline import lookup_v4_match_with_fallback
|
||||
|
||||
|
||||
# ─── Synthetic catalog stub ──────────────────────────────────────
|
||||
# Tests control which synthetic templates are catalog-registered + capacity-OK.
|
||||
|
||||
_MOCK_CATALOG: dict[str, object] = {
|
||||
"MOCK_template_direct_a": object(), # registered
|
||||
"MOCK_template_direct_b": object(), # registered (used for dedup case)
|
||||
"MOCK_template_reject_a": object(), # registered (but label=reject)
|
||||
"MOCK_template_restructure_a": object(), # registered (but label=restructure)
|
||||
# "MOCK_template_missing_contract" intentionally absent — get_contract returns None.
|
||||
}
|
||||
|
||||
|
||||
def _mock_get_contract(template_id: str):
|
||||
"""Synthetic contract lookup — return catalog entry or None."""
|
||||
return _MOCK_CATALOG.get(template_id)
|
||||
|
||||
|
||||
def _mock_capacity_fit_ok(template_id: str, raw_content: str) -> dict:
|
||||
"""Synthetic capacity precheck — always OK."""
|
||||
return {"fit_status": "ok"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_selector_deps(monkeypatch):
|
||||
"""Monkeypatch module-level dependencies of `lookup_v4_match_with_fallback`.
|
||||
|
||||
Codex #10 E3 + Claude #12 verification — selector has no DI; module-level
|
||||
`get_contract` / `compute_capacity_fit` must be monkeypatched.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"src.phase_z2_pipeline.get_contract", _mock_get_contract
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.phase_z2_pipeline.compute_capacity_fit", _mock_capacity_fit_ok
|
||||
)
|
||||
|
||||
|
||||
def _make_v4(judgments: list[dict], section_id: str = "S1") -> dict:
|
||||
"""Wrap synthetic judgments into V4 input shape."""
|
||||
return {"mdx_sections": {section_id: {"judgments_full32": judgments}}}
|
||||
|
||||
|
||||
def _j(rank: int, template_id: str, frame_id: str, label: str,
|
||||
confidence: float = 0.9) -> dict:
|
||||
"""Synthetic V4 judgment record — shape matches real V4 evidence shape."""
|
||||
return {
|
||||
"frame_id": frame_id,
|
||||
"frame_number": rank,
|
||||
"template_id": template_id,
|
||||
"confidence": confidence,
|
||||
"label": label,
|
||||
"v4_full_rank": rank,
|
||||
}
|
||||
|
||||
|
||||
# ─── Case 1 : rank-1 direct eligible retention (no fallback used) ───────────
|
||||
|
||||
|
||||
def test_rank_1_direct_eligible_is_retained(patch_selector_deps):
|
||||
"""Codex #10 E4 case 1 — rank-1 use_as_is + registered → keep rank-1, no fallback."""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_direct_a", "MOCK_frame_001", "use_as_is"),
|
||||
_j(2, "MOCK_template_direct_b", "MOCK_frame_002", "use_as_is"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n"
|
||||
)
|
||||
|
||||
assert match is not None
|
||||
assert match.template_id == "MOCK_template_direct_a"
|
||||
assert match.v4_rank == 1
|
||||
assert match.selection_path == "rank_1"
|
||||
assert trace["fallback_used"] is False
|
||||
assert trace["selection_path"] == "rank_1"
|
||||
assert trace["selected_rank"] == 1
|
||||
|
||||
|
||||
# ─── Case 2 : rank-1 non-direct → rank-2/3 direct selected (fallback used) ───
|
||||
|
||||
|
||||
def test_rank_1_non_direct_promotes_rank_2(patch_selector_deps):
|
||||
"""Codex #10 E4 case 2 — rank-1 reject + rank-2 use_as_is → promote rank-2."""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_reject_a", "MOCK_frame_001", "reject"),
|
||||
_j(2, "MOCK_template_direct_a", "MOCK_frame_002", "use_as_is"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n"
|
||||
)
|
||||
|
||||
assert match is not None
|
||||
assert match.template_id == "MOCK_template_direct_a"
|
||||
assert match.v4_rank == 2
|
||||
assert match.selection_path == "rank_2_fallback"
|
||||
assert trace["fallback_used"] is True
|
||||
assert trace["selected_rank"] == 2
|
||||
assert "phase_z_status_not_allowed" in trace["fallback_reason"]
|
||||
|
||||
|
||||
# ─── Case 3 : duplicate template_id is skipped / deduped ────────────────────
|
||||
|
||||
|
||||
def test_duplicate_template_id_is_skipped_rank_3_wins(patch_selector_deps):
|
||||
"""Codex #14 dedup precision lock — first occurrence reserves template_id
|
||||
for the chain regardless of decision. Later rank with same template_id MUST
|
||||
be skipped as duplicate, regardless of its V4 label.
|
||||
|
||||
Fixture simulates V4 anomaly : rank-1 + rank-2 share same template_id (and
|
||||
same frame_id per Codex #6 1:1 catalog terminology — real catalog 정합).
|
||||
rank-1 label = reject (non-direct, first occurrence), rank-2 label =
|
||||
use_as_is (would be executable but MUST be skipped as duplicate per
|
||||
Codex #14 intended rule). rank-3 = distinct executable template, wins.
|
||||
|
||||
Per Codex #14 example :
|
||||
rank 1: A reject → skipped (non-direct), template A claimed
|
||||
rank 2: A use_as_is → skipped as duplicate_template_id (must NOT win)
|
||||
rank 3: B use_as_is → selected (distinct template, eligible)
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
# rank-1 : non-direct (reject), reserves template_id for chain
|
||||
_j(1, "MOCK_template_dup_a", "MOCK_frame_dup_001", "reject"),
|
||||
# rank-2 : same template_id + same frame_id (1:1 catalog), would be
|
||||
# executable but MUST be skipped as duplicate (Codex #14 intended rule)
|
||||
_j(2, "MOCK_template_dup_a", "MOCK_frame_dup_001", "use_as_is"),
|
||||
# rank-3 : distinct executable template, wins
|
||||
_j(3, "MOCK_template_direct_a", "MOCK_frame_003", "use_as_is"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n"
|
||||
)
|
||||
|
||||
# rank-3 must be selected (distinct executable, after rank-1+2 duplicates)
|
||||
assert match is not None
|
||||
assert match.template_id == "MOCK_template_direct_a"
|
||||
assert match.v4_rank == 3
|
||||
assert match.selection_path == "rank_3_fallback"
|
||||
assert trace["fallback_used"] is True
|
||||
assert trace["selected_rank"] == 3
|
||||
|
||||
# Trace must preserve all 3 candidate entries with precise reasons
|
||||
candidates = trace["candidates"]
|
||||
by_rank = {c["rank"]: c for c in candidates}
|
||||
assert set(by_rank.keys()) == {1, 2, 3}
|
||||
|
||||
# rank-1 : non-direct first occurrence (status_not_allowed reason preserved)
|
||||
assert by_rank[1]["decision"] == "skipped"
|
||||
assert by_rank[1]["reason"] == "phase_z_status_not_allowed:fallback_candidate"
|
||||
assert by_rank[1]["template_id"] == "MOCK_template_dup_a"
|
||||
assert by_rank[1]["v4_label"] == "reject"
|
||||
|
||||
# rank-2 : duplicate of rank-1 template (MUST be skipped as duplicate, NOT selected)
|
||||
assert by_rank[2]["decision"] == "skipped"
|
||||
assert by_rank[2]["reason"] == "duplicate_template_id"
|
||||
assert by_rank[2]["template_id"] == "MOCK_template_dup_a"
|
||||
# audit fields preserved even though duplicate
|
||||
assert by_rank[2]["v4_label"] == "use_as_is"
|
||||
assert by_rank[2]["frame_id"] == "MOCK_frame_dup_001"
|
||||
|
||||
# rank-3 : distinct executable, selected
|
||||
assert by_rank[3]["decision"] == "selected"
|
||||
assert by_rank[3]["template_id"] == "MOCK_template_direct_a"
|
||||
|
||||
|
||||
# ─── Case 4 : missing contract → skipped / chain-exhausted trace ────────────
|
||||
|
||||
|
||||
def test_missing_contract_yields_chain_exhausted_trace(patch_selector_deps):
|
||||
"""Codex #10 E4 case 4 — all ranks missing catalog contract → chain exhausted."""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_missing_contract", "MOCK_frame_001", "use_as_is"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n"
|
||||
)
|
||||
|
||||
assert match is None
|
||||
assert trace["selection_path"] == "chain_exhausted"
|
||||
candidates = trace["candidates"]
|
||||
assert any(c.get("reason") == "skipped_no_contract" for c in candidates)
|
||||
|
||||
|
||||
# ─── Case 5 : restructure / reject preserved as non-direct candidate evidence
|
||||
|
||||
|
||||
def test_restructure_reject_preserved_as_non_direct_evidence(patch_selector_deps):
|
||||
"""Codex #10 E4 case 5 + Codex #2 conceptual + Claude #11 L5 — restructure / reject
|
||||
candidates must remain visible in candidate_evidence with route hints,
|
||||
not silently discarded.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_reject_a", "MOCK_frame_001", "reject"),
|
||||
_j(2, "MOCK_template_restructure_a", "MOCK_frame_002", "restructure"),
|
||||
_j(3, "MOCK_template_direct_a", "MOCK_frame_003", "use_as_is"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n"
|
||||
)
|
||||
|
||||
assert match is not None
|
||||
assert match.template_id == "MOCK_template_direct_a"
|
||||
|
||||
candidates = trace["candidates"]
|
||||
# All 3 must appear with informative schema (L2 fields)
|
||||
by_rank = {c["rank"]: c for c in candidates}
|
||||
assert set(by_rank.keys()) == {1, 2, 3}
|
||||
|
||||
# rank-1 reject — non-direct, design_reference_only
|
||||
assert by_rank[1]["v4_label"] == "reject"
|
||||
assert by_rank[1]["filtered_for_direct_execution"] is True
|
||||
assert by_rank[1]["route_hint"] == "design_reference_only"
|
||||
|
||||
# rank-2 restructure — non-direct, ai_adaptation_required
|
||||
assert by_rank[2]["v4_label"] == "restructure"
|
||||
assert by_rank[2]["filtered_for_direct_execution"] is True
|
||||
assert by_rank[2]["route_hint"] == "ai_adaptation_required"
|
||||
|
||||
# rank-3 use_as_is — direct, direct_render
|
||||
assert by_rank[3]["v4_label"] == "use_as_is"
|
||||
assert by_rank[3]["filtered_for_direct_execution"] is False
|
||||
assert by_rank[3]["route_hint"] == "direct_render"
|
||||
|
||||
|
||||
# ─── Case 6 : additive fields do not regress existing trace shape ───────────
|
||||
|
||||
|
||||
def test_existing_trace_shape_does_not_regress(patch_selector_deps):
|
||||
"""Codex #10 E4 case 6 + Claude #11 L9 — additive L2/L3 fields must not break
|
||||
existing trace consumers. Existing fields (`label`, `fallback_used`,
|
||||
`selection_path`, `selected_rank`, etc.) must remain present and unchanged.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_direct_a", "MOCK_frame_001", "use_as_is"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n"
|
||||
)
|
||||
|
||||
# Existing top-level trace fields preserved
|
||||
expected_top_fields = {
|
||||
"section_id", "max_rank", "selection_path", "selected_rank",
|
||||
"selected_template_id", "selected_frame_id", "selected_label",
|
||||
"fallback_used", "fallback_reason", "candidates",
|
||||
}
|
||||
assert expected_top_fields.issubset(trace.keys())
|
||||
|
||||
# Existing candidate fields preserved
|
||||
candidate = trace["candidates"][0]
|
||||
expected_candidate_fields = {
|
||||
"rank", "template_id", "frame_id", "frame_number", "confidence",
|
||||
"label", "phase_z_status", "catalog_registered", "decision", "reason",
|
||||
}
|
||||
assert expected_candidate_fields.issubset(candidate.keys())
|
||||
|
||||
# New L2 additive fields present (v4_label / filtered_for_direct_execution / route_hint)
|
||||
assert candidate["v4_label"] == candidate["label"] # alias of label
|
||||
assert "filtered_for_direct_execution" in candidate
|
||||
assert "route_hint" in candidate
|
||||
|
||||
# rank-1 use_as_is path — no fallback used
|
||||
assert trace["fallback_used"] is False
|
||||
assert trace["selection_path"] == "rank_1"
|
||||
|
||||
|
||||
# ─── Case 7 : Step 9 helper-call shape test (IMP-32 u5 — replaces source guard) ───
|
||||
|
||||
|
||||
def test_build_application_plan_unit_emits_candidate_evidence_and_alias():
|
||||
"""IMP-32 u5 — direct helper-call shape test for Step 9 evidence fields.
|
||||
|
||||
Replaces the IMP-05 Case 7 `inspect.getsource(phase_z2_pipeline)` literal
|
||||
guard (introduced at commit `23d1b25` while Step 9 unit assembly was
|
||||
inline) with a direct call to `_build_application_plan_unit`, the helper
|
||||
extracted in IMP-32 u3. Verification axes preserved:
|
||||
|
||||
- candidate_evidence list identity sourced from `selection_trace["candidates"]`
|
||||
- fallback_chain compat-alias identity (same list object as candidate_evidence)
|
||||
- key order: candidate_evidence before fallback_chain
|
||||
- compat-alias comment preserved on the helper's fallback_chain line
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.phase_z2_pipeline import _build_application_plan_unit
|
||||
|
||||
candidates_list = [
|
||||
{"rank": 1, "template_id": "MOCK_template_direct_a", "label": "use_as_is"},
|
||||
]
|
||||
selection_trace = {"candidates": candidates_list}
|
||||
|
||||
# Synthetic CompositionUnit-shape duck-typed input — matches V4Match attrs
|
||||
# used inside the helper (template_id / frame_id / frame_number / v4_rank /
|
||||
# confidence / label per src/phase_z2_pipeline.py V4Match dataclass).
|
||||
v4_candidate = SimpleNamespace(
|
||||
template_id="MOCK_template_direct_a",
|
||||
frame_id="MOCK_frame_001",
|
||||
frame_number=1,
|
||||
v4_rank=1,
|
||||
confidence=0.9,
|
||||
label="use_as_is",
|
||||
)
|
||||
unit = SimpleNamespace(
|
||||
source_section_ids=["S1"],
|
||||
v4_candidates=[v4_candidate],
|
||||
v4_rank=1,
|
||||
selection_path="rank_1",
|
||||
fallback_reason=None,
|
||||
frame_template_id="MOCK_template_direct_a",
|
||||
)
|
||||
|
||||
result = _build_application_plan_unit(
|
||||
unit=unit,
|
||||
zone_plan={},
|
||||
selection_trace=selection_trace,
|
||||
plan_record=None,
|
||||
v4_all_for_unit=[],
|
||||
layout_preset="Type A",
|
||||
layout_candidates_list=[],
|
||||
)
|
||||
|
||||
# IMP-05 L2 — candidate_evidence is the primary field, identity-bound to
|
||||
# selection_trace["candidates"] (not a copy).
|
||||
assert "candidate_evidence" in result
|
||||
assert result["candidate_evidence"] is candidates_list
|
||||
|
||||
# compat alias — fallback_chain references the SAME list object as
|
||||
# candidate_evidence (verified by `is` identity, not equality).
|
||||
assert "fallback_chain" in result
|
||||
assert result["fallback_chain"] is candidates_list
|
||||
|
||||
# key order — candidate_evidence MUST precede fallback_chain in the
|
||||
# returned dict to preserve documented L2 ordering.
|
||||
keys = list(result.keys())
|
||||
assert keys.index("candidate_evidence") < keys.index("fallback_chain")
|
||||
|
||||
# compat-alias comment preserved on the helper's fallback_chain line.
|
||||
helper_source = inspect.getsource(_build_application_plan_unit)
|
||||
assert "compat alias; prefer candidate_evidence" in helper_source
|
||||
|
||||
|
||||
# ─── Case 8 : Step 20 slide-status qualifier fields presence + defensive default
|
||||
|
||||
|
||||
def test_step20_slide_status_qualifier_fields_present_with_defensive_defaults():
|
||||
"""Codex #10 D4 + Codex #17 idea F + Claude #21 idea J — Step 20 slide-status
|
||||
must expose `fallback_selection_count` and `selection_paths[]` derived from
|
||||
comp_debug["v4_fallback_summary"] with defensive defaults (0, []) when the
|
||||
summary is missing or empty. Top-level `overall` enum must remain stable.
|
||||
"""
|
||||
from src.phase_z2_pipeline import compute_slide_status
|
||||
from src.phase_z2_pipeline import MdxSection
|
||||
|
||||
# Case A — comp_debug with populated v4_fallback_summary
|
||||
sections_empty: list[MdxSection] = []
|
||||
units_empty: list = []
|
||||
overflow_pass = {"passed": True, "fail_reasons": []}
|
||||
comp_debug_with = {
|
||||
"v4_fallback_summary": {
|
||||
"fallback_used_count": 1,
|
||||
"fallback_selection_count": 1,
|
||||
"selection_paths": [
|
||||
{"section_id": "S1", "selection_path": "rank_2_fallback",
|
||||
"selected_rank": 2, "selected_template_id": "MOCK_T",
|
||||
"fallback_trigger": "phase_z_status_not_allowed:fallback_candidate"},
|
||||
],
|
||||
},
|
||||
"candidates_summary": [],
|
||||
}
|
||||
status_a = compute_slide_status(
|
||||
sections_empty, units_empty, comp_debug_with, overflow_pass,
|
||||
adapter_needed_units=None, debug_zones=None,
|
||||
)
|
||||
# Step 20 qualifier fields present near existing fallback fields (Codex F ordering)
|
||||
assert "fallback_selection_count" in status_a
|
||||
assert "selection_paths" in status_a
|
||||
assert status_a["fallback_selection_count"] == 1
|
||||
assert len(status_a["selection_paths"]) == 1
|
||||
assert status_a["selection_paths"][0]["section_id"] == "S1"
|
||||
# Existing fields preserved (no regression)
|
||||
assert "fallback_used" in status_a
|
||||
assert "fallback_selections" in status_a
|
||||
assert "overall" in status_a
|
||||
|
||||
# Case B — comp_debug missing v4_fallback_summary (defensive defaults)
|
||||
comp_debug_empty = {"candidates_summary": []}
|
||||
status_b = compute_slide_status(
|
||||
sections_empty, units_empty, comp_debug_empty, overflow_pass,
|
||||
adapter_needed_units=None, debug_zones=None,
|
||||
)
|
||||
# Defensive defaults — 0 + [] when summary missing
|
||||
assert status_b["fallback_selection_count"] == 0
|
||||
assert status_b["selection_paths"] == []
|
||||
# Top-level overall enum still stable
|
||||
assert "overall" in status_b
|
||||
|
||||
# Case C — comp_debug with empty v4_fallback_summary dict
|
||||
comp_debug_empty_summary = {"v4_fallback_summary": {}, "candidates_summary": []}
|
||||
status_c = compute_slide_status(
|
||||
sections_empty, units_empty, comp_debug_empty_summary, overflow_pass,
|
||||
adapter_needed_units=None, debug_zones=None,
|
||||
)
|
||||
# Defensive defaults — 0 + [] when summary present but empty
|
||||
assert status_c["fallback_selection_count"] == 0
|
||||
assert status_c["selection_paths"] == []
|
||||
|
||||
|
||||
# ─── Case 9 : IMP-30 u1 — opt-in provisional synthesis on chain_exhausted ───
|
||||
|
||||
|
||||
def test_allow_provisional_default_off_preserves_imp05_behavior(patch_selector_deps):
|
||||
"""IMP-30 u1 — default ``allow_provisional=False`` keeps chain_exhausted
|
||||
returning ``(None, trace)`` exactly as IMP-05 specified. Regression guard
|
||||
for IMP-05 close commit 23d1b25.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_restructure_a", "MOCK_frame_001", "restructure"),
|
||||
_j(2, "MOCK_template_reject_a", "MOCK_frame_002", "reject"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n"
|
||||
)
|
||||
|
||||
assert match is None
|
||||
assert trace["selection_path"] == "chain_exhausted"
|
||||
assert trace.get("provisional") is None
|
||||
assert trace["selected_rank"] is None
|
||||
assert trace["selected_template_id"] is None
|
||||
|
||||
|
||||
def test_allow_provisional_synthesizes_rank_1_on_chain_exhausted(patch_selector_deps):
|
||||
"""IMP-30 u1 — opt-in ``allow_provisional=True`` synthesizes a provisional
|
||||
rank-1 match when the rank-1..3 chain is exhausted (all restructure/reject).
|
||||
Downstream first-render invariant uses this to render a "needs adaptation"
|
||||
zone instead of aborting.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_restructure_a", "MOCK_frame_001", "restructure"),
|
||||
_j(2, "MOCK_template_reject_a", "MOCK_frame_002", "reject"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n",
|
||||
allow_provisional=True,
|
||||
)
|
||||
|
||||
# Provisional rank-1 synthesized from the rank-1 judgment
|
||||
assert match is not None
|
||||
assert match.provisional is True
|
||||
assert match.template_id == "MOCK_template_restructure_a"
|
||||
assert match.frame_id == "MOCK_frame_001"
|
||||
assert match.label == "restructure"
|
||||
assert match.v4_rank == 1
|
||||
assert match.selection_path == "provisional_rank_1"
|
||||
# fallback_reason mirrors the chain-exhaust reason
|
||||
assert match.fallback_reason is not None
|
||||
assert "phase_z_status_not_allowed" in match.fallback_reason
|
||||
|
||||
# Top-level trace mirrors reflect provisional selection
|
||||
assert trace["selection_path"] == "provisional_rank_1"
|
||||
assert trace["selected_rank"] == 1
|
||||
assert trace["selected_template_id"] == "MOCK_template_restructure_a"
|
||||
assert trace["selected_frame_id"] == "MOCK_frame_001"
|
||||
assert trace["selected_label"] == "restructure"
|
||||
assert trace["fallback_used"] is True
|
||||
assert trace["provisional"] is True
|
||||
|
||||
# Original candidate skip reasons are preserved (not rewritten by synthesis)
|
||||
by_rank = {c["rank"]: c for c in trace["candidates"]}
|
||||
assert by_rank[1]["decision"] == "skipped"
|
||||
assert by_rank[1]["reason"] == "phase_z_status_not_allowed:extract_matched_zone"
|
||||
assert by_rank[2]["decision"] == "skipped"
|
||||
assert by_rank[2]["reason"] == "phase_z_status_not_allowed:fallback_candidate"
|
||||
|
||||
|
||||
def test_allow_provisional_no_op_when_normal_selection_succeeds(patch_selector_deps):
|
||||
"""IMP-30 u1 — ``allow_provisional=True`` is a no-op when normal selection
|
||||
succeeds. The rank-1 (or rank-N fallback) result MUST be non-provisional.
|
||||
"""
|
||||
v4 = _make_v4([
|
||||
_j(1, "MOCK_template_direct_a", "MOCK_frame_001", "use_as_is"),
|
||||
])
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n",
|
||||
allow_provisional=True,
|
||||
)
|
||||
|
||||
assert match is not None
|
||||
assert match.provisional is False
|
||||
assert match.selection_path == "rank_1"
|
||||
assert trace["selection_path"] == "rank_1"
|
||||
assert trace.get("provisional") is None
|
||||
|
||||
|
||||
def test_allow_provisional_no_op_when_no_v4_section(patch_selector_deps):
|
||||
"""IMP-30 u1 — when no V4 section is resolved (no rank-1 judgment to
|
||||
synthesize from), ``allow_provisional=True`` MUST still return
|
||||
``(None, trace)``. u3/u4 handle this case with a placeholder zone or
|
||||
empty-shell terminal slide.
|
||||
"""
|
||||
v4 = {"mdx_sections": {}} # no section at all
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n",
|
||||
allow_provisional=True,
|
||||
)
|
||||
|
||||
assert match is None
|
||||
assert trace["fallback_reason"] == "no_v4_section"
|
||||
|
||||
|
||||
def test_allow_provisional_no_op_when_empty_judgments(patch_selector_deps):
|
||||
"""IMP-30 u1 — when the V4 section exists but ``judgments_full32`` is
|
||||
empty, ``allow_provisional=True`` MUST still return ``(None, trace)``.
|
||||
No synthetic rank-1 can be fabricated from nothing.
|
||||
"""
|
||||
v4 = {"mdx_sections": {"S1": {"judgments_full32": []}}}
|
||||
|
||||
match, trace = lookup_v4_match_with_fallback(
|
||||
v4, "S1", raw_content="- a\n- b\n- c\n",
|
||||
allow_provisional=True,
|
||||
)
|
||||
|
||||
assert match is None
|
||||
assert trace["fallback_reason"] == "empty_v4_judgments"
|
||||
Reference in New Issue
Block a user