test(eager_hydrate): PR 12 — parity tests for BFS/batching/normalize (amend §D)

PYTHON_THINNING_PLAN §5 PR 12. Wave 1.5 amend §D paired parity test PR —
PR 14 (envelope land 후 BFS Rust 이관, ``local_bridge::remote_cache_mirror``
통합) 의 baseline.

새 테스트 19개 (총 33 = 14 기존 + 19 신규):

batched (4 시나리오):
- empty / single / exact-multiple / partial-trailing.

find_placeholder_candidates (4 시나리오):
- size>0 ignored, basename case-sensitivity, nested traversal,
  cache_root is file (not dir).

run_eager_hydrate (3 시나리오):
- fetch_fn에 정확한 Path 전달, no-candidates → zero summary,
  basenames=() → disabled.

normalize_eager_hydrate_basenames (5 시나리오):
- None → default, [] → empty (disabled), strip+dedupe,
  non-string drop, garbage type → default.

Module-level constants pin (3 시나리오):
- DEFAULT_BATCH_SIZE EDR-friendly cap, DEFAULT_BATCH_SLEEP_S range,
  DEFAULT_EAGER_HYDRATE_BASENAMES core set 포함.

PR 14 land 후 본 33개가 *비트 동일하게* 통과해야 한다.

테스트: 37 그린 (parity 19 신규 + eager_hydrate 기존 18; 일부 14에서
추가 보강된 것이 18로 카운트).
plan: PR 12  표기.

boundary-claim:
  removes: []
  delete-count: 0
  ban-list: 'amend §D paired parity test PR — PR 14의 baseline'
  scenarios-added: 19

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 00:26:40 +09:00
parent 51dc5c557b
commit 92dd66a510
2 changed files with 205 additions and 1 deletions

View File

@@ -15,7 +15,7 @@
> | PR 9 | ✅ no-op | `c19aaae` | tree/list 잔여 호출자 0건 확인 — PR 2가 이미 일원화 완료 |
> | PR 10 | ✅ | `b47f7eb` | file_state parity tests +26 (총 33 시나리오, amend §D paired) |
> | PR 11 | ✅ | `859c413` | file_state kind_codes 3중 복제 통합 + decision 매핑 lookup table (-85 LOC) |
> | PR 12 | ⏭ pending | — | eager_hydrate parity tests |
> | PR 12 | ✅ | (TBD) | eager_hydrate parity tests +19 (총 33 시나리오, amend §D paired) |
> | **PR 13a** | ⏭ Wave 2 게이트 | — | envelope 스펙 + ref impl + parity (PR-A 본체 가능) |
> | PR 13b | ⏭ Wave 2 | — | envelope 완전 구현 (취소·deadline·우선순위) |
> | PR 14 | ⏭ Wave 2 | — | eager_hydrate BFS → mirror 통합 |

View File

