152 lines
5.7 KiB
Python
152 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from gyxx_flow.catalog import WorkflowCatalog
|
|
from gyxx_flow.modules import (
|
|
EXPECTED_MODULE_IDS,
|
|
BusinessModule,
|
|
ModuleRegistry,
|
|
create_default_registry,
|
|
)
|
|
from gyxx_flow.modules.content_marketing import ContentMarketingModule
|
|
from gyxx_flow.modules.product_commerce import ProductCommerceModule
|
|
from gyxx_flow.modules.shop_intelligence import ShopIntelligenceModule
|
|
from gyxx_flow.modules.supply_chain import SupplyChainModule
|
|
from gyxx_flow.workflow import WorkflowDefinition
|
|
|
|
|
|
def test_default_registry_contains_exactly_the_four_business_modules() -> None:
|
|
registry = create_default_registry()
|
|
|
|
assert registry.module_ids == EXPECTED_MODULE_IDS
|
|
assert isinstance(registry.get("content_marketing"), ContentMarketingModule)
|
|
assert isinstance(registry.get("product_commerce"), ProductCommerceModule)
|
|
assert isinstance(registry.get("shop_intelligence"), ShopIntelligenceModule)
|
|
assert isinstance(registry.get("supply_chain"), SupplyChainModule)
|
|
|
|
|
|
def test_catalog_composition_registers_all_workflows() -> None:
|
|
catalog = WorkflowCatalog.load(Path(__file__).parents[1] / "config")
|
|
|
|
registry = create_default_registry(catalog=catalog)
|
|
|
|
assert len(registry.workflow_ids) == 23
|
|
assert set(registry.workflow_ids) == {
|
|
"shop.metrics.weekly",
|
|
"shop.competitor.weekly",
|
|
"shop.jd_self_operated.daily",
|
|
"shop.douyin_price_appeal",
|
|
"supply.purchase_confirmation.daily",
|
|
"supply.replenishment.weekly",
|
|
"supply.replenishment_alert.daily",
|
|
"content.metrics.daily",
|
|
"content.marketing_report.daily",
|
|
"content.relogin.weekly",
|
|
"content.creator_report.monthly",
|
|
"content.summary.monthly",
|
|
"content.cooperations.daily",
|
|
"content.comments.weekly",
|
|
"content.summary.weekly",
|
|
"product.persona.daily",
|
|
"product.daily",
|
|
"product.alert.daily",
|
|
"product.import.daily",
|
|
"product.style_analysis.interval",
|
|
"product.main_image.weekly",
|
|
"product.sales_sheet.daily",
|
|
"product.market_rank",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("module", "module_id", "expected_workflow_ids"),
|
|
[
|
|
(ContentMarketingModule(), "content_marketing", ()),
|
|
(ProductCommerceModule(), "product_commerce", ()),
|
|
(ShopIntelligenceModule(), "shop_intelligence", ()),
|
|
(SupplyChainModule(), "supply_chain", ()),
|
|
],
|
|
)
|
|
def test_every_module_implements_the_uniform_contract(
|
|
module: BusinessModule, module_id: str, expected_workflow_ids: tuple[str, ...]
|
|
) -> None:
|
|
assert isinstance(module, BusinessModule)
|
|
assert module.module_id == module_id
|
|
assert tuple(
|
|
definition.workflow_id for definition in module.workflow_definitions()
|
|
) == expected_workflow_ids
|
|
|
|
|
|
def test_registry_rejects_duplicate_module_ids() -> None:
|
|
with pytest.raises(ValueError, match="duplicate module id"):
|
|
ModuleRegistry([ContentMarketingModule(), ContentMarketingModule()])
|
|
|
|
|
|
def test_registry_indexes_workflows_without_exposing_mutable_state() -> None:
|
|
class ExampleModule:
|
|
module_id = "content_marketing"
|
|
|
|
def workflow_definitions(self) -> tuple[WorkflowDefinition, ...]:
|
|
return (WorkflowDefinition("content.example", ()),)
|
|
|
|
registry = ModuleRegistry([ExampleModule()])
|
|
|
|
assert registry.workflow_ids == ("content.example",)
|
|
assert registry.workflow("content.example").workflow_id == "content.example"
|
|
with pytest.raises(KeyError):
|
|
registry.workflow("content.missing")
|
|
|
|
|
|
def test_business_packages_do_not_import_each_other_or_forbidden_gyxx_layers() -> None:
|
|
source_root = Path(__file__).parents[1] / "src" / "gyxx_flow" / "modules"
|
|
business_packages = set(EXPECTED_MODULE_IDS)
|
|
allowed_shared_roots = {
|
|
"gyxx_flow.core",
|
|
"gyxx_flow.workflow",
|
|
"gyxx_flow.adapters",
|
|
"gyxx_flow.contracts",
|
|
"gyxx_flow.modules.contracts",
|
|
}
|
|
violations: list[str] = []
|
|
|
|
for package in sorted(business_packages):
|
|
allowed_roots = allowed_shared_roots | {f"gyxx_flow.modules.{package}"}
|
|
for path in (source_root / package).rglob("*.py"):
|
|
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
|
for imported in _resolved_imports(tree, package):
|
|
other_business = {
|
|
f"gyxx_flow.modules.{name}" for name in business_packages - {package}
|
|
}
|
|
if any(imported == root or imported.startswith(f"{root}.") for root in other_business):
|
|
violations.append(f"{path.name}: cross-module import {imported}")
|
|
continue
|
|
if imported.startswith("gyxx_flow.") and not any(
|
|
imported == root or imported.startswith(f"{root}.")
|
|
for root in allowed_roots
|
|
):
|
|
violations.append(f"{path.name}: forbidden layer import {imported}")
|
|
|
|
assert violations == []
|
|
|
|
|
|
def _resolved_imports(tree: ast.AST, package: str) -> tuple[str, ...]:
|
|
names: list[str] = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
names.extend(alias.name for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.level == 0 and node.module:
|
|
names.append(node.module)
|
|
continue
|
|
base = ["gyxx_flow", "modules", package]
|
|
parent = base[: len(base) - node.level + 1]
|
|
if node.module:
|
|
names.append(".".join([*parent, node.module]))
|
|
else:
|
|
names.extend(".".join([*parent, alias.name]) for alias in node.names)
|
|
return tuple(names)
|