mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-17 04:41:01 +08:00
466 lines
17 KiB
Python
466 lines
17 KiB
Python
"""视觉候选 snapshot 的版本、排序、深拷贝和公开摘要契约测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from threading import Event, Lock
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from backend.tasks import TaskRecord
|
|
from backend.persistence.engine import DatabaseError
|
|
from backend.persistence.models import ScopeContext
|
|
from backend.persistence.repositories.tasks import TaskRepository
|
|
from backend.visual_snapshot import (
|
|
VisualMatchSnapshotError,
|
|
build_visual_match_snapshot,
|
|
validate_visual_match_snapshot,
|
|
visual_match_snapshot_manifest,
|
|
visual_match_snapshot_summary,
|
|
)
|
|
from backend.services.tasks import PostgresTaskService
|
|
|
|
|
|
def _snapshot() -> dict[str, object]:
|
|
"""构造包含相同分数候选的最小合法 snapshot。"""
|
|
return build_visual_match_snapshot(
|
|
query_meme_id="query",
|
|
image_sha256="a" * 64,
|
|
model="dinov2_vitb14",
|
|
dimensions=768,
|
|
preprocess_version="dinov2-v1-gif-first-frame",
|
|
candidates=[
|
|
{
|
|
"meme_id": "meme-b",
|
|
"image_sha256": "b" * 64,
|
|
"size_bytes": 12,
|
|
"score": 0.8,
|
|
"relative_path": "candidate-02.png",
|
|
"context": {"title": "B"},
|
|
},
|
|
{
|
|
"meme_id": "meme-a",
|
|
"image_sha256": "c" * 64,
|
|
"size_bytes": 10,
|
|
"score": 0.8,
|
|
"relative_path": "candidate-01.png",
|
|
"context": {"title": "A"},
|
|
},
|
|
],
|
|
matched_at=datetime(2026, 8, 31, tzinfo=timezone.utc),
|
|
)
|
|
|
|
|
|
def test_snapshot_has_stable_order_hash_and_deep_copied_context() -> None:
|
|
"""候选按分数和 ID 稳定排序,context 修改不影响已生成 snapshot。"""
|
|
snapshot = _snapshot()
|
|
candidates = snapshot["candidates"]
|
|
assert isinstance(candidates, list)
|
|
assert [item["meme_id"] for item in candidates] == ["meme-a", "meme-b"]
|
|
digest = snapshot["snapshot_sha256"]
|
|
assert isinstance(digest, str) and len(digest) == 64
|
|
|
|
validated = validate_visual_match_snapshot(snapshot, expected_sha256=digest)
|
|
assert validated == snapshot
|
|
snapshot["candidates"][0]["context"]["title"] = "changed" # type: ignore[index]
|
|
assert validated["candidates"][0]["context"]["title"] == "A" # type: ignore[index]
|
|
|
|
|
|
def test_snapshot_summary_excludes_candidate_facts() -> None:
|
|
"""公开摘要只包含版本、hash、时间和数量,不泄露候选语境或路径。"""
|
|
summary = visual_match_snapshot_summary(_snapshot())
|
|
assert set(summary) == {"protocol_version", "snapshot_sha256", "matched_at", "candidate_count"}
|
|
assert summary["candidate_count"] == 2
|
|
|
|
record = TaskRecord(
|
|
task_id="task",
|
|
task_type="meme_context_generation",
|
|
visual_snapshot_sha256=summary["snapshot_sha256"],
|
|
visual_snapshot_protocol_version=summary["protocol_version"],
|
|
visual_snapshot_matched_at=summary["matched_at"],
|
|
visual_snapshot_candidate_count=summary["candidate_count"],
|
|
result={"visual_match_snapshot": {**summary, "context": {"secret": "no"}}},
|
|
)
|
|
public = record.as_dict()
|
|
assert public["visual_match_snapshot"] == summary
|
|
assert "context" not in public["visual_match_snapshot"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutator", "code"),
|
|
[
|
|
(lambda value: value["candidates"].append(dict(value["candidates"][0])), "visual_match_snapshot_invalid"),
|
|
(lambda value: value["candidates"][0].update({"score": float("nan")}), "visual_match_snapshot_invalid"),
|
|
(lambda value: value["candidates"][0].update({"relative_path": "../outside.png"}), "visual_match_snapshot_invalid"),
|
|
],
|
|
)
|
|
def test_snapshot_validation_rejects_corruption(mutator, code: str) -> None:
|
|
"""重复候选、非 finite 分数和路径跳转不能进入任务事实。"""
|
|
snapshot = _snapshot()
|
|
mutator(snapshot)
|
|
with pytest.raises(VisualMatchSnapshotError) as captured:
|
|
validate_visual_match_snapshot(snapshot)
|
|
assert captured.value.code == code
|
|
|
|
|
|
def test_snapshot_expected_hash_mismatch_is_fail_closed() -> None:
|
|
"""恢复时摘要 hash 改变必须拒绝,而不能静默重算。"""
|
|
with pytest.raises(VisualMatchSnapshotError) as captured:
|
|
validate_visual_match_snapshot(_snapshot(), expected_sha256="f" * 64)
|
|
assert captured.value.code == "visual_match_snapshot_invalid"
|
|
|
|
|
|
def test_task_repository_rejects_partial_snapshot_summary() -> None:
|
|
"""JSONB 缺失但摘要列残留时不能被当作未预计算任务。"""
|
|
task = SimpleNamespace(
|
|
id="task",
|
|
task_type="meme_context_generation",
|
|
visual_match_snapshot=None,
|
|
visual_snapshot_sha256="a" * 64,
|
|
visual_snapshot_protocol_version=2,
|
|
visual_snapshot_matched_at=None,
|
|
visual_snapshot_candidate_count=None,
|
|
)
|
|
|
|
class Session:
|
|
"""返回固定 Task 的最小 repository session。"""
|
|
|
|
def scalar(self, _statement):
|
|
"""返回测试任务。"""
|
|
return task
|
|
|
|
repository = TaskRepository(Session(), ScopeContext("local"))
|
|
with pytest.raises(DatabaseError) as captured:
|
|
repository.get_visual_snapshot("task")
|
|
assert captured.value.code == "visual_match_snapshot_invalid"
|
|
|
|
|
|
def test_snapshot_builder_drops_manifest_unsafe_context_fields() -> None:
|
|
"""snapshot 只保留研究语境,不能把 URL、storage key 或 scope 扩展带入 manifest。"""
|
|
snapshot = build_visual_match_snapshot(
|
|
query_meme_id="query",
|
|
image_sha256="a" * 64,
|
|
model="dinov2_vitb14",
|
|
dimensions=768,
|
|
preprocess_version="dinov2-v1-gif-first-frame",
|
|
candidates=[
|
|
{
|
|
"meme_id": "meme-a",
|
|
"image_sha256": "b" * 64,
|
|
"size_bytes": 1,
|
|
"score": 0.5,
|
|
"relative_path": "candidate-01.png",
|
|
"context": {
|
|
"title": "参考",
|
|
"source_urls": ["https://internal.example/source"],
|
|
"storage_key": "secret-storage-key",
|
|
"scope_id": "scope-secret",
|
|
},
|
|
}
|
|
],
|
|
matched_at=datetime(2026, 8, 31, tzinfo=timezone.utc),
|
|
)
|
|
manifest = visual_match_snapshot_manifest(snapshot)
|
|
serialized = json.dumps(manifest, ensure_ascii=False)
|
|
assert manifest["candidates"][0]["context"] == {"title": "参考"} # type: ignore[index]
|
|
assert "https://" not in serialized
|
|
assert "storage_key" not in serialized
|
|
assert "scope_id" not in serialized
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mutator",
|
|
[
|
|
lambda value: value.__setitem__("unexpected", True),
|
|
lambda value: value["query"].__setitem__("scope_id", "scope-secret"),
|
|
lambda value: value["candidates"][0].__setitem__("storage_key", "secret"),
|
|
lambda value: value["candidates"][0]["context"].__setitem__("scope_id", "scope-secret"),
|
|
],
|
|
)
|
|
def test_snapshot_validation_rejects_unknown_fields(mutator) -> None:
|
|
"""snapshot、query、候选和语境中的未知字段都不能伪装成同一事实。"""
|
|
snapshot = _snapshot()
|
|
mutator(snapshot)
|
|
with pytest.raises(VisualMatchSnapshotError) as captured:
|
|
validate_visual_match_snapshot(snapshot)
|
|
assert captured.value.code == "visual_match_snapshot_invalid"
|
|
|
|
|
|
def test_task_service_prepares_snapshot_once_and_reuses_it() -> None:
|
|
"""任务前置器首次保存 snapshot,后续 claim 不再次调用视觉匹配。"""
|
|
snapshot = _snapshot()
|
|
|
|
class Tasks:
|
|
"""提供 snapshot repository 最小 fencing 夹具。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化空的任务 snapshot。"""
|
|
self.value = None
|
|
|
|
def get_visual_snapshot(self, _task_id: str):
|
|
"""返回当前保存的 snapshot。"""
|
|
return self.value
|
|
|
|
def set_visual_snapshot_fenced(self, _task_id: str, _generation: int, _owner: str, value):
|
|
"""记录一次成功的 claim fenced snapshot 写入。"""
|
|
self.value = value
|
|
return True
|
|
|
|
def interrupt_owner(self, _owner: str) -> None:
|
|
"""测试夹具不持有真实租约,因此无需中断任务。"""
|
|
|
|
class Environment:
|
|
"""提供共享 tasks repository 的事务上下文。"""
|
|
|
|
def __init__(self, tasks: Tasks) -> None:
|
|
"""保存任务夹具。"""
|
|
self.tasks = tasks
|
|
|
|
def __enter__(self):
|
|
"""进入测试事务。"""
|
|
return self
|
|
|
|
def __exit__(self, *_args) -> bool:
|
|
"""结束测试事务。"""
|
|
return False
|
|
|
|
class Resources:
|
|
"""把所有 scope environment 指向同一个测试 repository。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化任务夹具。"""
|
|
self.tasks = Tasks()
|
|
|
|
def environment(self, _scope: str):
|
|
"""返回 scope-bound 测试环境。"""
|
|
return Environment(self.tasks)
|
|
|
|
calls = []
|
|
resources = Resources()
|
|
service = PostgresTaskService(
|
|
resources,
|
|
visual_snapshot_preparer=lambda **_kwargs: (calls.append(True) or snapshot),
|
|
)
|
|
try:
|
|
claim = SimpleNamespace(id="task", task_type="meme_context_generation", claim_generation=1)
|
|
payload = {
|
|
"meme_id": "query",
|
|
"image_sha256": "a" * 64,
|
|
"visual_model": "dinov2_vitb14",
|
|
"visual_dimensions": 768,
|
|
"preprocess_version": "dinov2-v1-gif-first-frame",
|
|
"visual_match_snapshot_protocol_version": 2,
|
|
}
|
|
service._prepare_visual_snapshot(claim, payload)
|
|
service._prepare_visual_snapshot(claim, payload)
|
|
assert len(calls) == 1
|
|
assert payload["_visual_snapshot_sha256"] == snapshot["snapshot_sha256"]
|
|
assert payload["_visual_snapshot_candidate_count"] == 2
|
|
finally:
|
|
service.shutdown()
|
|
|
|
|
|
def test_task_service_migration_backfills_attempt_config_hash() -> None:
|
|
"""旧任务先创建 attempt 后补齐 snapshot 配置时,恢复摘要必须同步 hash。"""
|
|
row = SimpleNamespace(claim_generation=1, workspace_selector=None, processing_config_hash=None)
|
|
current_task = SimpleNamespace()
|
|
|
|
class Session:
|
|
"""按调用顺序返回当前 Task 和已有 attempt 的最小数据库夹具。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化查询序号。"""
|
|
self.calls = 0
|
|
|
|
def scalar(self, _statement):
|
|
"""返回 claim 与同一 attempt 的旧摘要行。"""
|
|
self.calls += 1
|
|
return current_task if self.calls == 1 else row
|
|
|
|
def commit(self) -> None:
|
|
"""模拟提交 attempt 更新。"""
|
|
|
|
class Environment:
|
|
"""提供任务状态写入所需的事务上下文。"""
|
|
|
|
def __init__(self, session: Session) -> None:
|
|
"""保存最小 SQL session。"""
|
|
self.uow = SimpleNamespace(session=session)
|
|
self.tasks = SimpleNamespace(interrupt_owner=lambda _owner: None)
|
|
|
|
def __enter__(self):
|
|
"""进入测试事务。"""
|
|
return self
|
|
|
|
def __exit__(self, *_args) -> bool:
|
|
"""结束测试事务。"""
|
|
return False
|
|
|
|
class Resources:
|
|
"""返回固定的 scope 事务。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化 SQL session。"""
|
|
self.session = Session()
|
|
|
|
def environment(self, _scope: str):
|
|
"""返回最小事务上下文。"""
|
|
return Environment(self.session)
|
|
|
|
service = PostgresTaskService(Resources(), agent_concurrency=1)
|
|
try:
|
|
claim = SimpleNamespace(
|
|
id="legacy-task",
|
|
task_type="meme_context_generation",
|
|
claim_generation=1,
|
|
attempt_count=1,
|
|
)
|
|
config_hash = "b" * 64
|
|
payload = {
|
|
"image_sha256": "a" * 64,
|
|
"processing_config_hash": config_hash,
|
|
"_claim_task_id": "legacy-task",
|
|
"_claim_generation": 1,
|
|
"_claim_owner": service.owner,
|
|
"_claim_attempt": 1,
|
|
}
|
|
service._image_attempt_state(claim, payload, "prepared")
|
|
assert row.processing_config_hash == config_hash
|
|
finally:
|
|
service.shutdown()
|
|
|
|
|
|
def test_task_service_resume_without_snapshot_never_rematches() -> None:
|
|
"""protocol v2 恢复缺失 snapshot 时必须稳定失败且不调用视觉前置器。"""
|
|
class Tasks:
|
|
"""提供缺失 snapshot 的恢复夹具。"""
|
|
|
|
def get_visual_snapshot(self, _task_id: str):
|
|
"""模拟持久层没有 snapshot。"""
|
|
return None
|
|
|
|
def interrupt_owner(self, _owner: str) -> None:
|
|
"""测试夹具不持有真实租约,因此无需中断任务。"""
|
|
|
|
class Environment:
|
|
"""提供 scope 事务上下文。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化任务 repository。"""
|
|
self.tasks = Tasks()
|
|
|
|
def __enter__(self):
|
|
"""进入测试事务。"""
|
|
return self
|
|
|
|
def __exit__(self, *_args) -> bool:
|
|
"""结束测试事务。"""
|
|
return False
|
|
|
|
class Resources:
|
|
"""提供固定环境。"""
|
|
|
|
def environment(self, _scope: str):
|
|
"""返回测试事务。"""
|
|
return Environment()
|
|
|
|
calls: list[str] = []
|
|
service = PostgresTaskService(Resources(), visual_snapshot_preparer=lambda **_kwargs: calls.append("match"))
|
|
try:
|
|
claim = SimpleNamespace(id="task", task_type="meme_context_generation", claim_generation=1)
|
|
payload = {
|
|
"meme_id": "query",
|
|
"image_sha256": "a" * 64,
|
|
"visual_match_snapshot_protocol_version": 2,
|
|
"_resume_available": True,
|
|
}
|
|
with pytest.raises(RuntimeError, match="visual_match_snapshot_invalid"):
|
|
service._prepare_visual_snapshot(claim, payload)
|
|
assert calls == []
|
|
finally:
|
|
service.shutdown()
|
|
|
|
|
|
def _stub_agent_task_service(events: list[object]) -> tuple[PostgresTaskService, SimpleNamespace]:
|
|
"""构造只覆盖任务顺序的无数据库 service 夹具。"""
|
|
service = object.__new__(PostgresTaskService)
|
|
service.scope = SimpleNamespace(scope_id="local")
|
|
service.owner = "test-owner"
|
|
service.resume_enabled = False
|
|
service.lease_seconds = 120
|
|
service.resume_max_backoff_seconds = 60
|
|
service.resume_backoff_seconds = 0
|
|
service._worker_manager = None
|
|
service._handlers = {}
|
|
service._stopped = Event()
|
|
service._stopped.set()
|
|
service._scheduled = set()
|
|
service._lock = Lock()
|
|
service._image_attempt_state = lambda _claim, _payload, state: events.append(f"attempt:{state}")
|
|
service._fenced_update = lambda *_args, **_kwargs: True
|
|
service._with_reverse_image_audit = lambda _task_id, result, **_kwargs: result or {}
|
|
service._fenced_success = lambda *_args, **_kwargs: events.append("success") or True
|
|
service._fenced_failure = lambda *_args, **kwargs: events.append(f"failure:{kwargs['error']['error']}") or True
|
|
service._maybe_finalize = lambda *_args, **_kwargs: None
|
|
claim = SimpleNamespace(
|
|
id="task",
|
|
scope_id="local",
|
|
task_type="meme_context_generation",
|
|
submission_mode="standalone",
|
|
image_stage="agent",
|
|
claim_generation=1,
|
|
attempt_count=1,
|
|
resume_attempt_count=0,
|
|
resume_started_at=None,
|
|
payload={
|
|
"meme_id": "query",
|
|
"image_sha256": "a" * 64,
|
|
"visual_model": "dinov2_vitb14",
|
|
"visual_dimensions": 768,
|
|
"preprocess_version": "dinov2-v1-gif-first-frame",
|
|
"visual_match_snapshot_protocol_version": 2,
|
|
},
|
|
)
|
|
return service, claim
|
|
|
|
|
|
def test_task_service_materializes_before_grant_and_external_window() -> None:
|
|
"""成功 Agent 顺序必须是 snapshot、物化、grant、external_started、handler。"""
|
|
events: list[object] = []
|
|
service, claim = _stub_agent_task_service(events)
|
|
service._prepare_visual_snapshot = lambda *_args: events.append("snapshot")
|
|
service._visual_candidate_preparer = object()
|
|
service._commit_agent_grant = lambda *_args: events.append("grant")
|
|
service._handlers["meme_context_generation"] = lambda _payload, _progress: events.append("handler") or {}
|
|
|
|
service._run("task", preclaimed=claim)
|
|
|
|
assert events == [
|
|
"attempt:prepared",
|
|
"snapshot",
|
|
"attempt:prepared",
|
|
"grant",
|
|
"attempt:grant_committed",
|
|
"attempt:external_started",
|
|
"handler",
|
|
"attempt:completed",
|
|
"success",
|
|
]
|
|
|
|
|
|
def test_task_service_precompute_failure_does_not_commit_grant() -> None:
|
|
"""预计算稳定失败只能收束当前任务,不能进入 grant 或 handler。"""
|
|
events: list[object] = []
|
|
service, claim = _stub_agent_task_service(events)
|
|
service._prepare_visual_snapshot = lambda *_args: (_ for _ in ()).throw(RuntimeError("query_embedding_not_ready"))
|
|
service._commit_agent_grant = lambda *_args: events.append("grant")
|
|
service._handlers["meme_context_generation"] = lambda _payload, _progress: events.append("handler") or {}
|
|
|
|
service._run("task", preclaimed=claim)
|
|
|
|
assert "grant" not in events
|
|
assert "handler" not in events
|
|
assert "failure:query_embedding_not_ready" in events
|