first commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user