zapretgui/tests/test_architecture_cleanup_contract.py
loop-uh 634649d359 fix
2026-06-22 22:17:22 +03:00

226 lines
9.5 KiB
Python

from __future__ import annotations
import ast
import inspect
from pathlib import Path
from types import SimpleNamespace
import unittest
REPO_ROOT = Path(__file__).resolve().parents[1]
SRC_ROOT = REPO_ROOT / "src"
class ArchitectureCleanupContractTests(unittest.TestCase):
def _module_tree(self, relative_path: str) -> ast.Module:
return ast.parse((SRC_ROOT / relative_path).read_text(encoding="utf-8"))
def test_page_deps_builders_do_not_receive_broad_context(self) -> None:
for relative_path in ("ui/page_deps/system.py", "ui/page_deps/presets.py"):
with self.subTest(path=relative_path):
tree = self._module_tree(relative_path)
builder_args: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name.startswith("build_"):
builder_args.extend(arg.arg for arg in node.args.args)
builder_args.extend(arg.arg for arg in node.args.kwonlyargs)
self.assertNotIn(
"context",
builder_args,
"page deps builder-ы не должны получать общий PageDepsContext",
)
def test_page_deps_common_does_not_define_broad_page_deps_context(self) -> None:
tree = self._module_tree("ui/page_deps/common.py")
class_names = {
node.name
for node in ast.walk(tree)
if isinstance(node, ast.ClassDef)
}
self.assertNotIn(
"PageDepsContext",
class_names,
"общий PageDepsContext нельзя оставлять как точку возврата к широким deps",
)
def test_page_deps_specs_cover_required_builder_parameters(self) -> None:
from ui.page_composition import PAGE_DEPS_BUILDERS
for page_name, spec in PAGE_DEPS_BUILDERS.items():
with self.subTest(page=page_name.name):
provided = {"page_name"}
provided.update(f"{feature_name}_feature" for feature_name in spec.features)
provided.update(spec.actions)
if spec.include_ui_state_store:
provided.add("ui_state_store")
signature = inspect.signature(spec.builder)
required = {
name
for name, parameter in signature.parameters.items()
if parameter.default is inspect.Parameter.empty
and parameter.kind
in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
)
}
self.assertTrue(
required <= provided,
f"{page_name.name}: PageDepsSpec не передаёт {sorted(required - provided)}",
)
def test_window_page_deps_sources_include_all_page_actions(self) -> None:
from main.window_page_deps_setup import build_window_page_deps_sources
from main.window_page_actions import WindowPageActions
from ui.page_composition import PAGE_DEPS_BUILDERS
required_actions = {
action_name
for spec in PAGE_DEPS_BUILDERS.values()
for action_name in spec.actions
}
required_features = {
feature_name
for spec in PAGE_DEPS_BUILDERS.values()
for feature_name in spec.features
}
features = SimpleNamespace(**{name: object() for name in required_features})
state = SimpleNamespace(ui=object())
page_actions = SimpleNamespace(**{name: object() for name in WindowPageActions.__dataclass_fields__})
sources = build_window_page_deps_sources(
features=features,
state=state,
page_actions=page_actions,
)
self.assertTrue(
required_actions <= set(sources.actions),
f"PageDepsSources.actions не содержит {sorted(required_actions - set(sources.actions))}",
)
def test_preset_switch_worker_requires_fast_switch_contract(self) -> None:
source = (SRC_ROOT / "winws_runtime" / "runtime" / "control_workers.py").read_text(encoding="utf-8")
tree = ast.parse(source)
worker = next(
node
for node in ast.walk(tree)
if isinstance(node, ast.ClassDef) and node.name == "PresetSwitchWorker"
)
for node in ast.walk(worker):
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id == "getattr":
args = node.args
if len(args) >= 2 and isinstance(args[1], ast.Constant):
self.assertNotEqual(args[1].value, "switch_preset_file_fast")
if isinstance(node.func, ast.Attribute):
self.assertNotEqual(
node.func.attr,
"start_from_preset_file",
"PresetSwitchWorker не должен иметь fallback на полный запуск preset",
)
def test_fast_switch_contract_does_not_call_full_start_pipeline_inside_runner(self) -> None:
for relative_path, class_name in (
("winws_runtime/runners/zapret1_runner.py", "Winws1StrategyRunner"),
("winws_runtime/runners/zapret2_runner.py", "Winws2StrategyRunner"),
):
with self.subTest(path=relative_path):
tree = self._module_tree(relative_path)
runner = next(
node
for node in ast.walk(tree)
if isinstance(node, ast.ClassDef) and node.name == class_name
)
switch_method = next(
node
for node in runner.body
if isinstance(node, ast.FunctionDef) and node.name == "switch_preset_file_fast"
)
for node in ast.walk(switch_method):
if isinstance(node, ast.Attribute):
self.assertNotEqual(
node.attr,
"_start_from_preset_file_locked",
"switch_preset_file_fast не должен откатываться в полный start pipeline",
)
def test_source_does_not_reference_obsolete_list_sidecars(self) -> None:
patterns = (".base.txt", ".user.txt")
offenders: list[str] = []
for path in SRC_ROOT.rglob("*.py"):
text = path.read_text(encoding="utf-8", errors="replace")
for line_number, line in enumerate(text.splitlines(), 1):
if any(pattern in line for pattern in patterns):
offenders.append(f"{path.relative_to(REPO_ROOT)}:{line_number}: {line.strip()}")
self.assertEqual(
offenders,
[],
"src не должен знать старые list-sidecar файлы *.base.txt / *.user.txt",
)
def test_settings_normalize_does_not_migrate_old_winws1_folder_schema(self) -> None:
source = (SRC_ROOT / "settings" / "normalize.py").read_text(encoding="utf-8")
forbidden = (
"_prepare_winws1_preset_folders",
'"all-tcp-udp"',
'"game-filter"',
'"circular"',
)
for pattern in forbidden:
with self.subTest(pattern=pattern):
self.assertNotIn(
pattern,
source,
"settings.normalize не должен знать старую схему папок winws1",
)
def test_sidebar_expanded_save_has_single_state_source(self) -> None:
patterns = (
"sidebar_expanded_save_pending",
"sidebar_expanded_save_start_scheduled",
"_sync_sidebar_expanded_save_compat_fields",
"_theme_persist_pending",
"_theme_persist_start_scheduled",
"_appearance_save_pending",
"_appearance_save_start_scheduled",
)
offenders: list[str] = []
for path in SRC_ROOT.rglob("*.py"):
text = path.read_text(encoding="utf-8", errors="replace")
for line_number, line in enumerate(text.splitlines(), 1):
if any(pattern in line for pattern in patterns):
offenders.append(f"{path.relative_to(REPO_ROOT)}:{line_number}: {line.strip()}")
self.assertEqual(
offenders,
[],
"состояние отложенного сохранения должно жить только в LatestValueWorkerState",
)
def test_source_does_not_use_old_display_theme_names(self) -> None:
patterns = ("Светлая синяя", "Темная синяя", "Светлая", "Темная")
offenders: list[str] = []
for path in SRC_ROOT.rglob("*.py"):
text = path.read_text(encoding="utf-8", errors="replace")
for line_number, line in enumerate(text.splitlines(), 1):
if any(pattern in line for pattern in patterns):
offenders.append(f"{path.relative_to(REPO_ROOT)}:{line_number}: {line.strip()}")
self.assertEqual(
offenders,
[],
"внутренняя логика темы должна использовать только dark/light/system",
)
if __name__ == "__main__":
unittest.main()