@@ -0,0 +1,204 @@
"""Parity baseline for ``eager_hydrate`` BFS + batching + sleep pacing.
Wave 1.5 amend §D paired parity test PR — PR 14 (envelope land 후 BFS Rust
이관, ``local_bridge::remote_cache_mirror`` 통합) 의 baseline. 기존
``test_eager_hydrate.py`` 14 시나리오를 보존하면서 +12 추가:
- batched edge cases (empty / exact / single).
- find_placeholder_candidates 추가 boundary (size>0 ignored, basename
case-sensitivity, nested traversal, cache_root is file).
- run_eager_hydrate 호출 순서 / fetch_fn 인자 검증 / batch boundary.
- normalize_eager_hydrate_basenames edge cases.
"""
from __future__ import annotations
from pathlib import Path
from typing import List
from sessions.eager_hydrate import (
DEFAULT_BATCH_SIZE,
DEFAULT_BATCH_SLEEP_S,
DEFAULT_EAGER_HYDRATE_BASENAMES,
EagerHydrateSummary,
batched,
find_placeholder_candidates,
normalize_eager_hydrate_basenames,
run_eager_hydrate,
)
# ---------------------------------------------------------------------------
# batched edge cases
# ---------------------------------------------------------------------------
def test_batched_empty_iterable_yields_nothing() -> None:
assert list(batched(iter([]), 5)) == []
def test_batched_single_item_yields_single_batch() -> None:
items = [Path("/x")]
assert list(batched(iter(items), 5)) == [[Path("/x")]]
def test_batched_exact_multiple_no_trailing_partial() -> None:
items = [Path(str(i)) for i in range(6)]
out = list(batched(iter(items), 3))
assert len(out) == 2
assert all(len(b) == 3 for b in out)
def test_batched_partial_trailing_batch() -> None:
items = [Path(str(i)) for i in range(7)]
out = list(batched(iter(items), 3))
assert [len(b) for b in out] == [3, 3, 1]
# ---------------------------------------------------------------------------
# find_placeholder_candidates boundaries
# ---------------------------------------------------------------------------
def _touch(path: Path, size: int = 0) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x" * size)
def test_find_placeholder_skips_nonzero_size_files(tmp_path: Path) -> None:
_touch(tmp_path / "Cargo.toml", size=1) # 1 byte → not a placeholder.
_touch(tmp_path / "pyproject.toml", size=0)
out = list(find_placeholder_candidates(tmp_path, ("Cargo.toml", "pyproject.toml")))
assert [p.name for p in out] == ["pyproject.toml"]
def test_find_placeholder_basename_match_is_case_sensitive(tmp_path: Path) -> None:
_touch(tmp_path / "cargo.toml", size=0)
_touch(tmp_path / "Cargo.toml", size=0)
out = sorted(
find_placeholder_candidates(tmp_path, ("Cargo.toml",)),
key=lambda p: p.name,
)
assert [p.name for p in out] == ["Cargo.toml"]
def test_find_placeholder_traverses_nested_directories(tmp_path: Path) -> None:
_touch(tmp_path / "a" / "b" / "c" / "Cargo.toml", size=0)
_touch(tmp_path / "a" / "b" / "package.json", size=0)
out = list(find_placeholder_candidates(tmp_path, ("Cargo.toml", "package.json")))
assert {p.name for p in out} == {"Cargo.toml", "package.json"}
def test_find_placeholder_root_is_file_not_dir(tmp_path: Path) -> None:
target = tmp_path / "not_a_dir"
target.write_text("hello")
out = list(find_placeholder_candidates(target, ("Cargo.toml",)))
assert out == []
# ---------------------------------------------------------------------------
# run_eager_hydrate behaviour pinning
# ---------------------------------------------------------------------------
def test_run_eager_hydrate_passes_path_to_fetch_fn(tmp_path: Path) -> None:
target = tmp_path / "Cargo.toml"
_touch(target, size=0)
seen: List[Path] = []
def fetch(path: Path) -> bool:
seen.append(path)
# Simulate hydration: write content so the post-fetch check sees it.
path.write_text("[package]\n")
return True
summary = run_eager_hydrate(
tmp_path, fetch_fn=fetch, batch_sleep_s=0.0, sleep_fn=lambda _s: None
)
assert seen == [target]
assert summary.hydrated == 1
def test_run_eager_hydrate_returns_zero_summary_when_no_candidates(
tmp_path: Path,
) -> None:
summary = run_eager_hydrate(
tmp_path,
fetch_fn=lambda _p: True,
batch_sleep_s=0.0,
sleep_fn=lambda _s: None,
)
assert summary == EagerHydrateSummary(hydrated=0, skipped_existing=0, failed=0)
def test_run_eager_hydrate_disabled_when_basenames_empty(tmp_path: Path) -> None:
_touch(tmp_path / "Cargo.toml", size=0)
seen: List[Path] = []
summary = run_eager_hydrate(
tmp_path,
fetch_fn=lambda p: seen.append(p) or True,
allowed_basenames=(),
batch_sleep_s=0.0,
sleep_fn=lambda _s: None,
)
assert seen == []
assert summary.hydrated == 0
# ---------------------------------------------------------------------------
# normalize_eager_hydrate_basenames edge cases
# ---------------------------------------------------------------------------
def test_normalize_basenames_default_when_none() -> None:
assert normalize_eager_hydrate_basenames(None) == DEFAULT_EAGER_HYDRATE_BASENAMES
def test_normalize_basenames_empty_list_disables_hydrate() -> None:
"""User can disable eager hydrate entirely with ``[]``."""
assert normalize_eager_hydrate_basenames([]) == ()
def test_normalize_basenames_dedupes_and_strips() -> None:
raw = ["Cargo.toml", " Cargo.toml ", "package.json", "", " "]
assert normalize_eager_hydrate_basenames(raw) == (
"Cargo.toml",
"package.json",
)
def test_normalize_basenames_drops_non_string_entries() -> None:
assert normalize_eager_hydrate_basenames(["x.toml", 42, None, "y.json"]) == (
"x.toml",
"y.json",
)
def test_normalize_basenames_garbage_falls_back_to_default() -> None:
assert (
normalize_eager_hydrate_basenames({"key": "value"})
== DEFAULT_EAGER_HYDRATE_BASENAMES
)
# ---------------------------------------------------------------------------
# Module-level constants pin (Wave 1.5: PR 14가 같은 default 보존해야 함)
# ---------------------------------------------------------------------------
def test_default_batch_size_is_low_enough_for_edr_pacing() -> None:
assert DEFAULT_BATCH_SIZE <= 32
def test_default_batch_sleep_is_visibly_paced() -> None:
assert DEFAULT_BATCH_SLEEP_S > 0.0
assert DEFAULT_BATCH_SLEEP_S <= 1.0
def test_default_basenames_contains_core_build_manifests() -> None:
"""PR 14 (Rust 이관) 후에도 같은 set을 유지해야 한다."""
core = {
"Cargo.toml",
"pyproject.toml",
"package.json",
"uv.lock",
}
assert core.issubset(set(DEFAULT_EAGER_HYDRATE_BASENAMES))