ReMe/tests/unit/auto_resource_test_support.py
WQS 5c17874f73
feat(auto_resource): interpret image resources into daily notes (#500)
* feat: add auto_image step for image resource caption notes

* feat: wire image resources into the resource watch loop

* refactor: split auto resource processors behind router

* refactor: align resource processor module names

* refactor: preserve auto resource compatibility

* refactor: clarify auto resource routing structure

* fix: address auto resource review concerns

* test: scope auto resource fixtures

* docs: align auto resource processor wording

* test: cover image resize failures

* fix: harden image resource lifecycle

* fix: preserve resource image detail and linked daily ownership

* style(file-graph): stabilize multiline docstring formatting
2026-09-07 12:00:38 +08:00

270 lines
9.7 KiB
Python

"""Shared test harness for auto-resource processor and router tests."""
import io
import json
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest_asyncio
from agentscope.model import ChatModelBase
from PIL import Image
from reme.components import R
from reme.components.agent_wrapper import BaseAgentWrapper
from reme.components.file_store import LocalFileStore
from reme.components.runtime_context import RuntimeContext
from reme.steps.evolve.auto_image_resource import AutoImageResourceStep
from reme.steps.evolve.auto_resource import AutoResourceStep
from reme.steps.evolve.base_auto_resource import BaseAutoResourceStep
from reme.steps.file_io import DailyListStep, FrontmatterUpdateStep, MoveStep, WriteStep
class FakeAgentWrapper(BaseAgentWrapper):
"""Capture text-processor calls without invoking a real model."""
def __init__(self):
super().__init__()
self.inputs = ""
async def reply(self, inputs, **_kwargs) -> dict:
"""Record and accept one text-processor request."""
self.inputs = inputs
return {"result": "ok"}
class FlakyAgentWrapper(BaseAgentWrapper):
"""Fail one text item, then succeed."""
def __init__(self):
super().__init__()
self.calls = 0
async def reply(self, _inputs, **_kwargs) -> dict:
"""Fail the first request and accept subsequent ones."""
self.calls += 1
if self.calls == 1:
raise RuntimeError("text provider unavailable")
return {"result": "recovered"}
class FakeVisionModel(ChatModelBase):
"""Capture VLM calls and return canned plain text."""
def __init__(self, text: str):
self.text = text
self.calls: list = []
async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument
"""Force callers through the plain fallback."""
raise NotImplementedError("structured path not faked")
async def __call__(self, messages, **kwargs):
"""Record a call and return the canned plain text."""
self.calls.append(messages)
return SimpleNamespace(content=[{"type": "text", "text": self.text}])
class FlakyVisionModel(ChatModelBase):
"""Fail the first plain call, then succeed."""
def __init__(self, text: str):
self.text = text
self.calls = 0
async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument
"""Force callers through the plain fallback."""
raise NotImplementedError("structured path not faked")
async def __call__(self, messages, **kwargs):
"""Fail once, then return the canned plain text."""
self.calls += 1
if self.calls == 1:
raise RuntimeError("vision backend unavailable")
return SimpleNamespace(content=[{"type": "text", "text": self.text}])
class StructuredVisionModel(ChatModelBase):
"""Serve structured output and count fallback plain calls."""
def __init__(self, content: dict | None = None, error: Exception | None = None, plain_text: str = "plain"):
self.content = content
self.error = error
self.plain_text = plain_text
self.structured_calls: list = []
self.plain_calls: list = []
async def generate_structured_output(self, messages, structured_model, **kwargs): # pylint: disable=unused-argument
"""Return or fail with the configured structured response."""
self.structured_calls.append(messages)
if self.error is not None:
raise self.error
return SimpleNamespace(content=dict(self.content or {}))
async def __call__(self, messages, **kwargs): # pylint: disable=unused-argument
"""Record and return the configured plain fallback."""
self.plain_calls.append(messages)
return SimpleNamespace(content=[{"type": "text", "text": self.plain_text}])
class FakeAudioResourceStep(BaseAutoResourceStep):
"""Minimal third modality used to verify the router extension contract."""
resource_suffixes = frozenset({".wav"})
async def _handle_upsert(
self,
file_path: str,
date_str: str,
note_stem: str,
added: bool,
source_path: Path,
) -> None:
del source_path
self.context.response.success = True
self.context.response.answer = f"Processed audio resource: {file_path}"
self.context.response.metadata.update(
{
"path": f"daily/{date_str}/{note_stem}.md",
"action": "added" if added else "modified",
"processor": "audio",
"modified": True,
},
)
class _StepJob:
"""Tiny job adapter for tests that need ``BaseStep.run_job``."""
def __init__(self, step_cls, app_context, file_store):
self.step_cls = step_cls
self.app_context = app_context
self.file_store = file_store
async def __call__(self, **kwargs):
step = self.step_cls(app_context=self.app_context, file_store=self.file_store)
result = await step(**kwargs)
return result or step.context.response
def make_app_context(workspace: Path):
"""Create the minimal application context used by resource tests."""
context = MagicMock()
context.app_config.workspace_dir = str(workspace)
context.app_config.daily_dir = "daily"
context.app_config.digest_dir = "digest"
context.app_config.resource_dir = "resource"
context.app_config.session_dir = "session"
context.app_config.timezone = None
return context
def _install_file_jobs(app_context, file_store) -> None:
app_context.jobs = {
"daily_list": _StepJob(DailyListStep, app_context, file_store),
"frontmatter_update": _StepJob(FrontmatterUpdateStep, app_context, file_store),
"move": _StepJob(MoveStep, app_context, file_store),
"write": _StepJob(WriteStep, app_context, file_store),
}
def image_bytes(image_format: str = "PNG", size=(8, 8), color=(200, 30, 30)) -> bytes:
"""Synthesize a small image in a Pillow-supported format."""
if image_format == "HEIF":
from pillow_heif import register_heif_opener
register_heif_opener()
image = Image.new("RGB", size, color)
buffer = io.BytesIO()
image.save(buffer, format=image_format)
return buffer.getvalue()
def png_bytes(width: int = 8, height: int = 8, color=(200, 30, 30)) -> bytes:
"""Compatibility shorthand for PNG-focused assertions."""
return image_bytes("PNG", (width, height), color)
def write_binary(path: Path, data: bytes) -> Path:
"""Write test bytes, creating parent directories."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
def write_note(path: Path, source_resource: str, body: str = "old caption") -> Path:
"""Write a minimal source-owned image note."""
content = (
f"---\nname: {path.stem}\ndescription: old\n"
f'source_resource: "{source_resource}"\nkind: image\n'
f"media_type: image/png\n---\n![[{source_resource[2:-2]}]]\n\n## Caption\n\n{body}\n"
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def caption_json(name: str, description: str, caption: str) -> str:
"""Build a plain-call caption payload."""
return json.dumps({"name": name, "description": description, "caption": caption})
def image_processor(app_context, file_store, model, *, routed: bool, **kwargs):
"""Build either the image processor or the public unified-router path."""
if not routed:
return AutoImageResourceStep(app_context=app_context, file_store=file_store, as_llm=model, **kwargs)
app_context.registry = R
return AutoResourceStep(
app_context=app_context,
**kwargs,
dispatch_steps=[
{"backend": "auto_image_resource_step", "file_store": file_store, "as_llm": model},
{
"backend": "auto_text_resource_step",
"file_store": file_store,
"agent_wrapper": FakeAgentWrapper(),
},
],
)
@dataclass
class AutoResourceTestEnv:
"""Started, isolated workspace shared by one test invocation."""
workspace: Path
app_context: object
file_store: LocalFileStore
def write_binary(self, relative_path: str, data: bytes) -> Path:
"""Write bytes relative to this workspace."""
return write_binary(self.workspace / relative_path, data)
def write_note(self, relative_path: str, source_resource: str, body: str = "old caption") -> Path:
"""Write a source-owned note relative to this workspace."""
return write_note(self.workspace / relative_path, source_resource, body)
def processor(self, model, *, routed: bool = False, **kwargs):
"""Build the direct processor or unified router for this workspace."""
return image_processor(self.app_context, self.file_store, model, routed=routed, **kwargs)
async def run(self, step, changes, **context_kwargs):
"""Run one processor invocation with a fresh runtime context."""
return await step(RuntimeContext(changes=changes, **context_kwargs))
@pytest_asyncio.fixture
async def auto_resource_env(tmp_path, monkeypatch):
"""Yield a started resource-test workspace and always close its file store."""
workspace = tmp_path / "workspace"
workspace.mkdir()
monkeypatch.chdir(workspace)
app_context = make_app_context(workspace)
file_store = LocalFileStore(name="test_store", embedding_store="")
await file_store.start()
_install_file_jobs(app_context, file_store)
try:
yield AutoResourceTestEnv(workspace, app_context, file_store)
finally:
await file_store.close()