Files
Mini-Nav/tests/test_run_cam_correctness.py
SikongJueluo d6e1b9d8ba refactor(cam): rename read-noise path to read-pass-through and reorganize test module structure
- Rename `read_noise` scenarios and noise_mode to `read_pass_through` across
  run_cam_correctness.py, test_run_cam_correctness.py, and Makefiles
- Update RTL comment in match_engine_pipeline.sv to reflect pass-through behavior
- Move unit-level cocotb tests from `tests.test_*` flat namespace to
  `tests.modules.*` and `tests.top.*` subdirectory layout, matching actual
  Makefile paths (hw/sim/tests/modules/..., hw/sim/tests/top/...)
- Remove redundant dual-noise subtarget from read_noise/Makefile
- Update help text and docs to reflect read-path pass-through semantics
- Add .codegraph to .gitignore
2026-05-27 16:34:34 +08:00

579 lines
22 KiB
Python

#!/usr/bin/env python3
"""Contract tests for scripts/run_cam_correctness.py."""
from __future__ import annotations
import importlib.util
import subprocess
from pathlib import Path
from typer.testing import CliRunner
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def load_runner():
"""Load scripts/run_cam_correctness via importlib."""
path = str(
Path(__file__).resolve().parent.parent / "scripts" / "run_cam_correctness.py"
)
spec = importlib.util.spec_from_file_location("run_cam_correctness", path)
assert spec is not None, f"Could not find spec for {path}"
assert spec.loader is not None, f"No loader available for {path}"
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class FakeCompletedProcess:
"""Stand-in for subprocess.CompletedProcess."""
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeSubprocess:
"""Record subprocess calls; return pre-configured return codes.
Parameters
----------
returncodes:
Sequence of return codes consumed in order.
exception_indices:
Call indices at which to raise ``subprocess.TimeoutExpired``
instead of returning a result.
"""
def __init__(self, returncodes=None, exception_indices=None):
self.returncodes = returncodes or []
self.exception_indices = set(exception_indices or [])
self.calls = []
self._index = 0
def run(self, command, cwd=None, env=None, text=None, capture_output=None, timeout=None):
self.calls.append({
"command": command,
"cwd": cwd,
"env": dict(env or {}),
"text": text,
"capture_output": capture_output,
"timeout": timeout,
})
idx = self._index
self._index += 1
if idx in self.exception_indices:
raise subprocess.TimeoutExpired(cmd=command, timeout=timeout or 1800)
rc = (
self.returncodes[idx]
if idx < len(self.returncodes)
else 0
)
stdout = "fake stdout\n"
stderr = "fake stderr\n" if rc != 0 else ""
return FakeCompletedProcess(returncode=rc, stdout=stdout, stderr=stderr)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestRunnerContract:
"""Contract tests for scripts/run_cam_correctness."""
def test_default_scenarios_match_approved_spec(self):
runner = load_runner()
names = [s.name for s in runner.default_scenarios]
assert names == [
"cam_top_basic",
"cam_top_write_noise",
"cam_top_read_path",
"cam_core_banked",
"match_engine_pipeline",
"cam_write_noise",
"cam_read_path",
"ref_model_noise",
]
sc = {s.name: s for s in runner.default_scenarios}
# --- cam_top_basic ---
s = sc["cam_top_basic"]
assert s.name == "cam_top_basic"
assert s.kind == "cocotb"
assert s.module == "tests.top.no_noise.test_no_noise"
assert s.testcase is None
assert s.params == {}
assert s.noise_mode == "none"
assert s.expected == "match"
# --- cam_top_write_noise ---
s = sc["cam_top_write_noise"]
assert s.name == "cam_top_write_noise"
assert s.kind == "cocotb"
assert s.module == "tests.top.write_noise.test_write_noise"
assert s.testcase is None
assert s.params == {}
assert s.noise_mode == "write_noise"
assert s.expected == "bit_flip_injected"
# --- cam_top_read_path ---
s = sc["cam_top_read_path"]
assert s.name == "cam_top_read_path"
assert s.kind == "cocotb"
assert s.module == "tests.top.read_noise.test_read_noise"
assert s.testcase is None
assert s.params == {}
assert s.noise_mode == "read_pass_through"
assert s.expected == "pure_matching"
# --- cam_core_banked ---
s = sc["cam_core_banked"]
assert s.name == "cam_core_banked"
assert s.kind == "cocotb"
assert s.module == "tests.modules.cam_core_banked.test_cam_core_banked"
assert s.testcase is None
assert s.params == {}
assert s.noise_mode == "none"
assert s.expected == "read_write_aligned"
# --- match_engine_pipeline ---
s = sc["match_engine_pipeline"]
assert s.name == "match_engine_pipeline"
assert s.kind == "cocotb"
assert s.module == "tests.modules.match_engine_pipeline.test_match_engine_pipeline"
assert s.testcase is None
assert s.params == {}
assert s.noise_mode == "none"
assert s.expected == "top1_hamming_correct"
# --- cam_write_noise ---
s = sc["cam_write_noise"]
assert s.name == "cam_write_noise"
assert s.kind == "cocotb"
assert s.module == "tests.modules.cam_write_noise.test_cam_write_noise"
assert s.testcase is None
assert s.params == {}
assert s.noise_mode == "write_noise"
assert s.expected == "bit_flip_injected"
# --- cam_read_path ---
s = sc["cam_read_path"]
assert s.name == "cam_read_path"
assert s.kind == "cocotb"
assert s.module == "tests.modules.cam_read_noise.test_cam_read_noise"
assert s.testcase is None
assert s.params == {}
assert s.noise_mode == "read_pass_through"
assert s.expected == "pass_through"
# --- ref_model_noise ---
s = sc["ref_model_noise"]
assert s.name == "ref_model_noise"
assert s.kind == "pytest"
assert s.module is None
assert s.testcase is None
assert s.params == {}
assert s.noise_mode == "none"
assert s.expected == "reference_noise_model_pass"
def test_build_cocotb_command_includes_make_params(self):
runner = load_runner()
scenario = runner.Scenario(
name="cam_top_write_noise",
kind="cocotb",
module="tests.test_cam_basic",
testcase="exact_noise_model_match",
params={"WRITE_NOISE_EN": "1", "WRITE_NOISE_RATE_NUM": "1"},
noise_mode="write_noise",
expected="bit_flip_injected",
)
cmd = runner.build_scenario_command(scenario)
assert cmd == [
"make",
"-C",
"hw/sim/tests/top/write_noise",
"NUM_ROWS=512",
"HASH_BITS=512",
"LANES=8",
"COCOTB_TEST_MODULES=tests.test_cam_basic",
"COCOTB_TESTCASE=exact_noise_model_match",
"WRITE_NOISE_EN=1",
"WRITE_NOISE_RATE_NUM=1",
]
def test_build_cocotb_command_uses_correct_make_directory(self):
"""Unit-level cocotb scenarios build in hw/sim/tests/modules/...,
top-level scenarios in hw/sim/tests/top/..."""
runner = load_runner()
sc = {s.name: s for s in runner.default_scenarios}
# Unit scenarios — make -C hw/sim/tests/modules/<subpath>
for name, subpath in [
("cam_core_banked", "modules/cam_core_banked"),
("match_engine_pipeline", "modules/match_engine_pipeline"),
("cam_write_noise", "modules/cam_write_noise"),
("cam_read_path", "modules/cam_read_noise"),
]:
cmd = runner.build_scenario_command(sc[name])
c_flag = cmd[cmd.index("-C") + 1]
assert c_flag == f"hw/sim/tests/{subpath}", (
f"Expected -C hw/sim/tests/{subpath} for {name!r}, got -C {c_flag}"
)
# Top-level scenarios — make -C hw/sim/tests/top/<subpath>
for name, subpath in [
("cam_top_basic", "top/no_noise"),
("cam_top_write_noise", "top/write_noise"),
("cam_top_read_path", "top/read_noise"),
]:
cmd = runner.build_scenario_command(sc[name])
c_flag = cmd[cmd.index("-C") + 1]
assert c_flag == f"hw/sim/tests/{subpath}", (
f"Expected -C hw/sim/tests/{subpath} for {name!r}, got -C {c_flag}"
)
def test_build_pytest_command_uses_reference_model_test(self):
runner = load_runner()
scenario = runner.Scenario(
name="ref_model_noise",
kind="pytest",
module="tests.test_ref_model_noise",
testcase=None,
params={},
noise_mode="none",
expected="reference_noise_model_pass",
)
cmd = runner.build_scenario_command(scenario)
assert cmd == [
"uv",
"run",
"pytest",
"hw/sim/tests/model/test_ref_model_noise.py",
"-q",
]
def test_run_all_writes_outputs_and_continues_after_failure(
self, tmp_path, monkeypatch
):
runner = load_runner()
fake_subp = FakeSubprocess(returncodes=[0, 0, 0, 1, 0])
monkeypatch.setattr(runner.subprocess, "run", fake_subp.run)
time_values = iter([0.0, 1.0, 1.0, 2.5, 2.5, 3.0, 3.0, 4.0, 4.0, 5.0])
monkeypatch.setattr(runner.time, "monotonic", lambda: next(time_values))
scenarios = [
runner.default_scenarios[0], # cam_top_basic
runner.default_scenarios[4], # match_engine_pipeline
runner.default_scenarios[7], # ref_model_noise
]
date = "2026-05-14"
output_root = tmp_path
output_dir = output_root / date
log_dir = output_dir / "logs"
exit_code = runner.run_all(
scenarios=scenarios,
num_rows=512,
hash_bits=512,
lanes=8,
output_root=str(tmp_path),
date=date,
)
assert exit_code == 1
assert len(fake_subp.calls) == 5
# Exact file paths — no globs
results_csv = output_dir / "results.csv"
raw_csv = output_dir / "raw_results.csv"
summary_md = output_dir / "summary.md"
assert results_csv.is_file(), f"Missing {results_csv}"
assert raw_csv.is_file(), f"Missing {raw_csv}"
assert summary_md.is_file(), f"Missing {summary_md}"
# Output / log files exist
assert log_dir.is_dir()
for scenario in scenarios:
log_file = log_dir / f"{scenario.name}.log"
assert log_file.is_file(), f"Missing log: {log_file}"
assert "fake stdout\n" in log_file.read_text()
# Results CSV content
results_text = results_csv.read_text()
assert (
"cam_top_basic,pass,512,512,8,none,match,match,logs/cam_top_basic.log"
in results_text
)
assert (
"match_engine_pipeline,fail,512,512,8,none,top1_hamming_correct,failed,"
"logs/match_engine_pipeline.log" in results_text
)
assert (
"popcount_pipeline_indirect,fail,512,512,8,none,hamming_score_correct,"
"failed,logs/match_engine_pipeline.log" in results_text
)
assert (
"ref_model_noise,pass,512,512,8,none,reference_noise_model_pass,"
"reference_noise_model_pass,logs/ref_model_noise.log" in results_text
)
# Raw CSV header and data
raw_text = raw_csv.read_text()
assert (
"scenario,status,returncode,duration_sec,command,log_path,results_xml"
in raw_text
)
assert "match_engine_pipeline,fail,1,1.0" in raw_text
# Summary content checks
summary_text = summary_md.read_text()
assert "# CAM 功能正确性验证摘要" in summary_text
assert "**总场景数:** 4" in summary_text
assert "**失败数:** 2" in summary_text
assert "**通过数:** 2" in summary_text
assert "**运行日期:**" in summary_text
# Config parameter lines
assert "NUM_ROWS=512" in summary_text
assert "HASH_BITS=512" in summary_text
assert "LANES=8" in summary_text
# Output directory
assert "**输出目录:**" in summary_text
# Scenario result table header
assert "| 场景 | 状态 | 噪声模式 | 预期 | 实际 | 日志 |" in summary_text
assert "|------|------|----------|------|------|------|" in summary_text
# Table contains at least one pass and one fail row
assert "cam_top_basic | pass | none | match | match" in summary_text
assert "match_engine_pipeline | fail | none | top1_hamming_correct | failed" in summary_text
assert "popcount_pipeline_indirect | fail | none | hamming_score_correct | failed" in summary_text
assert "ref_model_noise | pass | none | reference_noise_model_pass" in summary_text
# Failed scenarios section — must include log path for failed rows
assert "## 失败场景" in summary_text
assert "**match_engine_pipeline**" in summary_text
assert "`logs/match_engine_pipeline.log`" in summary_text
assert "**popcount_pipeline_indirect**" in summary_text
# Coverage section headings and key bullets
assert "## 覆盖说明" in summary_text
assert "**cam_top**" in summary_text
assert "**cam_core_banked**" in summary_text
assert "**match_engine_pipeline**" in summary_text
assert "**popcount_pipeline**" in summary_text
assert "**cam_write_noise**" in summary_text
assert "**cam_read_noise**" in summary_text
# Conclusion section heading
assert "## 论文可引用结论草稿" in summary_text
assert "`results.csv`" in summary_text
assert "`raw_results.csv`" in summary_text
def test_result_rows_popcount_derived_row_pass(self):
"""Derived popcount_pipeline_indirect row shows hamming_score_correct when match_engine_pipeline passes."""
runner = load_runner()
pass_run = runner.ScenarioRun(
scenario=runner.default_scenarios[4], # match_engine_pipeline
status="pass",
returncode=0,
duration_sec=1.0,
command=["make", "-C", "hw/sim"],
log_path=Path("logs/match_engine_pipeline.log"),
results_xml="",
)
rows = runner.result_rows([pass_run])
derived = [r for r in rows if r["test_name"] == "popcount_pipeline_indirect"]
assert len(derived) == 1
assert derived[0]["expected"] == "hamming_score_correct"
assert derived[0]["actual"] == "hamming_score_correct"
assert derived[0]["status"] == "pass"
def test_run_one_scenario_handles_subprocess_timeout(self, tmp_path, monkeypatch):
"""Subprocess.TimeoutExpired is caught and recorded as a failure."""
runner = load_runner()
fake_subp = FakeSubprocess(returncodes=[0], exception_indices={0})
monkeypatch.setattr(runner.subprocess, "run", fake_subp.run)
monkeypatch.setattr(runner.time, "monotonic", lambda: 0.0)
scenario = runner.default_scenarios[0] # cam_top_basic (cocotb)
output_dir = tmp_path / "2026-05-14"
(output_dir / "logs").mkdir(parents=True)
run = runner.run_one_scenario(
scenario,
num_rows=512,
hash_bits=512,
lanes=8,
output_dir=output_dir,
repo_root=tmp_path,
)
assert run.status == "fail"
assert run.returncode == -1
log_text = (output_dir / "logs" / "cam_top_basic.log").read_text()
assert "timed out" in log_text
assert len(fake_subp.calls) == 1
assert fake_subp.calls[0]["timeout"] == 1800
def test_pytest_scenario_preserves_existing_pythonpath(self, tmp_path, monkeypatch):
"""PYTHONPATH is prepended with absolute hw/sim and preserves prior value."""
runner = load_runner()
fake_subp = FakeSubprocess(returncodes=[0])
monkeypatch.setattr(runner.subprocess, "run", fake_subp.run)
monkeypatch.setattr(runner.time, "monotonic", lambda: 0.0)
monkeypatch.setenv("PYTHONPATH", "/existing/path")
scenario = runner.default_scenarios[7] # ref_model_noise (pytest)
output_dir = tmp_path / "2026-05-14"
(output_dir / "logs").mkdir(parents=True)
runner.run_one_scenario(
scenario,
num_rows=512,
hash_bits=512,
lanes=8,
output_dir=output_dir,
repo_root=tmp_path,
)
assert len(fake_subp.calls) == 1
env = fake_subp.calls[0]["env"]
pp = env.get("PYTHONPATH", "")
assert str(tmp_path / "hw" / "sim") in pp
assert "/existing/path" in pp
def test_cocotb_scenario_copies_results_xml(self, tmp_path, monkeypatch):
"""Existing hw/sim/results.xml is copied to logs/<scenario>.results.xml."""
runner = load_runner()
fake_subp = FakeSubprocess(returncodes=[0])
monkeypatch.setattr(runner.subprocess, "run", fake_subp.run)
monkeypatch.setattr(runner.time, "monotonic", lambda: 0.0)
# Create the source XML that cocotb would have produced
src_xml = tmp_path / "hw" / "sim" / "results.xml"
src_xml.parent.mkdir(parents=True)
src_xml.write_text("<testsuite />")
scenario = runner.default_scenarios[0] # cam_top_basic (cocotb)
output_dir = tmp_path / "2026-05-14"
(output_dir / "logs").mkdir(parents=True)
run = runner.run_one_scenario(
scenario,
num_rows=512,
hash_bits=512,
lanes=8,
output_dir=output_dir,
repo_root=tmp_path,
)
# The source file should have been copied
dst_xml = output_dir / "logs" / "cam_top_basic.results.xml"
assert dst_xml.is_file(), f"Missing copied XML: {dst_xml}"
assert dst_xml.read_text() == "<testsuite />"
# ScenarioRun points to the copied file
assert run.results_xml == "logs/cam_top_basic.results.xml"
def test_cocotb_scenario_no_results_xml_means_empty_string(self, tmp_path, monkeypatch):
"""When no hw/sim/results.xml exists, results_xml is empty string."""
runner = load_runner()
fake_subp = FakeSubprocess(returncodes=[0])
monkeypatch.setattr(runner.subprocess, "run", fake_subp.run)
monkeypatch.setattr(runner.time, "monotonic", lambda: 0.0)
scenario = runner.default_scenarios[0] # cam_top_basic (cocotb)
output_dir = tmp_path / "2026-05-14"
(output_dir / "logs").mkdir(parents=True)
run = runner.run_one_scenario(
scenario,
num_rows=512,
hash_bits=512,
lanes=8,
output_dir=output_dir,
repo_root=tmp_path,
)
assert run.results_xml == ""
# No .results.xml file should exist
assert not (output_dir / "logs" / "cam_top_basic.results.xml").exists()
# ------------------------------------------------------------------
# CLI / Typer tests
# ------------------------------------------------------------------
def test_cli_defaults_without_args(self, monkeypatch):
"""Invoking the CLI without args uses all defaults and script-derived repo_root."""
import datetime as dt
runner = load_runner()
captured = {}
def fake_run_all(**kwargs):
captured.update(kwargs)
return 0
monkeypatch.setattr(runner, "run_all", fake_run_all)
# Replace the module-level date_type reference with a mock so today() is
# deterministic. We cannot monkeypatch datetime.date.today directly
# because datetime.date is an immutable C type.
class FakeDate:
@classmethod
def today(cls):
return dt.date(2026, 5, 15)
monkeypatch.setattr(runner, "date_type", FakeDate)
cli_runner = CliRunner()
result = cli_runner.invoke(runner.app, [])
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert captured["num_rows"] == 512
assert captured["hash_bits"] == 512
assert captured["lanes"] == 8
assert captured["output_root"] == Path("outputs/cam_correctness")
assert captured["date"] == "2026-05-15"
assert captured["scenarios"] == runner.default_scenarios
assert captured["repo_root"] == Path(__file__).resolve().parent.parent
def test_typer_help_exposes_expected_options(self):
runner = load_runner()
cli_runner = CliRunner()
result = cli_runner.invoke(runner.app, ["--help"])
assert result.exit_code == 0
for opt in ["--num-rows", "--hash-bits", "--lanes", "--output-root", "--date"]:
assert opt in result.output, f"Expected '{opt}' in help output"
def test_cli_invokes_run_all_with_defaults(self, tmp_path, monkeypatch):
runner = load_runner()
captured = {}
def fake_run_all(**kwargs):
captured.update(kwargs)
return 0
monkeypatch.setattr(runner, "run_all", fake_run_all)
cli_runner = CliRunner()
result = cli_runner.invoke(
runner.app, ["--output-root", str(tmp_path), "--date", "2026-05-14"]
)
assert result.exit_code == 0, f"CLI failed: {result.output}"
assert captured["num_rows"] == 512
assert captured["hash_bits"] == 512
assert captured["lanes"] == 8
assert captured["output_root"] == tmp_path
assert captured["date"] == "2026-05-14"
assert captured["scenarios"] == runner.default_scenarios