Files
MemeMeow/tests/test_metadata.py
infstellar 3df9f7878c feat(agent): add isolated executor and result protocol
Add authenticated in-container execution, shared runtime activity summaries, strict result-file validation, and OpenCode integration tests.

引入带认证的容器内执行器、共享 runtime 活动摘要、严格结果文件校验和 OpenCode 集成测试。
2026-08-15 15:23:15 +08:00

165 lines
7.1 KiB
Python

"""图片 sidecar 元数据服务的 schema、指纹和检索文本测试。"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from pydantic import ValidationError
from backend.metadata import MemeContext, MetadataError, MetadataService
def make_image(tmp_path: Path, name: str = "cat.png", content: bytes = b"image") -> tuple[Path, MetadataService]:
"""创建隔离图片根目录和测试图片。"""
root = tmp_path / "images"
root.mkdir(parents=True, exist_ok=True)
image = root / name
image.write_bytes(content)
return image, MetadataService(root)
def test_sidecar_preserves_schema_and_unknown_fields(tmp_path: Path):
"""sidecar 使用完整文件名命名,并保留未知扩展字段。"""
image, service = make_image(tmp_path)
metadata = service.create_pending(image)
payload = metadata.model_dump(mode="json")
payload["custom"] = {"owner": "test"}
payload["meme_context"]["summary"] = "一只猫正在做出反应"
service.write(image, payload)
loaded = service.load(image)
assert service.sidecar_path(image).name == "cat.png.json"
assert loaded.model_extra["custom"] == {"owner": "test"}
assert loaded.meme_context.summary == "一只猫正在做出反应"
def test_title_only_accepts_letters_numbers_and_spaces():
"""标题只接受文字、数字和空格,并将连续空白折叠为单个空格。"""
assert MemeContext(title=" 杰瑞 震惊 2 ").title == "杰瑞 震惊 2"
assert MemeContext(title="Cafe\u0301\u00a0测试").title == "Café 测试"
assert MemeContext(title=" ").title is None
for title in ("认真!", "认真!", "猫:认真", "滑稽“认真”", "测试_标题", "开心🙂", "第①次", "版本Ⅷ", "零\u200d宽"):
with pytest.raises(ValidationError, match="title_contains_disallowed_characters"):
MemeContext(title=title)
def test_title_rejects_overlong_and_multiline_values():
"""标题按规范化结果限制长度,并优先报告控制字符错误。"""
assert MemeContext(title=("x " * 40) + "x").title == ("x " * 40) + "x"
with pytest.raises(ValidationError):
MemeContext(title="x" * 121)
with pytest.raises(ValidationError, match="title_must_be_single_line"):
MemeContext(title="第一行\n第二行")
def test_same_stem_different_extensions_have_distinct_sidecars(tmp_path: Path):
"""同名不同扩展名的图片不会共享 sidecar。"""
first, service = make_image(tmp_path, "cat.png", b"png")
second = first.with_name("cat.gif")
second.write_bytes(b"gif")
service.create_pending(first)
service.create_pending(second)
assert service.sidecar_path(first) != service.sidecar_path(second)
assert service.sidecar_path(first).is_file()
assert service.sidecar_path(second).is_file()
def test_image_change_marks_metadata_repair_required(tmp_path: Path):
"""图片内容变化后,旧 sidecar 不能继续作为有效元数据。"""
image, service = make_image(tmp_path)
service.create_pending(image)
image.write_bytes(b"changed")
assert service.status(image)["status"] == "repair_required"
with pytest.raises(MetadataError) as error:
service.load(image)
assert error.value.code == "metadata_image_mismatch"
def test_human_fields_are_not_overwritten_by_research(tmp_path: Path):
"""自动研究更新不得覆盖人工确认的字段。"""
image, service = make_image(tmp_path)
service.create_pending(image)
service.update_context(image, {"title": "人工确认标题", "summary": "人工确认摘要"}, producer="human", status="ready")
service.update_context(image, {"title": "模型猜测摘要", "summary": "模型猜测摘要", "keywords": ["模型猜测摘要", "关键词"]}, producer="research", status="ready")
loaded = service.load(image)
assert loaded.meme_context.title == "人工确认标题"
assert loaded.meme_context.summary == "人工确认摘要"
assert loaded.meme_context.keywords == ["模型猜测摘要", "关键词"]
assert loaded.provenance.field_sources["title"] == "human"
def test_embedding_record_skips_pending_and_never_uses_filename(tmp_path: Path):
"""待研究图片不可索引,文件名不能成为 embedding 输入。"""
image, service = make_image(tmp_path, "filename-fallback.png")
service.create_pending(image)
record = service.embedding_record(image)
assert record["indexable"] is False
assert record["text"] == ""
assert record["skip_reason"] == "metadata_pending"
def test_semantic_document_excludes_research_helpers(tmp_path: Path):
"""embedding 文本只包含允许的语义字段。"""
image, service = make_image(tmp_path)
service.create_pending(image)
service.update_context(
image,
{
"title": "无奈摊手",
"summary": "一个人无奈地摊手",
"subjects": ["人物"],
"visible_text": ["原文"],
"references": ["已确认模板"],
"meaning": "表示无奈",
"keywords": ["无奈"],
"search_queries": ["不应进入向量"],
"uncertainties": ["不确定出处"],
"source_urls": ["https://example.com/source"],
},
producer="research",
status="ready",
)
text = service.embedding_record(image)["text"]
assert text.startswith("标题:无奈摊手\n摘要:一个人无奈地摊手")
assert "一个人无奈地摊手" in text
assert "已确认模板" in text
assert "不应进入向量" not in text
assert "不确定出处" not in text
assert "example.com" not in text
def test_repair_materializes_title_for_legacy_sidecar(tmp_path: Path):
"""修复任务为旧 sidecar 补写空标题,不丢失已有语境。"""
image, service = make_image(tmp_path)
service.create_pending(image)
sidecar = service.sidecar_path(image)
payload = json.loads(sidecar.read_text(encoding="utf-8"))
payload["meme_context"].pop("title")
payload["meme_context"]["summary"] = "旧摘要"
sidecar.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
result = service.repair()
repaired = json.loads(sidecar.read_text(encoding="utf-8"))
assert result["repaired"] == 1
assert repaired["meme_context"]["title"] is None
assert repaired["meme_context"]["summary"] == "旧摘要"
def test_repair_creates_pending_sidecar_and_reports_orphan(tmp_path: Path):
"""修复任务补齐缺失 sidecar,并报告而不删除孤立 JSON。"""
image, service = make_image(tmp_path)
nested = image.parent / "nested" / "other.gif"
nested.parent.mkdir()
nested.write_bytes(b"other")
orphan = image.with_name("orphan.png.json")
orphan.write_text(json.dumps({"orphan": True}), encoding="utf-8")
result = service.repair()
assert result["created"] + result["repaired"] == 2
assert service.sidecar_path(image).is_file()
assert service.sidecar_path(nested).is_file()
assert result["orphaned"] == 1
assert orphan.is_file()
second = service.repair()
assert second["processed"] == 2
assert second["repaired"] == 0