surfaces/.planning/phases/01-matrix-qa-polish/01-04-PLAN.md

825 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
phase: 01-matrix-qa-polish
plan: 04
type: execute
wave: 3
depends_on: ["01-01", "01-02", "01-03"]
files_modified:
- tests/adapter/matrix/test_dispatcher.py
- tests/adapter/matrix/test_reactions.py
- tests/adapter/matrix/test_store.py
- tests/adapter/matrix/test_invite_space.py
- tests/adapter/matrix/test_chat_space.py
- tests/adapter/matrix/test_send_outgoing.py
- tests/adapter/matrix/test_confirm.py
autonomous: true
requirements: []
must_haves:
truths:
- "All 4 previously-broken tests are fixed and green"
- "12 new tests (MAT-01..MAT-12) are implemented and green"
- "pytest tests/ -q shows 96+ tests passing"
- "No test uses hardcoded 'C1' assumption from old DM flow"
artifacts:
- path: "tests/adapter/matrix/test_invite_space.py"
provides: "MAT-01, MAT-02, MAT-03 tests"
contains: "space=True"
- path: "tests/adapter/matrix/test_chat_space.py"
provides: "MAT-04, MAT-05, MAT-10, MAT-12 tests"
contains: "room_put_state"
- path: "tests/adapter/matrix/test_send_outgoing.py"
provides: "MAT-06, MAT-07 tests"
contains: "!yes"
- path: "tests/adapter/matrix/test_confirm.py"
provides: "MAT-09 test"
contains: "get_pending_confirm"
- path: "tests/adapter/matrix/test_dispatcher.py"
provides: "Fixed broken tests + MAT-11"
- path: "tests/adapter/matrix/test_reactions.py"
provides: "Fixed broken tests"
- path: "tests/adapter/matrix/test_store.py"
provides: "MAT-08 pending_confirm roundtrip test"
contains: "pending_confirm"
key_links:
- from: "tests/adapter/matrix/test_invite_space.py"
to: "adapter/matrix/handlers/auth.py"
via: "tests handle_invite"
pattern: "handle_invite"
- from: "tests/adapter/matrix/test_chat_space.py"
to: "adapter/matrix/handlers/chat.py"
via: "tests make_handle_new_chat"
pattern: "make_handle_new_chat"
---
<objective>
Fix all broken tests and implement 12 new test cases (MAT-01..MAT-12) covering the Space+rooms refactor.
Purpose: Achieve 96+ green tests as required by Phase 1 deliverables. Currently 97 pass; 4 will break from Plans 01-03 changes. This plan fixes those 4 and adds 12 new, targeting ~109 total.
Output: Full green test suite with comprehensive Space+rooms coverage.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/01-matrix-qa-polish/01-CONTEXT.md
@.planning/phases/01-matrix-qa-polish/01-RESEARCH.md
@.planning/phases/01-matrix-qa-polish/01-VALIDATION.md
@tests/adapter/matrix/test_dispatcher.py
@tests/adapter/matrix/test_reactions.py
@tests/adapter/matrix/test_store.py
@adapter/matrix/handlers/auth.py
@adapter/matrix/handlers/chat.py
@adapter/matrix/handlers/confirm.py
@adapter/matrix/handlers/settings.py
@adapter/matrix/bot.py
@adapter/matrix/store.py
@adapter/matrix/reactions.py
@adapter/matrix/converter.py
@core/protocol.py
<interfaces>
<!-- After Plans 01-03, these are the key function signatures to test against: -->
```python
# adapter/matrix/handlers/auth.py
async def handle_invite(client, room, event, platform, store, auth_mgr) -> None
# Creates Space (space=True), chat room, room_put_state, room_invite x2, stores user_meta+room_meta
# adapter/matrix/store.py
async def get_pending_confirm(store: StateStore, room_id: str) -> dict | None
async def set_pending_confirm(store: StateStore, room_id: str, meta: dict) -> None
async def clear_pending_confirm(store: StateStore, room_id: str) -> None
# adapter/matrix/handlers/chat.py
def make_handle_new_chat(client, store) -> Callable # closure factory
def make_handle_archive(client, store) -> Callable # closure factory
def make_handle_rename(client, store) -> Callable # closure factory
# adapter/matrix/handlers/confirm.py
def make_handle_confirm(store=None) -> Callable # closure factory
def make_handle_cancel(store=None) -> Callable # closure factory
# adapter/matrix/bot.py
async def send_outgoing(client, room_id, event, store=None) -> None
# For OutgoingUI: renders text + "!yes/!no", calls set_pending_confirm
# adapter/matrix/reactions.py
def build_skills_text(settings) -> str # No longer mentions "Реакции 1-9"
def build_confirmation_text(description) -> str # Uses "!yes/!no" not emojis
```
<!-- From core/store.py — InMemoryStore for test fixtures: -->
```python
class InMemoryStore:
async def get(key) -> Any
async def set(key, value) -> None
async def delete(key) -> None # Check if exists; if not, use set(key, None)
```
<!-- From sdk.mock — MockPlatformClient: -->
```python
class MockPlatformClient:
# Provides get_or_create_user, get_settings, etc.
```
<!-- From sdk.interface — UserSettings for test data: -->
```python
@dataclass
class UserSettings:
skills: dict
connectors: dict
soul: dict
safety: dict
plan: dict
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Fix 4 broken tests in test_dispatcher.py and test_reactions.py</name>
<files>tests/adapter/matrix/test_dispatcher.py, tests/adapter/matrix/test_reactions.py</files>
<read_first>tests/adapter/matrix/test_dispatcher.py, tests/adapter/matrix/test_reactions.py, adapter/matrix/handlers/auth.py, adapter/matrix/handlers/chat.py, adapter/matrix/bot.py, adapter/matrix/reactions.py, adapter/matrix/store.py</read_first>
<action>
**Fix 1: test_dispatcher.py::test_invite_event_creates_dm_room_and_sends_welcome**
The old test checks `client.join` and `meta["chat_id"] == "C1"` via room_meta on the DM room. After refactor, handle_invite creates a Space + chat room, so the test needs different mocks and assertions.
Replace the entire test function with:
```python
async def test_invite_event_creates_space_and_chat_room():
from adapter.matrix.store import get_user_meta, get_room_meta
runtime = build_runtime(platform=MockPlatformClient())
# Mock client with room_create, room_put_state, room_invite, room_send, join
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
join=AsyncMock(),
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
room_send=AsyncMock(),
)
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
# Verify Space created with space=True
assert client.room_create.await_count == 2
first_call = client.room_create.call_args_list[0]
assert first_call.kwargs.get("space") is True or (len(first_call.args) > 0 and first_call.kwargs.get("space") is True)
# Verify room_put_state called to add child to Space
client.room_put_state.assert_awaited_once()
put_state_call = client.room_put_state.call_args
assert put_state_call.kwargs.get("event_type") == "m.space.child" or put_state_call.args[1] == "m.space.child"
# Verify user_meta has space_id
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
assert user_meta is not None
assert user_meta.get("space_id") == "!space:example.org"
# Verify room_meta for chat room
room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
assert room_meta is not None
assert room_meta["chat_id"] == "C1"
assert room_meta["space_id"] == "!space:example.org"
# Verify auth confirmed
assert await runtime.auth_mgr.is_authenticated("@alice:example.org") is True
# Verify welcome message sent
client.room_send.assert_awaited_once()
```
Also add import at top if not present:
```python
from adapter.matrix.store import get_user_meta, get_room_meta
```
(get_room_meta is already imported)
**Fix 2: test_dispatcher.py::test_invite_event_is_idempotent_per_room**
This test now needs to check idempotency on user_meta (not room_meta). Replace with:
```python
async def test_invite_event_is_idempotent_per_user():
runtime = build_runtime(platform=MockPlatformClient())
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
client = SimpleNamespace(
join=AsyncMock(),
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
room_send=AsyncMock(),
)
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
# Second call should be a no-op (user already has space_id)
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
# room_create called only twice (once for Space, once for chat room) — not 4 times
assert client.room_create.await_count == 2
```
**Fix 3: test_dispatcher.py::test_new_chat_creates_real_matrix_room_when_client_available**
After refactor, make_handle_new_chat needs space_id in user_meta and calls room_put_state. Update:
```python
async def test_new_chat_creates_real_matrix_room_when_client_available():
from adapter.matrix.store import set_user_meta
client = SimpleNamespace(
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!r2:example")),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
)
runtime = build_runtime(platform=MockPlatformClient(), client=client)
# Pre-populate user_meta with space_id (as if invite flow already ran)
await set_user_meta(runtime.store, "u1", {"space_id": "!space:example", "next_chat_index": 1})
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await runtime.dispatcher.dispatch(start)
new = IncomingCommand(
user_id="u1",
platform="matrix",
chat_id="C1",
command="new",
args=["Research"],
)
result = await runtime.dispatcher.dispatch(new)
client.room_create.assert_awaited_once_with(name="Research", visibility="private", is_direct=False)
client.room_put_state.assert_awaited_once()
# Verify room_put_state adds child to space
put_call = client.room_put_state.call_args
assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example"
assert any(isinstance(r, OutgoingMessage) and "Research" in r.text for r in result)
```
**Fix 4: test_dispatcher.py::test_matrix_dispatcher_registers_custom_handlers**
This test checks `"Реакции 1⃣-9⃣" in r.text` on line 39. After reactions removal, this string no longer appears. Update:
Change line 39 from:
```python
assert any(isinstance(r, OutgoingMessage) and "Реакции 1⃣-9⃣" in r.text for r in result)
```
to:
```python
assert any(isinstance(r, OutgoingMessage) and "!skill on/off" in r.text for r in result)
```
**Fix 5: test_reactions.py::test_build_skills_text**
Change assertion from:
```python
assert "Реакции 1⃣-9⃣" in text
```
to:
```python
assert "!skill on/off" in text
```
**Fix 6: test_reactions.py::test_build_confirmation_text**
The old test checks for "подтвердить" which may still be in the text. Update to check for new format:
```python
def test_build_confirmation_text():
text = build_confirmation_text("Отправить письмо?")
assert "Отправить письмо?" in text
assert "!yes" in text
assert "!no" in text
```
Also make sure the `get_room_meta` import and `get_user_meta` import are present in test_dispatcher.py. Add `from adapter.matrix.store import get_user_meta, set_user_meta` if not already imported.
</action>
<verify>
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_reactions.py -x -q 2>&1 | tail -5</automated>
</verify>
<acceptance_criteria>
- `test_dispatcher.py` does NOT contain `test_invite_event_creates_dm_room_and_sends_welcome` (renamed to `test_invite_event_creates_space_and_chat_room`)
- `test_dispatcher.py` contains `test_invite_event_creates_space_and_chat_room`
- `test_dispatcher.py` contains `space=True` in assertions
- `test_dispatcher.py` contains `room_put_state` in assertions
- `test_reactions.py` contains `!skill on/off` instead of `Реакции 1`
- `test_reactions.py` contains `!yes` in confirmation text test
- `pytest tests/adapter/matrix/test_dispatcher.py tests/adapter/matrix/test_reactions.py -x -q` passes
</acceptance_criteria>
<done>All 4 previously-broken tests fixed and passing (renamed/updated for Space+rooms)</done>
</task>
<task type="auto">
<name>Task 2: Create new test files and implement MAT-01..MAT-12</name>
<files>tests/adapter/matrix/test_invite_space.py, tests/adapter/matrix/test_chat_space.py, tests/adapter/matrix/test_send_outgoing.py, tests/adapter/matrix/test_confirm.py, tests/adapter/matrix/test_store.py, tests/adapter/matrix/test_dispatcher.py</files>
<read_first>adapter/matrix/handlers/auth.py, adapter/matrix/handlers/chat.py, adapter/matrix/bot.py, adapter/matrix/handlers/confirm.py, adapter/matrix/handlers/settings.py, adapter/matrix/store.py, tests/adapter/matrix/test_store.py, tests/adapter/matrix/test_dispatcher.py</read_first>
<action>
Create 4 new test files and extend 2 existing ones. All tests use `pytest-asyncio` (async test functions are auto-detected).
**File 1: tests/adapter/matrix/test_invite_space.py (MAT-01, MAT-02, MAT-03)**
```python
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.store import get_user_meta, get_room_meta
from adapter.matrix.bot import build_runtime
from sdk.mock import MockPlatformClient
def _make_client():
"""Helper: create mock client with Space+room creation responses."""
space_resp = SimpleNamespace(room_id="!space:example.org")
chat_resp = SimpleNamespace(room_id="!chat1:example.org")
return SimpleNamespace(
join=AsyncMock(),
room_create=AsyncMock(side_effect=[space_resp, chat_resp]),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
room_send=AsyncMock(),
)
async def test_mat01_invite_creates_space_and_chat1():
"""MAT-01: handle_invite creates Space + Чат 1, saves space_id in user_meta."""
runtime = build_runtime(platform=MockPlatformClient())
client = _make_client()
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
# Space created with space=True
first_call = client.room_create.call_args_list[0]
assert first_call.kwargs.get("space") is True
# Chat room created
assert client.room_create.await_count == 2
# room_put_state links child to Space
client.room_put_state.assert_awaited_once()
ps_kwargs = client.room_put_state.call_args.kwargs
assert ps_kwargs.get("event_type") == "m.space.child"
assert ps_kwargs.get("state_key") == "!chat1:example.org"
assert ps_kwargs.get("room_id") == "!space:example.org"
# user_meta stores space_id
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
assert user_meta is not None
assert user_meta["space_id"] == "!space:example.org"
# room_meta stores chat metadata
room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
assert room_meta is not None
assert room_meta["chat_id"] == "C1"
assert room_meta["space_id"] == "!space:example.org"
async def test_mat02_invite_idempotent():
"""MAT-02: Repeated invite does not create second Space."""
runtime = build_runtime(platform=MockPlatformClient())
client = _make_client()
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
# Reset side_effect for potential second call
client.room_create.side_effect = None
client.room_create.return_value = SimpleNamespace(room_id="!should-not-exist:example.org")
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
# Still only 2 room_create calls (from first invite)
assert client.room_create.await_count == 2
async def test_mat03_no_hardcoded_c1():
"""MAT-03: handle_invite uses next_chat_id, not hardcoded 'C1'."""
import ast
import inspect
source = inspect.getsource(handle_invite)
# Check that the literal string '"C1"' or "'C1'" does not appear as a value assignment
assert '"C1"' not in source or "chat_id" not in source.split('"C1"')[0].split("\n")[-1]
# More robust: verify via actual behavior — chat_id comes from next_chat_id
runtime = build_runtime(platform=MockPlatformClient())
client = _make_client()
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(client, room, event, runtime.platform, runtime.store, runtime.auth_mgr)
room_meta = await get_room_meta(runtime.store, "!chat1:example.org")
# C1 is correct for first user, but it came from next_chat_id (not hardcode)
assert room_meta["chat_id"] == "C1"
# Verify next_chat_index was incremented (proves next_chat_id was used)
user_meta = await get_user_meta(runtime.store, "@alice:example.org")
assert user_meta["next_chat_index"] == 2 # Incremented from 1 to 2
```
**File 2: tests/adapter/matrix/test_chat_space.py (MAT-04, MAT-05, MAT-10, MAT-12)**
```python
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
from nio.responses import RoomCreateError
from adapter.matrix.handlers.chat import make_handle_new_chat, make_handle_archive
from adapter.matrix.store import set_user_meta
from core.protocol import IncomingCommand, OutgoingMessage
from core.store import InMemoryStore
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
from sdk.mock import MockPlatformClient
async def _setup():
"""Helper: create platform, store, managers, authenticate user."""
platform = MockPlatformClient()
store = InMemoryStore()
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
await auth_mgr.confirm("@alice:example.org")
return platform, store, chat_mgr, auth_mgr, settings_mgr
async def test_mat04_new_chat_calls_room_put_state_with_space_id():
"""MAT-04: !new calls room_put_state to add room to Space."""
platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
await set_user_meta(store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2})
client = SimpleNamespace(
room_create=AsyncMock(return_value=SimpleNamespace(room_id="!newroom:ex")),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
)
handler = make_handle_new_chat(client, store)
event = IncomingCommand(
user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new", args=["Test"]
)
result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
client.room_put_state.assert_awaited_once()
ps_kwargs = client.room_put_state.call_args.kwargs
assert ps_kwargs.get("room_id") == "!space:ex"
assert ps_kwargs.get("event_type") == "m.space.child"
assert ps_kwargs.get("state_key") == "!newroom:ex"
assert any(isinstance(r, OutgoingMessage) and "Test" in r.text for r in result)
async def test_mat05_new_chat_without_space_id_returns_error():
"""MAT-05: !new without space_id in user_meta returns error message."""
platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
# user_meta exists but no space_id
await set_user_meta(store, "@alice:example.org", {"next_chat_index": 1})
client = SimpleNamespace(
room_create=AsyncMock(),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
)
handler = make_handle_new_chat(client, store)
event = IncomingCommand(
user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new"
)
result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
# Should return error, not crash
assert len(result) == 1
assert isinstance(result[0], OutgoingMessage)
assert "Space" in result[0].text or "ошибка" in result[0].text.lower() or "Ошибка" in result[0].text
# room_create should NOT have been called
client.room_create.assert_not_awaited()
async def test_mat10_archive_calls_chat_mgr_archive():
"""MAT-10: !archive archives chat via chat_mgr.archive (Space removal deferred)."""
platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
handler = make_handle_archive(None, store)
event = IncomingCommand(
user_id="@alice:example.org", platform="matrix", chat_id="C1", command="archive"
)
# Create a chat first so archive has something to work with
await chat_mgr.get_or_create(
user_id="@alice:example.org", chat_id="C1", platform="matrix",
surface_ref="!room:ex", name="Test"
)
result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
assert len(result) == 1
assert "архивирован" in result[0].text
async def test_mat12_room_create_error_returns_user_message():
"""MAT-12: RoomCreateError is handled gracefully with user-facing message."""
platform, store, chat_mgr, auth_mgr, settings_mgr = await _setup()
await set_user_meta(store, "@alice:example.org", {"space_id": "!space:ex", "next_chat_index": 2})
# Simulate RoomCreateError
error_resp = RoomCreateError(message="rate limited", status_code="429")
client = SimpleNamespace(
room_create=AsyncMock(return_value=error_resp),
room_put_state=AsyncMock(),
room_invite=AsyncMock(),
)
handler = make_handle_new_chat(client, store)
event = IncomingCommand(
user_id="@alice:example.org", platform="matrix", chat_id="C1", command="new", args=["Fail"]
)
result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
assert len(result) == 1
assert isinstance(result[0], OutgoingMessage)
assert "Не удалось" in result[0].text or "не удалось" in result[0].text
# room_put_state should NOT have been called (room creation failed)
client.room_put_state.assert_not_awaited()
```
NOTE: For MAT-12, `RoomCreateError` constructor signature may differ. Check the actual nio source. It might be `RoomCreateError(message="...", status_code="...")` or just `RoomCreateError(message="...")`. If the constructor fails, create a mock:
```python
error_resp = SimpleNamespace(status_code="429") # Duck-typing: no room_id attr
```
and rely on `isinstance(resp, RoomCreateError)` check in the handler. If isinstance check is used, the SimpleNamespace won't match — so use the real class or mock it. Actually, the handler uses `isinstance(resp, RoomCreateError)` so we MUST use a real `RoomCreateError` instance or the check won't match. Try both approaches:
- First: `RoomCreateError(message="error")`
- If that fails: mock the isinstance check by making room_create return an object where `hasattr(resp, 'room_id')` is False
Read `nio/responses.py` source to find the exact constructor if `RoomCreateError(message="error")` fails during test execution.
**File 3: tests/adapter/matrix/test_send_outgoing.py (MAT-06, MAT-07)**
```python
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
from adapter.matrix.bot import send_outgoing
from adapter.matrix.store import get_pending_confirm
from core.protocol import OutgoingUI, UIButton
from core.store import InMemoryStore
async def test_mat06_outgoing_ui_renders_text_with_yes_no():
"""MAT-06: OutgoingUI renders as text + '!yes / !no' hint."""
client = SimpleNamespace(room_send=AsyncMock())
store = InMemoryStore()
event = OutgoingUI(
chat_id="C1",
text="Удалить файл?",
buttons=[UIButton(label="Подтвердить", action="confirm")],
)
await send_outgoing(client, "!room:ex", event, store=store)
client.room_send.assert_awaited_once()
call_args = client.room_send.call_args
body = call_args.args[2]["body"] if len(call_args.args) > 2 else call_args.kwargs.get("content", {}).get("body", "")
assert "Удалить файл?" in body
assert "!yes" in body
assert "!no" in body
assert "Подтвердить" in body
async def test_mat07_outgoing_ui_no_reaction_sent():
"""MAT-07: OutgoingUI does NOT send m.reaction event."""
client = SimpleNamespace(room_send=AsyncMock())
store = InMemoryStore()
event = OutgoingUI(
chat_id="C1",
text="Confirm action?",
buttons=[UIButton(label="OK", action="confirm")],
)
await send_outgoing(client, "!room:ex", event, store=store)
# Only one room_send call (the text message), no m.reaction
assert client.room_send.await_count == 1
call_args = client.room_send.call_args
msg_type = call_args.args[1] if len(call_args.args) > 1 else ""
assert msg_type == "m.room.message"
# Verify no m.reaction calls
for call in client.room_send.call_args_list:
assert call.args[1] != "m.reaction"
```
**File 4: tests/adapter/matrix/test_confirm.py (MAT-09)**
```python
from __future__ import annotations
from adapter.matrix.handlers.confirm import make_handle_confirm, make_handle_cancel
from adapter.matrix.store import set_pending_confirm, get_pending_confirm
from core.protocol import IncomingCallback, OutgoingMessage
from core.store import InMemoryStore
from sdk.mock import MockPlatformClient
from core.chat import ChatManager
from core.auth import AuthManager
from core.settings import SettingsManager
async def test_mat09_yes_reads_pending_confirm():
"""MAT-09: !yes reads pending_confirm and returns action description."""
store = InMemoryStore()
platform = MockPlatformClient()
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
# Set up pending confirmation
await set_pending_confirm(store, "C1", {
"action_id": "delete_file",
"description": "Удалить файл config.yaml",
"payload": {},
})
handler = make_handle_confirm(store)
event = IncomingCallback(
user_id="@alice:example.org",
platform="matrix",
chat_id="C1",
action="confirm",
payload={"source": "command", "command": "yes"},
)
result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
assert len(result) == 1
assert isinstance(result[0], OutgoingMessage)
assert "Удалить файл config.yaml" in result[0].text
# pending_confirm should be cleared after confirmation
pending = await get_pending_confirm(store, "C1")
assert pending is None
async def test_no_clears_pending_confirm():
"""!no clears pending_confirm and returns cancellation."""
store = InMemoryStore()
platform = MockPlatformClient()
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
await set_pending_confirm(store, "C1", {
"action_id": "delete_file",
"description": "Удалить файл",
"payload": {},
})
handler = make_handle_cancel(store)
event = IncomingCallback(
user_id="@alice:example.org",
platform="matrix",
chat_id="C1",
action="cancel",
payload={"source": "command", "command": "no"},
)
result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
assert len(result) == 1
assert "отменено" in result[0].text.lower()
pending = await get_pending_confirm(store, "C1")
assert pending is None
async def test_yes_without_pending_returns_no_pending():
"""!yes with no pending confirmation returns 'no pending' message."""
store = InMemoryStore()
platform = MockPlatformClient()
chat_mgr = ChatManager(platform, store)
auth_mgr = AuthManager(platform, store)
settings_mgr = SettingsManager(platform, store)
handler = make_handle_confirm(store)
event = IncomingCallback(
user_id="@alice:example.org",
platform="matrix",
chat_id="C1",
action="confirm",
payload={},
)
result = await handler(event, auth_mgr, platform, chat_mgr, settings_mgr)
assert len(result) == 1
assert "Нет ожидающих" in result[0].text
```
**File 5: Extend tests/adapter/matrix/test_store.py (MAT-08)**
Add at the end of the existing file:
```python
async def test_pending_confirm_roundtrip(store: InMemoryStore):
"""MAT-08: get/set/clear_pending_confirm roundtrip."""
from adapter.matrix.store import get_pending_confirm, set_pending_confirm, clear_pending_confirm
# Initially None
assert await get_pending_confirm(store, "!room:m.org") is None
# Set
meta = {"action_id": "test", "description": "Do thing"}
await set_pending_confirm(store, "!room:m.org", meta)
assert await get_pending_confirm(store, "!room:m.org") == meta
# Clear
await clear_pending_confirm(store, "!room:m.org")
assert await get_pending_confirm(store, "!room:m.org") is None
```
**File 6: Extend tests/adapter/matrix/test_dispatcher.py (MAT-11)**
Add at the end of test_dispatcher.py:
```python
async def test_mat11_settings_returns_dashboard():
"""MAT-11: !settings returns a read-only dashboard with status info."""
runtime = build_runtime(platform=MockPlatformClient())
# Authenticate user first
start = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="start")
await runtime.dispatcher.dispatch(start)
settings_cmd = IncomingCommand(user_id="u1", platform="matrix", chat_id="C1", command="settings")
result = await runtime.dispatcher.dispatch(settings_cmd)
assert len(result) >= 1
text = result[0].text
# Dashboard should contain section headers
assert "Скиллы" in text or "скиллы" in text.lower()
assert "Изменить" in text or "!skills" in text
# Should NOT be the old command list format
assert "!connectors" not in text
assert "!whoami" not in text
```
IMPORTANT: Check that `core/store.py` InMemoryStore has a `delete` method. If it does NOT, the `clear_pending_confirm` function will fail. Read `core/store.py` and if `delete` is missing, implement `clear_pending_confirm` using `store.set(key, None)` instead and update the test accordingly.
</action>
<verify>
<automated>cd /Users/a/MAI/sem2/lambda/surfaces-bot && pytest tests/adapter/matrix/ -x -q 2>&1 | tail -10</automated>
</verify>
<acceptance_criteria>
- File `tests/adapter/matrix/test_invite_space.py` exists and contains `test_mat01`, `test_mat02`, `test_mat03`
- File `tests/adapter/matrix/test_chat_space.py` exists and contains `test_mat04`, `test_mat05`, `test_mat10`, `test_mat12`
- File `tests/adapter/matrix/test_send_outgoing.py` exists and contains `test_mat06`, `test_mat07`
- File `tests/adapter/matrix/test_confirm.py` exists and contains `test_mat09`
- `tests/adapter/matrix/test_store.py` contains `test_pending_confirm_roundtrip`
- `tests/adapter/matrix/test_dispatcher.py` contains `test_mat11_settings_returns_dashboard`
- `pytest tests/adapter/matrix/ -x -q` passes with 0 failures
- `pytest tests/ -q` shows 96+ tests passing
</acceptance_criteria>
<done>All 12 new tests (MAT-01..MAT-12) implemented and green; 4 broken tests fixed; total 96+ passing</done>
</task>
</tasks>
<verification>
After both tasks:
- `pytest tests/ -q` shows 96+ tests passing, 0 failures
- `pytest tests/adapter/matrix/ -q` shows all Matrix tests passing
- New test files exist: test_invite_space.py, test_chat_space.py, test_send_outgoing.py, test_confirm.py
</verification>
<success_criteria>
- 96+ tests passing in full suite
- 4 broken tests fixed (renamed/updated for Space model)
- 12 new tests implemented covering MAT-01..MAT-12
- No test references hardcoded "C1" from old DM flow
- All test files importable and runnable
</success_criteria>
<output>
After completion, create `.planning/phases/01-matrix-qa-polish/01-04-SUMMARY.md`
</output>