169 lines
6.5 KiB
Python
169 lines
6.5 KiB
Python
"""Сборка набора (спека §7): структура каталога, детерминированность, README прогона, граничные настройки."""
|
||
|
||
import hashlib
|
||
import time
|
||
from dataclasses import replace
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from synthgen import generate as gen
|
||
from synthgen.__main__ import main
|
||
from synthgen.config import Config, load_config
|
||
|
||
SMALL = Config(group_size=3, periods=2, osv_counterparties_mean=5, no_inn_companies=1)
|
||
|
||
|
||
def _digest(root: Path) -> dict[str, str]:
|
||
return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(root.rglob("*")) if p.is_file()}
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def default_run(tmp_path_factory):
|
||
out = tmp_path_factory.mktemp("default")
|
||
manifest = gen.generate(Config(), out)
|
||
return out, manifest
|
||
|
||
|
||
def test_layout(default_run):
|
||
out, manifest = default_run
|
||
assert (out / "README.md").stat().st_size > 0
|
||
assert (out / "config.yaml").is_file()
|
||
assert (out / "directories" / "ecp-SYN.xlsx").is_file()
|
||
codes = [f"C{i:02d}" for i in range(1, 6)]
|
||
assert sorted(p.name for p in (out / "osv").iterdir()) == codes
|
||
assert sorted(p.name for p in (out / "statements").iterdir()) == codes
|
||
assert len(list(out.glob("osv/*/*/*.xlsx"))) == 5 * 4 * 9
|
||
assert len(list(out.glob("statements/*/*/ББ_*.xlsx"))) == 5 * 4
|
||
assert len(list(out.glob("statements/*/*/ОФР_*.xlsx"))) == 5 * 4
|
||
assert sorted(p.name for p in (out / "osv" / "C01").iterdir()) == ["2025-FY", "2025-Q1", "2025-Q2", "2025-Q3"]
|
||
# манифест перечисляет все файлы набора
|
||
on_disk = {p for p in out.rglob("*") if p.is_file()}
|
||
assert set(manifest.files) == on_disk
|
||
|
||
|
||
def test_determinism(tmp_path):
|
||
a, b, c = tmp_path / "a", tmp_path / "b", tmp_path / "c"
|
||
gen.generate(SMALL, a)
|
||
gen.generate(SMALL, b)
|
||
gen.generate(replace(SMALL, seed=2), c)
|
||
assert _digest(a) == _digest(b)
|
||
assert str(a) not in (a / "config.yaml").read_text(encoding="utf-8")
|
||
d = tmp_path / "d"
|
||
gen.generate(replace(SMALL, out=d), d)
|
||
assert _digest(a) == _digest(d) # путь результата не попадает в файлы
|
||
assert _digest(a) != _digest(c)
|
||
|
||
|
||
def test_out_not_empty(tmp_path, capsys):
|
||
out = tmp_path / "res"
|
||
gen.generate(SMALL, out)
|
||
with pytest.raises(FileExistsError):
|
||
gen.generate(SMALL, out)
|
||
junk = out / "old.txt"
|
||
junk.write_text("старое", encoding="utf-8")
|
||
gen.generate(SMALL, out, force=True)
|
||
assert not junk.exists()
|
||
assert (out / "README.md").is_file()
|
||
|
||
args = ["--set", "group_size=3", "--set", "periods=1", "--set", "osv_counterparties_mean=3", "--out", str(out)]
|
||
assert main(args) == 3
|
||
assert "не пуст" in capsys.readouterr().err
|
||
assert main([*args, "--force"]) == 0
|
||
assert str(out) in capsys.readouterr().out
|
||
|
||
|
||
def test_force_refuses_foreign_dir(tmp_path, capsys):
|
||
foreign = tmp_path / "foreign"
|
||
foreign.mkdir()
|
||
(foreign / "data.txt").write_text("чужое", encoding="utf-8")
|
||
with pytest.raises(FileExistsError):
|
||
gen.generate(SMALL, foreign, force=True)
|
||
assert (foreign / "data.txt").exists()
|
||
args = ["--set", "group_size=2", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--force"]
|
||
assert main([*args, "--out", str(foreign)]) == 3
|
||
assert "не похож на набор" in capsys.readouterr().err
|
||
|
||
repo = tmp_path / "repo"
|
||
gen.generate(SMALL, repo)
|
||
(repo / ".git").mkdir()
|
||
with pytest.raises(FileExistsError):
|
||
gen.generate(SMALL, repo, force=True)
|
||
assert (repo / ".git").exists()
|
||
|
||
not_dir = tmp_path / "file.txt"
|
||
not_dir.write_text("x", encoding="utf-8")
|
||
assert main([*args, "--out", str(not_dir)]) == 3
|
||
|
||
|
||
def test_default_out_is_prefix_dir(tmp_path, monkeypatch):
|
||
monkeypatch.chdir(tmp_path)
|
||
assert main(["--set", "group_size=2", "--set", "periods=1", "--set", "osv_counterparties_mean=2", "--set", "prefix=ZZ"]) == 0
|
||
assert (tmp_path / "out" / "ZZ" / "README.md").is_file()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"overrides",
|
||
[
|
||
{"group_size": 2},
|
||
{"osv_counterparties_mean": 0},
|
||
{"group_size": 40, "periods": 1, "osv_counterparties_mean": 10},
|
||
{"no_inn_companies": 5},
|
||
{"vgo_mismatch_share": 1},
|
||
{"start": "2025-Q4", "periods": 2},
|
||
],
|
||
ids=["size2", "mean0", "size40", "all-no-inn", "all-mismatch", "from-q4"],
|
||
)
|
||
def test_edge_configs(tmp_path, overrides):
|
||
base = {"osv_counterparties_mean": 4, "periods": 1, **overrides}
|
||
cfg = load_config(None, [f"{k}={v}" for k, v in base.items()])
|
||
manifest = gen.generate(cfg, tmp_path / "x")
|
||
readme = (tmp_path / "x" / "README.md").read_text(encoding="utf-8")
|
||
assert readme.strip()
|
||
if cfg.group_size <= 5: # каждый файл набора открывается
|
||
import openpyxl
|
||
|
||
for path in manifest.files:
|
||
if path.suffix == ".xlsx":
|
||
openpyxl.load_workbook(path).close()
|
||
if not manifest.pairs:
|
||
assert "Пар ВГО нет" in readme
|
||
|
||
|
||
def _dec(v):
|
||
from decimal import Decimal
|
||
|
||
return Decimal(str(v))
|
||
|
||
|
||
def test_readme_lists_mismatches(tmp_path):
|
||
cfg = replace(SMALL, group_size=5, vgo_mismatch_share=_dec(1))
|
||
manifest = gen.generate(cfg, tmp_path / "x")
|
||
readme = (tmp_path / "x" / "README.md").read_text(encoding="utf-8")
|
||
mismatches = [(p, m) for p in manifest.pairs for m in p.mismatches]
|
||
assert mismatches
|
||
for p, m in mismatches:
|
||
line = next((ln for ln in readme.splitlines() if p.a in ln and "→" in ln and m.period_key in ln and f"{m.pct}" in ln), None)
|
||
assert line is not None, (p.a, p.b, m)
|
||
assert "выше порога" in line or "ниже порога" in line
|
||
|
||
|
||
def test_readme_notes(default_run):
|
||
out, _ = default_run
|
||
readme = (out / "README.md").read_text(encoding="utf-8")
|
||
for needle in ("90.01", "MULTIPLE_RULES", "правил", "80", "58", "За 3 месяца", "вариантн", "ручн", "платформ"):
|
||
assert needle in readme
|
||
for marker in ("M-4", "M-5", "T4.", "O-2", "O-3", "O-5", "O-6", "OPEN-QUESTION"):
|
||
assert marker not in readme
|
||
assert "не оракул" in readme
|
||
|
||
|
||
@pytest.mark.slow
|
||
def test_large_run_time(tmp_path):
|
||
cfg = Config(group_size=15, periods=6, osv_counterparties_mean=150)
|
||
t0 = time.monotonic()
|
||
manifest = gen.generate(cfg, tmp_path / "big")
|
||
elapsed = time.monotonic() - t0
|
||
print(f"large run: {elapsed:.1f} s, files={len(manifest.files)}")
|
||
assert elapsed < 300
|