first commit

This commit is contained in:
strepsiades
2026-03-09 16:31:42 +08:00
commit 4a9c444b10
486 changed files with 52479 additions and 0 deletions
@@ -0,0 +1,357 @@
from __future__ import annotations
from typing import Any, Dict, List
import pytest
from pydantic import BaseModel
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.sync_node import SyncNode
from sync_state_machine.domain.project.api_handler import ProjectApiHandler
from sync_state_machine.domain.project_detail.api_handler import ProjectDetailApiHandler
from sync_state_machine.domain.project_detail.sync_node import ProjectDetailSyncNode
from sync_state_machine.datasource.task_result import TaskStatus
class _ProjectSchema(BaseModel):
id: str
class _ProjectNode(SyncNode[_ProjectSchema]):
node_type = "project"
schema = _ProjectSchema
class _MockApiClient:
def __init__(self):
self.requests: List[Dict[str, Any]] = []
self.push_logs: Dict[str, Dict[str, Any]] = {}
self.all_info_calls: List[str] = []
async def request(self, method: str, endpoint: str, params: Dict[str, Any] | None = None, data: Dict[str, Any] | None = None, **kwargs):
if method == "GET" and endpoint == "/project/all_info":
project_id = (params or {}).get("project_id")
if project_id:
self.all_info_calls.append(str(project_id))
self.requests.append(
{
"method": method,
"endpoint": endpoint,
"params": params,
"data": data,
}
)
return {"code": 200, "data": {"biz_id": (data or {}).get("biz_id")}}
async def get_push_logs(self, task_ids: List[str]) -> Dict[str, Dict[str, Any]]:
return {task_id: self.push_logs.get(task_id, {}) for task_id in task_ids}
def _build_project_node(*, node_id: str, project_id: str, all_info: Dict[str, Any]) -> _ProjectNode:
return _ProjectNode(
node_id=node_id,
data_id=project_id,
data={"id": project_id},
context={"all_info": all_info},
)
def _build_project_detail_node(*, node_id: str, data_id: str, project_id: str, key: str, capacity: float = 12.3) -> ProjectDetailSyncNode:
payload = {
"id": data_id,
"project_id": project_id,
"key": key,
"value": [{"date": "2026-01-01", "capacity": capacity}],
}
return ProjectDetailSyncNode(node_id=node_id, data_id=data_id, data=payload)
def _patch_project_all_info_lookup(
monkeypatch: pytest.MonkeyPatch,
*,
collection: DataCollection,
call_recorder: List[str] | None = None,
) -> None:
async def _fake_get_all_info(cls, _client, project_id: str):
if call_recorder is not None:
call_recorder.append(project_id)
node = collection.get_by_data_id("project", project_id)
assert node is not None
return node.context.get("all_info", {})
monkeypatch.setattr(ProjectApiHandler, "get_all_info", classmethod(_fake_get_all_info))
@pytest.mark.asyncio
async def test_create_all_uses_project_update_endpoint_and_returns_in_progress() -> None:
collection = DataCollection("remote")
project = _build_project_node(node_id="p-node", project_id="project-1", all_info={"project_detail": []})
await collection.add(project)
handler = ProjectDetailApiHandler()
handler.set_collection(collection)
handler.api_client = _MockApiClient()
node = _build_project_detail_node(
node_id="detail-node-1",
data_id="local-detail-1",
project_id="project-1",
key="plan_construction",
)
results = await handler.create_all([node])
assert len(results) == 1
assert results[0].status == TaskStatus.IN_PROGRESS
assert isinstance(results[0].task_id, str) and results[0].task_id
req = handler.api_client.requests[-1]
assert req["method"] == "PUT"
assert req["endpoint"] == "/project/plan_construction"
@pytest.mark.asyncio
async def test_poll_tasks_backfills_real_data_id_from_project_all_info(monkeypatch: pytest.MonkeyPatch) -> None:
collection = DataCollection("remote")
project = _build_project_node(
node_id="p-node",
project_id="project-1",
all_info={
"project_detail": [
{
"id": "remote-detail-1",
"project_id": "project-1",
"key": "plan_construction",
"value": [{"date": "2026-01-01", "capacity": 13.0}],
}
]
},
)
await collection.add(project)
handler = ProjectDetailApiHandler()
handler.set_collection(collection)
handler.api_client = _MockApiClient()
_patch_project_all_info_lookup(monkeypatch, collection=collection)
node = _build_project_detail_node(
node_id="detail-node-1",
data_id="local-detail-1",
project_id="project-1",
key="plan_construction",
)
create_result = (await handler.create_all([node]))[0]
assert create_result.task_id
handler.api_client.push_logs[create_result.task_id] = {"code": 200, "data": {"biz_id": "project-1"}}
polled = await handler.poll_tasks([create_result.task_id])
final = polled[create_result.task_id]
assert final.status == TaskStatus.SUCCESS
assert final.data_id == "remote-detail-1"
@pytest.mark.asyncio
async def test_poll_tasks_fails_when_all_info_missing_detail(monkeypatch: pytest.MonkeyPatch) -> None:
collection = DataCollection("remote")
project = _build_project_node(node_id="p-node", project_id="project-1", all_info={"project_detail": []})
await collection.add(project)
handler = ProjectDetailApiHandler()
handler.set_collection(collection)
handler.api_client = _MockApiClient()
_patch_project_all_info_lookup(monkeypatch, collection=collection)
node = _build_project_detail_node(
node_id="detail-node-1",
data_id="local-detail-1",
project_id="project-1",
key="plan_construction",
)
create_result = (await handler.create_all([node]))[0]
assert create_result.task_id
handler.api_client.push_logs[create_result.task_id] = {"code": 200, "data": {"biz_id": "project-1"}}
polled = await handler.poll_tasks([create_result.task_id])
final = polled[create_result.task_id]
assert final.status == TaskStatus.FAILED
assert "project_detail not found in all_info" in (final.error or "")
@pytest.mark.asyncio
async def test_poll_tasks_fails_when_project_detail_has_duplicate_same_key(monkeypatch: pytest.MonkeyPatch) -> None:
collection = DataCollection("remote")
project = _build_project_node(
node_id="p-node",
project_id="project-1",
all_info={
"project_detail": [
{
"id": "remote-detail-1",
"project_id": "project-1",
"key": "plan_construction",
"value": [{"date": "2026-01-01", "capacity": 13.0}],
},
{
"id": "remote-detail-2",
"project_id": "project-1",
"key": "plan_construction",
"value": [{"date": "2026-01-01", "capacity": 14.0}],
},
]
},
)
await collection.add(project)
handler = ProjectDetailApiHandler()
handler.set_collection(collection)
handler.api_client = _MockApiClient()
_patch_project_all_info_lookup(monkeypatch, collection=collection)
node = _build_project_detail_node(
node_id="detail-node-1",
data_id="local-detail-1",
project_id="project-1",
key="plan_construction",
)
create_result = (await handler.create_all([node]))[0]
assert create_result.task_id
handler.api_client.push_logs[create_result.task_id] = {"code": 200, "data": {"biz_id": "project-1"}}
polled = await handler.poll_tasks([create_result.task_id])
final = polled[create_result.task_id]
assert final.status == TaskStatus.FAILED
assert "duplicated" in (final.error or "")
@pytest.mark.asyncio
async def test_create_all_marks_duplicate_project_key_in_same_batch_as_error() -> None:
collection = DataCollection("remote")
project = _build_project_node(node_id="p-node", project_id="project-1", all_info={"project_detail": []})
await collection.add(project)
handler = ProjectDetailApiHandler()
handler.set_collection(collection)
handler.api_client = _MockApiClient()
node1 = _build_project_detail_node(
node_id="detail-node-1",
data_id="local-detail-1",
project_id="project-1",
key="plan_construction",
capacity=10.0,
)
node2 = _build_project_detail_node(
node_id="detail-node-2",
data_id="local-detail-2",
project_id="project-1",
key="plan_construction",
capacity=20.0,
)
results = await handler.create_all([node1, node2])
assert results[0].status == TaskStatus.IN_PROGRESS
assert results[1].status == TaskStatus.FAILED
assert "Duplicate project_detail_data" in (results[1].error or "")
@pytest.mark.asyncio
async def test_create_node_reuses_existing_node_by_project_key_and_rebinds_data_id() -> None:
collection = DataCollection("remote")
handler = ProjectDetailApiHandler()
handler.set_collection(collection)
handler.api_client = _MockApiClient()
existing = _build_project_detail_node(
node_id="detail-node-old",
data_id="virtual:project_detail_data:project-1:plan_construction",
project_id="project-1",
key="plan_construction",
)
await collection.add(existing)
created = handler._create_node(
{
"id": "remote-detail-99",
"project_id": "project-1",
"key": "plan_construction",
"value": [{"date": "2026-01-01", "capacity": 21.0}],
}
)
assert created.node_id == "detail-node-old"
assert created.data_id == "remote-detail-99"
@pytest.mark.asyncio
async def test_poll_tasks_queries_all_info_once_per_project_for_multiple_keys(monkeypatch: pytest.MonkeyPatch) -> None:
collection = DataCollection("remote")
project = _build_project_node(
node_id="p-node",
project_id="project-1",
all_info={"project_detail": []},
)
await collection.add(project)
handler = ProjectDetailApiHandler()
handler.set_collection(collection)
client = _MockApiClient()
handler.api_client = client
node1 = _build_project_detail_node(
node_id="detail-node-1",
data_id="local-detail-1",
project_id="project-1",
key="plan_construction",
)
node2 = _build_project_detail_node(
node_id="detail-node-2",
data_id="local-detail-2",
project_id="project-1",
key="actual_construction",
)
create_results = await handler.create_all([node1, node2])
task_ids = [r.task_id for r in create_results if r.task_id]
assert len(task_ids) == 2
client.push_logs[task_ids[0]] = {"code": 200, "data": {"biz_id": "project-1"}}
client.push_logs[task_ids[1]] = {"code": 200, "data": {"biz_id": "project-1"}}
project.context["all_info"] = {
"project_detail": [
{
"id": "remote-detail-1",
"project_id": "project-1",
"key": "plan_construction",
"value": [{"date": "2026-01-01", "capacity": 11.0}],
},
{
"id": "remote-detail-2",
"project_id": "project-1",
"key": "actual_construction",
"value": [{"date": "2026-01-01", "capacity": 22.0}],
},
]
}
_patch_project_all_info_lookup(
monkeypatch,
collection=collection,
call_recorder=client.all_info_calls,
)
polled = await handler.poll_tasks(task_ids)
assert polled[task_ids[0]].status == TaskStatus.SUCCESS
assert polled[task_ids[0]].data_id == "remote-detail-1"
assert polled[task_ids[1]].status == TaskStatus.SUCCESS
assert polled[task_ids[1]].data_id == "remote-detail-2"
assert client.all_info_calls == ["project-1"]
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import asyncio
import time
import pytest
from sync_state_machine.datasource.api.client import ApiClient
class _FakeResponse:
status_code = 200
def raise_for_status(self) -> None:
return None
def json(self) -> dict:
return {"code": 200, "data": []}
class _FakeAsyncClient:
def __init__(self) -> None:
self.call_times: list[float] = []
async def request(self, **kwargs):
self.call_times.append(time.monotonic())
return _FakeResponse()
@pytest.mark.asyncio
async def test_api_client_global_rate_limit_applies_to_concurrent_requests() -> None:
client = ApiClient(
base_url="http://example.com",
uid="uid",
secret="secret",
rate_limit_max_requests=2,
rate_limit_window_seconds=0.2,
)
fake_client = _FakeAsyncClient()
client._client = fake_client
await asyncio.gather(
client.request("GET", "/ping"),
client.request("GET", "/ping"),
client.request("GET", "/ping"),
)
assert len(fake_client.call_times) == 3
third_delay = fake_client.call_times[2] - fake_client.call_times[0]
assert third_delay >= 0.15
@@ -0,0 +1,148 @@
from __future__ import annotations
from typing import Any, cast
import pytest
from pydantic import BaseModel
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.sync_node import SyncNode
from sync_state_machine.common.types import BindingStatus, SyncStatus
from sync_state_machine.common.types import SyncAction
from sync_state_machine.datasource.api.datasource import ApiDataSource
from sync_state_machine.datasource.datasource import BaseDataSource
from sync_state_machine.datasource.task_result import TaskResult
class _ProjectSchema(BaseModel):
id: str
name: str
class _ProjectNode(SyncNode[_ProjectSchema]):
node_type = "project"
schema = _ProjectSchema
class _Handler:
node_type = "project"
class _DS(BaseDataSource):
pass
def _build_node(*, node_id: str, data_id: str, name: str) -> _ProjectNode:
return _ProjectNode(node_id=node_id, data_id=data_id, data={"id": data_id, "name": name})
def test_api_datasource_resolve_push_id_from_put_trace() -> None:
ds = ApiDataSource(base_url="http://example.com", uid="u", secret="s")
node = _build_node(node_id="n1", data_id="biz-1", name="A")
with node.allow_core_state_write("test"):
node.action = SyncAction.UPDATE
ds.client._record_trace(
method="PUT",
endpoint="/project",
url="http://example.com/project",
request_params=None,
request_payload={"push_id": "push-123", "biz_id": "biz-1", "data": {"name": "B"}},
response={"code": 200},
elapsed_ms=8,
)
push_id = ds._resolve_push_id_for_success(node, TaskResult.success(node_id=node.node_id))
assert push_id == "push-123"
@pytest.mark.asyncio
async def test_upsert_loaded_nodes_append_load_source_log() -> None:
collection = DataCollection("remote")
ds = _DS()
ds.set_collection(collection)
existing = _build_node(node_id="n1", data_id="p-1", name="old")
existing.context["_loaded_from_persistence"] = True
await collection.add(existing)
incoming = _build_node(node_id="n2", data_id="p-1", name="new")
await ds._upsert_loaded_nodes(cast(Any, _Handler()), "project", [incoming])
updated = collection.get_by_data_id("project", "p-1")
assert updated is not None
assert updated.sync_log is not None
assert "加载来源: persistence -> _DS.load 覆盖刷新" in updated.sync_log
@pytest.mark.asyncio
async def test_api_datasource_append_trace_on_failed_result() -> None:
collection = DataCollection("remote")
ds = ApiDataSource(base_url="http://example.com", uid="u", secret="s")
ds.set_collection(collection)
node = _build_node(node_id="n-fail", data_id="biz-fail", name="X")
with node.allow_core_state_write("test"):
node.binding_status = BindingStatus.NORMAL
node.action = SyncAction.UPDATE
node.status = SyncStatus.IN_PROGRESS
await collection.add(node)
ds.client._record_trace(
method="PUT",
endpoint="/contract/update",
url="http://example.com/contract/update",
request_params=None,
request_payload={"push_id": "push-fail", "biz_id": "biz-fail", "data": {"name": "Y"}},
response={"code": 500, "message": "bad supplier"},
elapsed_ms=15,
)
await ds._handle_task_result(
cast(Any, _Handler()),
TaskResult.failed(node_id=node.node_id, error="Update failed: bad supplier"),
{},
)
updated = collection.get(node.node_id)
assert updated is not None
assert updated.sync_log is not None
assert "API链路[submit-failed] PUT /contract/update" in updated.sync_log
assert "response={\"code\":500,\"message\":\"bad supplier\"}" in updated.sync_log
assert updated.sync_log.index("API链路[submit-failed] PUT /contract/update") < updated.sync_log.index("执行状态写回")
@pytest.mark.asyncio
async def test_api_datasource_failed_update_does_not_use_unrelated_op_trace() -> None:
collection = DataCollection("remote")
ds = ApiDataSource(base_url="http://example.com", uid="u", secret="s")
ds.set_collection(collection)
node = _build_node(node_id="n-miss", data_id="biz-target", name="X")
with node.allow_core_state_write("test"):
node.binding_status = BindingStatus.NORMAL
node.action = SyncAction.UPDATE
node.status = SyncStatus.IN_PROGRESS
await collection.add(node)
ds.client._record_trace(
method="PUT",
endpoint="/material/plan",
url="http://example.com/material/plan",
request_params=None,
request_payload={"push_id": "push-other", "biz_id": "biz-other", "data": {"k": 1}},
response={"code": 200, "message": "OK"},
elapsed_ms=20,
)
await ds._handle_task_result(
cast(Any, _Handler()),
TaskResult.failed(node_id=node.node_id, error="Validation failed"),
{},
)
updated = collection.get(node.node_id)
assert updated is not None
assert updated.sync_log is not None
assert "API链路[submit-failed] PUT /material/plan" not in updated.sync_log
assert "未找到匹配 biz_id 的请求记录" in updated.sync_log
@@ -0,0 +1,29 @@
from __future__ import annotations
from scripts.build_filtered_datasource_for_projects import (
_extract_contract_supplier_ids,
_supplier_identity_set,
)
def test_extract_contract_supplier_ids_prefers_supplier_id_then_company_id() -> None:
contracts = [
{"supplier_id": "sup-1", "company_id": "co-1"},
{"company_id": "co-2"},
{"supplier_id": " ", "company_id": "co-3"},
{"supplier_id": "sup-4"},
{},
]
assert _extract_contract_supplier_ids(contracts) == {"sup-1", "co-2", "co-3", "sup-4"}
def test_supplier_identity_set_includes_id_and__id() -> None:
suppliers = [
{"_id": "sup-1"},
{"id": "sup-2"},
{"_id": "sup-3", "id": "sup-3-alias"},
{},
]
assert _supplier_identity_set(suppliers) == {"sup-1", "sup-2", "sup-3", "sup-3-alias"}
@@ -0,0 +1,36 @@
from __future__ import annotations
import pytest
from sync_state_machine.common.collection import DataCollection
from tests.fixtures.mock_domain import build_project_node, build_contract_node, register_test_domain
@pytest.mark.asyncio
async def test_collection_filter_by_project_ids() -> None:
register_test_domain()
collection = DataCollection("local")
await collection.add(build_project_node("p1_node", "p1", name="P1", code="P1"))
await collection.add(build_project_node("p2_node", "p2", name="P2", code="P2"))
await collection.add(build_contract_node("c1", "c1", name="C1", code="C1", project_ref="p1"))
await collection.add(build_contract_node("c2", "c2", name="C2", code="C2", project_ref="p2"))
c1 = collection.get("c1")
c2 = collection.get("c2")
assert c1 is not None and c1.get_data() is not None
assert c2 is not None and c2.get_data() is not None
c1_data = c1.get_data() or {}
c2_data = c2.get_data() or {}
c1_data["project_id"] = "p1"
c2_data["project_id"] = "p2"
c1.set_data(c1_data, validate=False)
c2.set_data(c2_data, validate=False)
deleted = await collection.filter_by_project_ids(["p1"])
assert deleted == 1
assert collection.get("p1_node") is not None
assert collection.get("c1") is not None
assert collection.get("p2_node") is not None
assert collection.get("c2") is None
@@ -0,0 +1,90 @@
from __future__ import annotations
import pytest
from sync_state_machine.domain.construction.api_handler import ConstructionTaskApiHandler
from sync_state_machine.domain.construction.sync_node import ConstructionTaskSyncNode
@pytest.mark.asyncio
async def test_construction_plan_update_uses_data_validation_and_reports_origin_status(monkeypatch: pytest.MonkeyPatch):
handler = ConstructionTaskApiHandler()
called = {"plan": 0, "base": 0}
async def _fake_update_base(client, data, push_id: str, biz_id: str):
called["base"] += 1
async def _fake_update_plan(client, data: dict, push_id: str, biz_id: str):
called["plan"] += 1
assert data.get("plan_start_date") == "2025-11-28"
assert data.get("plan_complete_date") == "2029-03-08"
assert data.get("contract_quantity") == 196645.0
monkeypatch.setattr(
"sync_state_machine.domain.construction.api_handler.api_update_construction_task",
_fake_update_base,
)
monkeypatch.setattr(
"sync_state_machine.domain.construction.api_handler.api_update_construction_task_plan",
_fake_update_plan,
)
handler.api_client = object()
node = ConstructionTaskSyncNode(
node_id="n1",
data_id="e9be27e9-e4aa-4eaf-8255-1fe451737227",
data={
"id": "e9be27e9-e4aa-4eaf-8255-1fe451737227",
"project_id": "f3e02e84-e81c-4aa6-88d4-f5aa108c8227",
"contract_id": "663dfc72-d63c-42da-8853-4abcddd405f6",
"show_name": "尾水隧洞开挖(方)",
"construction_section": "TJ",
"status": 1,
"task_type": "wssdkw",
"plan_start_date": "2025-11-28",
"plan_complete_date": "2029-03-08",
"actual_complete_date": None,
"complete_quantity": 29930.0,
"complete_rate": 15.22,
"contract_quantity": 196645.0,
"plan_node": [
{"date": "2025-11-28", "quantity": 0.0, "is_audit": True},
{"date": "2029-03-08", "quantity": 196645.0, "is_audit": True},
],
"statistic_date": "2026-01-29",
"is_crucial": False,
"delay_days": 0,
},
)
node.set_origin_data(
{
"id": "e9be27e9-e4aa-4eaf-8255-1fe451737227",
"project_id": "f3e02e84-e81c-4aa6-88d4-f5aa108c8227",
"contract_id": "663dfc72-d63c-42da-8853-4abcddd405f6",
"show_name": "尾水隧洞开挖(方)",
"construction_section": "TJ",
"status": 1,
"task_type": "wssdkw",
"plan_start_date": None,
"plan_complete_date": None,
"actual_complete_date": "2026-01-29",
"complete_quantity": 29930.0,
"complete_rate": 0.0,
"contract_quantity": None,
"plan_node": [],
"statistic_date": "2026-01-29",
"is_crucial": False,
"delay_days": None,
}
)
results = await handler.update_all([node])
assert len(results) == 1
assert results[0].status.value == "success"
assert called["plan"] == 1
assert "UPDATE_SCHEMA: ConstructionPlanUpdate:" in (node.sync_log or "")
assert "origin_invalid=" in (node.sync_log or "")
@@ -0,0 +1,68 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
from sync_state_machine.engine import StateMachineConfig, StateMachineRuntime
from sync_state_machine.engine.events import e20_create_prepare, e40_sync_execute
from sync_state_machine.engine.state_apply import apply_state_to_node
from tests.mocks.mock_datasource import MockSyncNode
CFG_PATH = Path(__file__).resolve().parents[2] / "sync_state_machine" / "config" / "node_state_machine.yaml"
def _new_node(node_id: str, *, data_id: str = "seed-id") -> MockSyncNode:
return MockSyncNode(
node_id=node_id,
data_id=data_id,
data={"id": node_id, "name": "mock"},
action=SyncAction.NONE,
status=SyncStatus.PENDING,
binding_status=BindingStatus.UNCHECKED,
)
def _set_core_state(runtime: StateMachineRuntime, node: MockSyncNode, state_id: str, *, data_id: str) -> None:
apply_state_to_node(
runtime,
node,
state_id=state_id,
reason="test setup",
fields={"binding_status", "action", "status"},
)
apply_state_to_node(
runtime,
node,
state_id=state_id,
reason="test setup",
fields={"data_id"},
data_id=data_id,
)
def test_create_success_requires_non_empty_result_data_id() -> None:
runtime = StateMachineRuntime(StateMachineConfig.load(CFG_PATH))
node = _new_node("create-missing-result-id", data_id="")
_set_core_state(runtime, node, "S06", data_id="")
with pytest.raises(RuntimeError, match="requires non-empty result_data_id"):
e40_sync_execute(
runtime,
node=node,
handler_result="SUCCESS",
action="CREATE",
poll_timeout=False,
result_data_id=None,
)
def test_e20_requires_s13_data_id_present() -> None:
runtime = StateMachineRuntime(StateMachineConfig.load(CFG_PATH))
node = _new_node("s13-no-data-id", data_id="")
_set_core_state(runtime, node, "S13", data_id="")
with pytest.raises(RuntimeError, match="cannot resolve current state"):
e20_create_prepare(runtime, node=node, spawn_success=True)
@@ -0,0 +1,108 @@
from __future__ import annotations
import pytest
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.datasource.datasource import BaseDataSource
from sync_state_machine.datasource.handler import ValidationResult
from sync_state_machine.datasource.task_result import TaskResult
class _FailHandler:
@property
def node_type(self) -> str:
return "fail"
@property
def node_class(self):
return None
@property
def schema(self):
return None
def set_collection(self, collection) -> None:
self._collection = collection
async def load(self):
raise RuntimeError("boom")
async def create_all(self, nodes):
return []
async def update_all(self, nodes):
return []
async def delete_all(self, nodes):
return []
async def poll_tasks(self, task_ids):
return {}
async def validate(self, data):
return ValidationResult(is_valid=True)
def extract_id(self, data):
return ""
def _create_node(self, data):
return None
class _OkHandler:
@property
def node_type(self) -> str:
return "ok"
@property
def node_class(self):
return None
@property
def schema(self):
return None
def set_collection(self, collection) -> None:
self._collection = collection
async def load(self):
return []
async def create_all(self, nodes):
return [TaskResult.success(node_id=str(i), data_id=str(i)) for i, _ in enumerate(nodes)]
async def update_all(self, nodes):
return [TaskResult.success(node_id=str(i), data_id=str(i)) for i, _ in enumerate(nodes)]
async def delete_all(self, nodes):
return [TaskResult.success(node_id=str(i), data_id=str(i)) for i, _ in enumerate(nodes)]
async def poll_tasks(self, task_ids):
return {}
async def validate(self, data):
return ValidationResult(is_valid=True)
def extract_id(self, data):
return ""
def _create_node(self, data):
return None
class _DS(BaseDataSource):
pass
@pytest.mark.asyncio
async def test_load_all_continue_when_one_handler_fails(capsys) -> None:
ds = _DS()
ds.register_handler(_FailHandler())
ds.register_handler(_OkHandler())
ds.set_collection(DataCollection("local"))
await ds.load_all(order=["fail", "ok"])
out = capsys.readouterr().out
assert "[fail] 加载失败" in out
assert "Loaded nodes for ok: 0" in out
@@ -0,0 +1,103 @@
from __future__ import annotations
from pydantic import ValidationError
import pytest
from sync_state_machine.config import (
PipelineRunConfig,
JsonlDataSourceConfig,
ApiDataSourceConfig,
PersistConfig,
LoggingConfig,
)
from sync_state_machine.pipeline.factory import _resolve_datasource_target_project_ids
def _build_config(
*,
top_level: list[str],
local_ids: list[str] | None = None,
remote_ids: list[str] | None = None,
) -> PipelineRunConfig:
return PipelineRunConfig(
node_types=["project"],
target_project_ids=top_level,
local_datasource=JsonlDataSourceConfig(
type="jsonl",
jsonl_dir="./filtered_datasource/datasource/filtered",
target_project_ids=local_ids or [],
),
remote_datasource=JsonlDataSourceConfig(
type="jsonl",
jsonl_dir="./remote_datasource/datasource",
target_project_ids=remote_ids or [],
),
strategies=[],
persist=PersistConfig(db_path="./test_results/sync_state_machine/test.db"),
logging=LoggingConfig(initialize=False),
)
def test_datasource_target_ids_take_precedence_over_top_level() -> None:
cfg = _build_config(top_level=["p_top"], local_ids=["p_local"], remote_ids=["p_remote"])
local_ids, remote_ids = _resolve_datasource_target_project_ids(cfg)
assert local_ids == ["p_local"]
assert remote_ids == ["p_remote"]
def test_datasource_target_ids_fallback_to_top_level_when_missing() -> None:
cfg = _build_config(top_level=["p_top"], local_ids=[], remote_ids=[])
local_ids, remote_ids = _resolve_datasource_target_project_ids(cfg)
assert local_ids == ["p_top"]
assert remote_ids == ["p_top"]
def test_datasource_target_ids_support_mixed_override_and_fallback() -> None:
cfg = _build_config(top_level=["p_top"], local_ids=["p_local"], remote_ids=[])
local_ids, remote_ids = _resolve_datasource_target_project_ids(cfg)
assert local_ids == ["p_local"]
assert remote_ids == ["p_top"]
def test_api_datasource_requires_target_project_ids() -> None:
with pytest.raises(ValidationError) as exc_info:
ApiDataSourceConfig(
type="api",
api_base_url="https://example.com",
target_project_ids=[], # Empty list should fail
)
assert "List should have at least 1 item" in str(exc_info.value)
with pytest.raises(ValidationError) as exc_info:
ApiDataSourceConfig(
type="api",
api_base_url="https://example.com",
# Missing target_project_ids should fail
)
assert "Field required" in str(exc_info.value) or "missing" in str(exc_info.value).lower()
def test_api_datasource_rate_limit_config_fields() -> None:
cfg = ApiDataSourceConfig(
type="api",
api_base_url="https://example.com",
target_project_ids=["p1"],
)
assert cfg.api_rate_limit_max_requests == 10
assert cfg.api_rate_limit_window_seconds == 10.0
overridden = ApiDataSourceConfig(
type="api",
api_base_url="https://example.com",
api_rate_limit_max_requests=5,
api_rate_limit_window_seconds=2.5,
target_project_ids=["p1"],
)
assert overridden.api_rate_limit_max_requests == 5
assert overridden.api_rate_limit_window_seconds == 2.5
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
from sync_state_machine.engine import StateMachineConfig, StateMachineRuntime
from sync_state_machine.engine.events import e01_bootstrap
from sync_state_machine.sync_system.strategy_ops.reset_ops import _is_create_zombie
from tests.mocks.mock_datasource import MockSyncNode
CFG_PATH = Path(__file__).resolve().parents[2] / "sync_state_machine" / "config" / "node_state_machine.yaml"
def _new_node(*, node_id: str, action: SyncAction, status: SyncStatus, data_id: str) -> MockSyncNode:
return MockSyncNode(
node_id=node_id,
data_id=data_id,
data={"id": data_id or "", "name": "mock"},
action=action,
status=status,
binding_status=BindingStatus.WARNING,
error="seed-error",
)
_CASES = [
(action, status, data_id)
for action in SyncAction
for status in SyncStatus
for data_id in ("", "id-1")
]
@pytest.mark.parametrize("action,status,data_id", _CASES)
def test_is_create_zombie_matrix(action: SyncAction, status: SyncStatus, data_id: str) -> None:
node = _new_node(node_id=f"z-{action.value}-{status.value}-{data_id or 'empty'}", action=action, status=status, data_id=data_id)
expected = action == SyncAction.CREATE and (status == SyncStatus.FAILED or not bool(data_id))
assert _is_create_zombie(node) is expected
@pytest.mark.parametrize("action,status,data_id", _CASES)
def test_e01_bootstrap_accepts_all_combinations(action: SyncAction, status: SyncStatus, data_id: str) -> None:
runtime = StateMachineRuntime(StateMachineConfig.load(CFG_PATH))
node = _new_node(node_id=f"e01-{action.value}-{status.value}-{data_id or 'empty'}", action=action, status=status, data_id=data_id)
original_action = node.action
original_status = node.status
original_binding = node.binding_status
original_error = node.error
is_zombie = _is_create_zombie(node)
decision = e01_bootstrap(runtime, node=node, is_create_zombie=is_zombie)
if is_zombie:
assert decision.to_state == "S15"
assert node.action == original_action
assert node.status == original_status
assert node.binding_status == original_binding
assert node.error == original_error
else:
assert decision.to_state == "S00"
assert node.binding_status == BindingStatus.UNCHECKED
assert node.action == SyncAction.NONE
assert node.status == SyncStatus.PENDING
assert node.error is None
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from sync_state_machine.engine.semantics import (
CoreBindingResult,
classify_core_pair,
)
def test_core_pair_and_event_mapping():
local, peer = classify_core_pair(
has_binding_record=True,
local_data={"id": "l1"},
peer_data={"id": "r1"},
)
assert (local, peer) == (CoreBindingResult.NORMAL, CoreBindingResult.NORMAL)
local2, peer2 = classify_core_pair(
has_binding_record=True,
local_data={"id": "l1"},
peer_data=None,
)
assert (local2, peer2) == (CoreBindingResult.WARNING, CoreBindingResult.ABNORMAL)
@@ -0,0 +1,63 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
from sync_state_machine.engine import StateMachineConfig, StateMachineRuntime
from sync_state_machine.engine.events import e10_bind_core
from sync_state_machine.engine.invariants import InvariantViolation
from tests.mocks.mock_datasource import MockSyncNode
CFG_PATH = Path(__file__).resolve().parents[2] / "sync_state_machine" / "config" / "node_state_machine.yaml"
def _new_node(node_id: str) -> MockSyncNode:
return MockSyncNode(
node_id=node_id,
data_id="seed-id",
data={"id": node_id, "name": "mock"},
action=SyncAction.NONE,
status=SyncStatus.PENDING,
binding_status=BindingStatus.UNCHECKED,
)
def test_event_path_calls_check_invariants(monkeypatch: pytest.MonkeyPatch) -> None:
runtime = StateMachineRuntime(StateMachineConfig.load(CFG_PATH))
called = {"value": False}
def _fake_check_invariants(snapshot):
called["value"] = True
return []
monkeypatch.setattr(runtime, "check_invariants", _fake_check_invariants)
node = _new_node("inv-call")
decision = e10_bind_core(
runtime,
node=node,
has_binding_record=False,
)
assert decision.to_state == "S02"
assert called["value"] is True
def test_event_path_raises_on_invariant_violation(monkeypatch: pytest.MonkeyPatch) -> None:
runtime = StateMachineRuntime(StateMachineConfig.load(CFG_PATH))
def _fake_violation(snapshot):
return [InvariantViolation(invariant_id="INV-X", message="boom")]
monkeypatch.setattr(runtime, "check_invariants", _fake_violation)
node = _new_node("inv-fail")
with pytest.raises(RuntimeError, match="Invariant violation"):
e10_bind_core(
runtime,
node=node,
has_binding_record=False,
)
@@ -0,0 +1,30 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.pipeline.factory import _prepare_remote_jsonl_dir
def test_prepare_remote_jsonl_dir_autocreates_inside_project_root(tmp_path: Path) -> None:
project_root = tmp_path / "project"
project_root.mkdir(parents=True, exist_ok=True)
remote_dir = project_root / "remote_datasource" / "datasource"
assert not remote_dir.exists()
prepared = _prepare_remote_jsonl_dir(remote_dir, project_root=project_root)
assert prepared == remote_dir
assert remote_dir.exists()
assert remote_dir.is_dir()
def test_prepare_remote_jsonl_dir_rejects_outside_project_root(tmp_path: Path) -> None:
project_root = tmp_path / "project"
outside_dir = tmp_path / "outside" / "remote_datasource" / "datasource"
project_root.mkdir(parents=True, exist_ok=True)
with pytest.raises(FileNotFoundError, match="auto-create is only allowed"):
_prepare_remote_jsonl_dir(outside_dir, project_root=project_root)
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from pathlib import Path
from sync_state_machine.engine import StateMachineConfig, StateMachineRuntime
CFG_PATH = Path(__file__).resolve().parents[2] / "sync_state_machine" / "config" / "node_state_machine.yaml"
def test_precondition_failed_requires_update_action() -> None:
cfg = StateMachineConfig.load(CFG_PATH)
runtime = StateMachineRuntime(cfg)
bad_snapshot = {
"binding_status": "NORMAL",
"action": "NONE",
"status": "PRECONDITION_FAILED",
"data_id_exist": False,
}
violations = runtime.check_invariants(bad_snapshot)
assert any(v.invariant_id == "INV-3_PRECOND_IMPLIES_UPDATE" for v in violations)
good_snapshot = {
"binding_status": "NORMAL",
"action": "UPDATE",
"status": "PRECONDITION_FAILED",
"data_id_exist": True,
}
violations2 = runtime.check_invariants(good_snapshot)
assert not any(v.invariant_id == "INV-3_PRECOND_IMPLIES_UPDATE" for v in violations2)
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
import importlib
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
PKG_ROOT = REPO_ROOT / "sync_state_machine"
def _module_name(py_file: Path) -> str:
rel = py_file.relative_to(REPO_ROOT).with_suffix("")
return ".".join(rel.parts)
def test_sync_state_machine_module_import_surface() -> None:
py_files = sorted(PKG_ROOT.rglob("*.py"))
assert py_files, "No python modules found under sync_state_machine"
excluded_names = {"__main__"}
excluded_parts = {"tests", "__pycache__"}
imported = 0
for py_file in py_files:
if any(part in excluded_parts for part in py_file.parts):
continue
if py_file.stem in excluded_names:
continue
module_name = _module_name(py_file)
importlib.import_module(module_name)
imported += 1
assert imported >= 20
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.common.binding import BindingManager
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.persistence import PersistenceBackend
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
from sync_state_machine.datasource.datasource import BaseDataSource
from sync_state_machine.engine.state_apply import apply_state_to_node
from sync_state_machine.pipeline.full_sync_pipeline import FullSyncPipeline
from tests.mocks.mock_datasource import MockSyncNode
class _DS(BaseDataSource):
pass
def _set_state(pipeline: FullSyncPipeline, node: MockSyncNode, state_id: str, *, data_id: str) -> None:
apply_state_to_node(
pipeline.sm_runtime,
node,
state_id=state_id,
reason="test setup",
fields={"binding_status", "action", "status"},
)
apply_state_to_node(
pipeline.sm_runtime,
node,
state_id=state_id,
reason="test setup",
fields={"data_id"},
data_id=data_id,
)
async def _new_pipeline(tmp_path: Path) -> tuple[FullSyncPipeline, DataCollection, DataCollection, BindingManager, PersistenceBackend]:
db_path = tmp_path / "state.db"
persistence = PersistenceBackend(str(db_path))
await persistence.initialize()
local_collection = DataCollection("local", persistence=persistence, auto_persist=False)
remote_collection = DataCollection("remote", persistence=persistence, auto_persist=False)
binding_manager = BindingManager(persistence, auto_persist=False)
pipeline = FullSyncPipeline(
local_datasource=_DS(),
remote_datasource=_DS(),
strategies=[],
persistence=persistence,
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
node_types=["mock"],
load_order=["mock"],
sync_order=["mock"],
enable_persistence=False,
)
return pipeline, local_collection, remote_collection, binding_manager, persistence
@pytest.mark.asyncio
async def test_peer_creating_source_moves_to_s01_when_target_create_success(tmp_path: Path) -> None:
pipeline, local, remote, bm, persistence = await _new_pipeline(tmp_path)
src = MockSyncNode(
node_id="src_local",
data_id="SRC-ID",
data={"id": "SRC-ID", "name": "src"},
action=SyncAction.NONE,
status=SyncStatus.PENDING,
binding_status=BindingStatus.UNCHECKED,
)
target = MockSyncNode(
node_id="tgt_remote",
data_id="TGT-ID",
data={"id": "TGT-ID", "name": "tgt"},
action=SyncAction.CREATE,
status=SyncStatus.SUCCESS,
binding_status=BindingStatus.NORMAL,
)
await local.add(src)
await remote.add(target)
_set_state(pipeline, src, "S13", data_id="SRC-ID")
_set_state(pipeline, target, "S11C", data_id="TGT-ID")
await bm.bind("mock", "src_local", "tgt_remote")
await pipeline._finalize_peer_creating_sources("mock")
assert src.binding_status == BindingStatus.NORMAL
assert src.action == SyncAction.NONE
assert src.status == SyncStatus.PENDING
await pipeline.close()
await persistence.close()
@pytest.mark.asyncio
async def test_peer_creating_source_stays_s13_when_target_create_failed(tmp_path: Path) -> None:
pipeline, local, remote, bm, persistence = await _new_pipeline(tmp_path)
src = MockSyncNode(
node_id="src_local_fail",
data_id="SRC-ID-FAIL",
data={"id": "SRC-ID-FAIL", "name": "src-fail"},
action=SyncAction.NONE,
status=SyncStatus.PENDING,
binding_status=BindingStatus.UNCHECKED,
)
target = MockSyncNode(
node_id="tgt_remote_fail",
data_id="",
data={"id": "", "name": "tgt-fail"},
action=SyncAction.CREATE,
status=SyncStatus.FAILED,
binding_status=BindingStatus.NORMAL,
)
await local.add(src)
await remote.add(target)
_set_state(pipeline, src, "S13", data_id="SRC-ID-FAIL")
_set_state(pipeline, target, "S12C", data_id="")
target.error = "remote create failed"
await bm.bind("mock", "src_local_fail", "tgt_remote_fail")
await pipeline._finalize_peer_creating_sources("mock")
assert src.binding_status == BindingStatus.PEER_CREATING
assert src.action == SyncAction.NONE
assert src.status == SyncStatus.PENDING
assert "peer_create_failed" in (src.error or "")
await pipeline.close()
await persistence.close()
@@ -0,0 +1,87 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.common.binding import BindingManager
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.persistence import PersistenceBackend
from sync_state_machine.datasource.datasource import BaseDataSource
from sync_state_machine.pipeline.full_sync_pipeline import FullSyncPipeline
from sync_state_machine.sync_system.strategy import BaseSyncStrategy
class _DS(BaseDataSource):
pass
class _FailStrategy(BaseSyncStrategy):
schema = object # type: ignore
async def bind(self, **kwargs):
raise RuntimeError("bind failed")
async def create(self):
return []
async def update(self):
return []
class _OkStrategy(BaseSyncStrategy):
schema = object # type: ignore
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.bound = False
async def bind(self, **kwargs):
self.bound = True
async def create(self):
return []
async def update(self):
return []
@pytest.mark.asyncio
async def test_phase_sync_by_node_type_continues_after_strategy_error(tmp_path: Path) -> None:
db_path = tmp_path / "state.db"
persistence = PersistenceBackend(str(db_path))
await persistence.initialize()
local_collection = DataCollection("local", persistence=persistence, auto_persist=False)
remote_collection = DataCollection("remote", persistence=persistence, auto_persist=False)
binding_manager = BindingManager(persistence, auto_persist=False)
local_ds = _DS()
remote_ds = _DS()
fail_strategy = _FailStrategy("fail", local_collection, remote_collection, binding_manager)
fail_strategy.skip_sync = True
ok_strategy = _OkStrategy("ok", local_collection, remote_collection, binding_manager)
ok_strategy.skip_sync = True
pipeline = FullSyncPipeline(
local_datasource=local_ds,
remote_datasource=remote_ds,
strategies=[fail_strategy, ok_strategy],
persistence=persistence,
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
node_types=[],
load_order=[],
sync_order=["fail", "ok"],
enable_persistence=False,
)
await pipeline.phase_sync_by_node_type()
assert ok_strategy.bound is True
assert pipeline.stats["failed"] >= 1
await pipeline.close()
@@ -0,0 +1,154 @@
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from sync_state_machine.common.binding import BindingManager
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.persistence import PersistenceBackend
from sync_state_machine.common.types import BindingStatus, SyncAction, SyncStatus
from sync_state_machine.domain.construction.sync_node import ConstructionTaskSyncNode
from sync_state_machine.domain.contract.sync_node import ContractSyncNode
from sync_state_machine.sync_system.strategy_ops.post_check_ops import evaluate_consistency_for_node_type
@pytest.mark.asyncio
async def test_post_check_uses_same_id_resolver_as_create_update_for_depend_ids(tmp_path: Path) -> None:
db_path = tmp_path / "state.db"
persistence = PersistenceBackend(str(db_path))
await persistence.initialize()
local_collection = DataCollection("local", persistence=persistence, auto_persist=False)
remote_collection = DataCollection("remote", persistence=persistence, auto_persist=False)
binding_manager = BindingManager(persistence, auto_persist=False)
local_contract_data_id = "local-contract-1"
remote_contract_data_id = "remote-contract-1"
local_contract = ContractSyncNode(
node_id="local-contract-node",
data_id=local_contract_data_id,
data={
"id": local_contract_data_id,
"project_id": "",
"name": "合同A",
"short_name": "合同A",
"code": "C-001",
"contract_type": "施工",
"currency": "CNY",
"manager_name": "张三",
"manager_phone": "13800000000",
"sign_company": "示例公司",
"company_id": "company-1",
"sign_date": "2025-01-01",
"credit_code": None,
"total_price": 1.0,
"sub_name": None,
},
binding_status=BindingStatus.NORMAL,
action=SyncAction.NONE,
status=SyncStatus.SUCCESS,
)
remote_contract = ContractSyncNode(
node_id="remote-contract-node",
data_id=remote_contract_data_id,
data={
"id": remote_contract_data_id,
"project_id": "",
"name": "合同A",
"short_name": "合同A",
"code": "C-001",
"contract_type": "施工",
"currency": "CNY",
"manager_name": "张三",
"manager_phone": "13800000000",
"sign_company": "示例公司",
"company_id": "company-1",
"sign_date": "2025-01-01",
"credit_code": None,
"total_price": 1.0,
"sub_name": None,
},
binding_status=BindingStatus.NORMAL,
action=SyncAction.NONE,
status=SyncStatus.SUCCESS,
)
await local_collection.add(local_contract)
await remote_collection.add(remote_contract)
await binding_manager.bind("contract", local_contract.node_id, remote_contract.node_id)
local_task = ConstructionTaskSyncNode(
node_id="local-task-node",
data_id="local-task-data-id",
data={
"id": "local-task-data-id",
"project_id": "",
"contract_id": local_contract_data_id,
"show_name": "厂房开挖(方)",
"construction_section": "TJ",
"status": 0,
"task_type": "cfkw",
"plan_start_date": None,
"plan_complete_date": None,
"actual_complete_date": None,
"complete_quantity": 0.0,
"complete_rate": 0.0,
"contract_quantity": None,
"plan_node": [],
"statistic_date": None,
"is_crucial": False,
"delay_days": None,
},
binding_status=BindingStatus.NORMAL,
action=SyncAction.NONE,
status=SyncStatus.SUCCESS,
)
remote_task = ConstructionTaskSyncNode(
node_id="remote-task-node",
data_id="remote-task-data-id",
data={
"id": "remote-task-data-id",
"project_id": "",
"contract_id": remote_contract_data_id,
"show_name": "厂房开挖(方)",
"construction_section": "TJ",
"status": 0,
"task_type": "cfkw",
"plan_start_date": None,
"plan_complete_date": None,
"actual_complete_date": None,
"complete_quantity": 0.0,
"complete_rate": 0.0,
"contract_quantity": None,
"plan_node": [],
"statistic_date": None,
"is_crucial": False,
"delay_days": None,
},
binding_status=BindingStatus.NORMAL,
action=SyncAction.NONE,
status=SyncStatus.SUCCESS,
)
await local_collection.add(local_task)
await remote_collection.add(remote_task)
await binding_manager.bind("construction_task", local_task.node_id, remote_task.node_id)
result = await evaluate_consistency_for_node_type(
node_type="construction_task",
local_collection=local_collection,
remote_collection=remote_collection,
binding_manager=binding_manager,
logger=logging.getLogger("sync_state_machine"),
depend_fields={"contract_id": "contract"},
compare_to_remote=True,
)
assert result["ok"] is True
assert result["mismatch_count"] == 0
await persistence.close()
@@ -0,0 +1,49 @@
from __future__ import annotations
from pydantic import BaseModel
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.sync_node import SyncNode
from sync_state_machine.domain.project_detail.api_handler import ProjectDetailApiHandler
class _ProjectSchema(BaseModel):
id: str
class _ProjectNode(SyncNode[_ProjectSchema]):
node_type = "project"
schema = _ProjectSchema
def test_project_detail_load_injects_project_id_before_validation() -> None:
collection = DataCollection("remote")
project = _ProjectNode(
node_id="project-node-1",
data_id="project-1",
data={"id": "project-1"},
context={
"all_info": {
"project_detail": [
{
"id": "detail-1",
"key": "plan_construction",
"value": [{"date": "2026-01-01", "capacity": 12.5}],
}
]
}
},
)
collection._nodes[project.node_id] = project
handler = ProjectDetailApiHandler()
handler.set_collection(collection)
nodes = __import__("asyncio").run(handler.load())
assert len(nodes) == 1
detail_data = nodes[0].get_data()
assert detail_data is not None
assert detail_data["project_id"] == "project-1"
assert detail_data["id"] == "detail-1"
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import logging
from io import StringIO
from sync_state_machine.pipeline.run_logger import clear_pipeline_logger_handlers, init_pipeline_logger
def test_suppress_node_logs_filter_works() -> None:
logger = logging.getLogger("sync_state_machine")
clear_pipeline_logger_handlers(logger)
stream = StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(logging.Formatter("%(message)s"))
# init and then replace default console handler with our capture handler carrying same filter behavior
logger = init_pipeline_logger(level=logging.INFO, mirror_console=False, replace_handlers=True, suppress_node_logs=True)
clear_pipeline_logger_handlers(logger)
logger.addHandler(handler)
class _LocalFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
if "node=" in msg:
return False
if "节点 " in msg and "状态" in msg:
return False
return True
handler.addFilter(_LocalFilter())
logger.info("[company] 自动绑定阻断: node=abc, reason=exists")
logger.info("✅ Strategy bind: company")
output = stream.getvalue()
assert "node=abc" not in output
assert "Strategy bind: company" in output
clear_pipeline_logger_handlers(logger)
@@ -0,0 +1,31 @@
from __future__ import annotations
from pathlib import Path
import pytest
from sync_state_machine.config.run_presets import load_overrides_from_file
def test_load_overrides_from_yaml_file(tmp_path: Path) -> None:
pytest.importorskip("yaml")
yaml_file = tmp_path / "profile.yaml"
yaml_file.write_text(
"""
node_types:
- company
logging:
initialize: true
suppress_node_logs: true
local_datasource:
type: jsonl
jsonl_dir: ${PROJECT_ROOT}/filtered_datasource/datasource/filtered
""".strip(),
encoding="utf-8",
)
overrides = load_overrides_from_file(yaml_file, project_root=tmp_path)
assert overrides["logging"]["suppress_node_logs"] is True
assert str(tmp_path) in overrides["local_datasource"]["jsonl_dir"]
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from pathlib import Path
from sync_state_machine.engine import StateMachineConfig, StateMachineRuntime
CFG_PATH = Path(__file__).resolve().parents[2] / "sync_state_machine" / "config" / "node_state_machine.yaml"
def test_all_event_outcomes_are_dispatchable() -> None:
cfg = StateMachineConfig.load(CFG_PATH)
runtime = StateMachineRuntime(cfg)
covered_events: set[str] = set()
for event_id, transition in cfg.transitions.items():
if not transition.outcomes:
continue
for idx, outcome in enumerate(transition.outcomes):
if outcome.to is None:
continue
src = next((s for s in transition.from_states if s != "*"), "*")
decision = runtime.dispatch(current_state=src, event_id=event_id, context=dict(outcome.when))
assert decision is not None, f"{event_id}[{idx}] did not dispatch"
assert decision.to_state == outcome.to, f"{event_id}[{idx}] target mismatch"
assert decision.outcome_index == idx, f"{event_id}[{idx}] wrong branch matched"
covered_events.add(event_id)
assert covered_events == set(cfg.transitions.keys())
@@ -0,0 +1,85 @@
from __future__ import annotations
from sync_state_machine.common.types import BindingStatus, SyncStatus
from sync_state_machine.sync_system.strategy_ops.bind_ops import (
check_dependency_satisfied,
collect_dependency_errors,
)
from tests.fixtures.mock_domain import (
TestProjectNode as ProjectNode,
)
def _normal_dep_node(*, data_id: str = "dep-1") -> ProjectNode:
return ProjectNode(
node_id="dep-node",
data_id=data_id,
data={"id": data_id or "", "name": "dep", "code": "DEP"},
binding_status=BindingStatus.NORMAL,
status=SyncStatus.PENDING,
)
def test_check_dependency_satisfied_allows_empty_ref_by_default() -> None:
ok = check_dependency_satisfied(
dependency_nodes=[None],
field_values=[""],
)
assert ok is True
def test_check_dependency_satisfied_blocks_empty_ref_when_strict() -> None:
ok = check_dependency_satisfied(
dependency_nodes=[None],
field_values=[""],
allow_empty_dependency_ref=False,
)
assert ok is False
def test_check_dependency_satisfied_missing_dep_data_id_strict_mode() -> None:
node = _normal_dep_node(data_id="")
loose = check_dependency_satisfied(
dependency_nodes=[node],
field_values=["P-1"],
allow_missing_dep_data_id=True,
)
strict = check_dependency_satisfied(
dependency_nodes=[node],
field_values=["P-1"],
allow_missing_dep_data_id=False,
)
assert loose is True
assert strict is False
def test_collect_dependency_errors_covers_not_found_bad_status_and_no_data_id() -> None:
bad_status_node = ProjectNode(
node_id="dep-bad-status",
data_id="dep-2",
data={"id": "dep-2", "name": "dep2", "code": "DEP2"},
binding_status=BindingStatus.WARNING,
status=SyncStatus.PENDING,
)
no_data_id_node = ProjectNode(
node_id="dep-no-id",
data_id="",
data={"id": "", "name": "dep3", "code": "DEP3"},
binding_status=BindingStatus.NORMAL,
status=SyncStatus.SUCCESS,
)
errors = collect_dependency_errors(
dependency_nodes=[None, bad_status_node, no_data_id_node],
field_names=["project_ref", "supplier_ref", "company_ref"],
field_types=["project", "supplier", "company"],
field_values=["P-404", "S-1", "C-1"],
allow_empty_dependency_ref=False,
allow_missing_dep_data_id=False,
)
assert errors == [
"not_found|project_ref|project|P-404",
"bad_status|supplier_ref|warning",
"no_data_id|company_ref|status=SUCCESS",
]
@@ -0,0 +1,77 @@
from __future__ import annotations
import json
import pytest
from pydantic import BaseModel
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.common.sync_node import SyncNode
from sync_state_machine.datasource.jsonl.datasource import JsonlDataSource
from sync_state_machine.datasource.jsonl.handler import BaseJsonlHandler
class _ProjectSchema(BaseModel):
id: str
class _BizSchema(BaseModel):
id: str
project_id: str | None = None
class _ProjectNode(SyncNode[_ProjectSchema]):
node_type = "project"
schema = _ProjectSchema
class _BizNode(SyncNode[_BizSchema]):
node_type = "contract"
schema = _BizSchema
@pytest.mark.asyncio
async def test_jsonl_project_load_applies_target_project_filter(tmp_path):
data_file = tmp_path / "project_1.jsonl"
data_file.write_text(
"\n".join([
json.dumps({"id": "p1"}, ensure_ascii=False),
json.dumps({"id": "p2"}, ensure_ascii=False),
])
+ "\n",
encoding="utf-8",
)
datasource = JsonlDataSource(tmp_path)
datasource.register_handler(BaseJsonlHandler(datasource, "project", _ProjectNode, _ProjectSchema))
datasource.set_target_project_ids(["p1"])
datasource.set_collection(DataCollection("local"))
await datasource.load_all(order=["project"])
loaded = datasource._collection.filter(node_type="project") # type: ignore[union-attr]
assert {node.data_id for node in loaded} == {"p1"}
@pytest.mark.asyncio
async def test_jsonl_non_project_load_filters_rows_by_project_id(tmp_path):
data_file = tmp_path / "contract_1.jsonl"
data_file.write_text(
"\n".join([
json.dumps({"id": "c1", "project_id": "p1"}, ensure_ascii=False),
json.dumps({"id": "c2", "project_id": "p2"}, ensure_ascii=False),
json.dumps({"id": "c3"}, ensure_ascii=False),
])
+ "\n",
encoding="utf-8",
)
datasource = JsonlDataSource(tmp_path)
datasource.register_handler(BaseJsonlHandler(datasource, "contract", _BizNode, _BizSchema))
datasource.set_target_project_ids(["p1"])
datasource.set_collection(DataCollection("local"))
await datasource.load_all(order=["contract"])
loaded = datasource._collection.filter(node_type="contract") # type: ignore[union-attr]
assert {node.data_id for node in loaded} == {"c1", "c3"}
@@ -0,0 +1,36 @@
from __future__ import annotations
import json
import pytest
from sync_state_machine.common.collection import DataCollection
from sync_state_machine.datasource.jsonl.datasource import JsonlDataSource
from sync_state_machine.domain.units.jsonl_handler import UnitsJsonlHandler
@pytest.mark.asyncio
async def test_units_jsonl_handler_loads_units_filename(tmp_path):
data_file = tmp_path / "units_1.jsonl"
data_file.write_text(
json.dumps(
{
"id": "u-1",
"project_id": "p-1",
"serial_number": 1,
"actual_production_date": "2025-01-01",
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
datasource = JsonlDataSource(tmp_path)
datasource.register_handler(UnitsJsonlHandler(datasource))
datasource.set_collection(DataCollection("local"))
await datasource.load_all(order=["units"])
loaded = datasource._collection.filter(node_type="units") # type: ignore[union-attr]
assert {node.data_id for node in loaded} == {"u-1"}
+67
View File
@@ -0,0 +1,67 @@
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from sync_state_machine.sync_system.config import UpdateDirection
from sync_state_machine.sync_system.strategy_ops.update_ops import run_update
@pytest.mark.asyncio
async def test_run_update_with_direction_overrides():
class FakeNode:
def __init__(self, data):
self._data = data
def get_data(self):
return self._data
# Setup mock strategy
strategy = MagicMock()
strategy.node_type = "TestNode"
strategy.config.field_direction_key = "sync_direction"
strategy.config.field_direction_overrides = {
"local_only": UpdateDirection.PUSH,
"remote_only": UpdateDirection.PULL,
"ignore": UpdateDirection.BIDIRECTIONAL # Just to check filtering
}
strategy.config.update_direction = UpdateDirection.PUSH
strategy.local_collection = MagicMock()
strategy.remote_collection = MagicMock()
with patch('sync_state_machine.sync_system.strategy_ops.update_ops.add_update_action', new_callable=AsyncMock) as mock_add_action:
mock_add_action.return_value = []
await run_update(strategy)
# In the active directions, PUSH and PULL should be handled because of overrides.
# Check PUSH call
assert mock_add_action.call_count == 2
push_call = mock_add_action.call_args_list[0]
assert push_call.kwargs['source_is_local'] is True
assert push_call.kwargs['from_collection'] == strategy.local_collection
pull_call = mock_add_action.call_args_list[1]
assert pull_call.kwargs['source_is_local'] is False
assert pull_call.kwargs['from_collection'] == strategy.remote_collection
# Test the node filters
push_filter = push_call.kwargs['node_filter']
pull_filter = pull_call.kwargs['node_filter']
# Node that has 'local_only' -> PUSH
node_push = FakeNode({"sync_direction": "local_only"})
assert push_filter(node_push) is True
assert pull_filter(node_push) is False
# Node that has 'remote_only' -> PULL
node_pull = FakeNode({"sync_direction": "remote_only"})
assert push_filter(node_pull) is False
assert pull_filter(node_pull) is True
# Node with unknown key -> default direction (PUSH)
node_default = FakeNode({"sync_direction": "unknown"})
assert push_filter(node_default) is True
assert pull_filter(node_default) is False
# Node with empty data -> default direction (PUSH)
node_empty = FakeNode(None)
assert push_filter(node_empty) is True
assert pull_filter(node_empty) is False
@@ -0,0 +1,33 @@
from __future__ import annotations
from pathlib import Path
from tools.validate_config import validate_config
REPO_ROOT = Path(__file__).resolve().parents[2]
def test_validate_config_rejects_missing_required_keys(tmp_path: Path) -> None:
invalid_cfg = tmp_path / "invalid.yaml"
invalid_cfg.write_text(
"""
meta:
version: 1
context: {}
states:
S00:
action: NONE
status: PENDING
desc: missing binding_status
stages: {}
transitions: {}
invariants: {}
""".strip(),
encoding="utf-8",
)
problems, _ = validate_config(invalid_cfg)
errors = [p for p in problems if p.level == "ERROR"]
assert errors
assert any("binding_status" in p.message or "binding_status" in p.path for p in errors)