"""feat/telegram-live-tracker: tests for the live Telegram task tracker. Covers (per DEV_TASK_TELEGRAM_TRACKER.md): * short_model_name: provider/claude- prefix trimming. * render_task_tracker: per-stage line format (in↓/out↑, model, cost, minutes), the "✅/⏸️ Подтверждение BRD · твоё время" line, the 💰 totals, and the finish block (⏱️ three times + 🔗/📦). * first message -> sendMessage stores message_id; transition -> editMessageText. * fallback: editMessageText fails -> a NEW message is sent and the id updated. * which alerts go out SEPARATELY (approve-gate / deploy-fail / agent-fail / error) vs which do NOT (QG-pending / agent-start / stage-transition). Isolated temp DB; no network (httpx is patched). """ import os import tempfile os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") _test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_tracker.db") os.environ["ORCH_DB_PATH"] = _test_db from unittest.mock import MagicMock, patch # noqa: E402 import pytest # noqa: E402 import src.db as db_module # noqa: E402 from src.db import init_db, get_db # noqa: E402 from src import notifications as N # noqa: E402 from src import usage as U # noqa: E402 @pytest.fixture(autouse=True) def setup_db(monkeypatch): monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) if os.path.exists(_test_db): os.unlink(_test_db) init_db() # Re-enable send_telegram (conftest stubs it to a no-op); these tests patch # httpx / the lower-level helpers explicitly per case. yield if os.path.exists(_test_db): os.unlink(_test_db) # --------------------------------------------------------------------------- # # helpers to build a task + runs in the DB # --------------------------------------------------------------------------- # def _mk_task(stage="development", title="\u0422\u0440\u0435\u043a\u0438 \u0441 \u0437\u0443\u043c\u0430 z5", wid="ET-012", brd_start=None, brd_end=None): conn = get_db() cur = conn.execute( "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, " "brd_review_started_at, brd_review_ended_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ("p1", wid, "enduro-trails", "feature/ET-012-x", stage, title, brd_start, brd_end), ) tid = cur.lastrowid conn.commit() conn.close() return tid def _mk_run(task_id, agent, started, finished, in_tok, out_tok, cache_read=0, cache_creation=0, cost=0.0, model=None, exit_code=0): conn = get_db() cur = conn.execute( "INSERT INTO agent_runs (task_id, agent, started_at, finished_at, " "exit_code, input_tokens, output_tokens, cache_read_tokens, " "cache_creation_tokens, cost_usd, model) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (task_id, agent, started, finished, exit_code, in_tok, out_tok, cache_read, cache_creation, cost, model), ) rid = cur.lastrowid conn.commit() conn.close() return rid # --------------------------------------------------------------------------- # # short_model_name # --------------------------------------------------------------------------- # def test_short_model_name(): assert U.short_model_name("tokenator/claude-opus-4-8") == "opus-4-8" assert U.short_model_name("vibecode/claude-sonnet-4.6") == "sonnet-4.6" assert U.short_model_name("claude-opus-4-8") == "opus-4-8" assert U.short_model_name("opus-4-8") == "opus-4-8" assert U.short_model_name(None) == "" assert U.short_model_name("") == "" def test_parse_usage_extracts_model_from_modelusage(): blob = ( '{"total_cost_usd":0.01,' '"usage":{"input_tokens":10,"output_tokens":5},' '"modelUsage":{"claude-opus-4-8":{"inputTokens":10,"outputTokens":5}}}' ) u = U.parse_usage_from_text(blob) assert u["model"] == "claude-opus-4-8" # --------------------------------------------------------------------------- # # render_task_tracker # --------------------------------------------------------------------------- # def test_render_in_progress_stage_lines_and_totals(): tid = _mk_task(stage="deploy", brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 10:08:00") # Analysis: 10м, 1.1M in (mostly cache) / 39.6k out, $2.38, opus-4-8 _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38, model="tokenator/claude-opus-4-8") _mk_run(tid, "architect", "2026-06-04 10:08:00", "2026-06-04 10:17:00", in_tok=500, out_tok=34400, cache_read=1_500_000, cost=2.24, model="tokenator/claude-opus-4-8") _mk_run(tid, "developer", "2026-06-04 10:17:00", "2026-06-04 10:28:00", in_tok=400, out_tok=45800, cache_read=8_400_000, cost=7.29, model="tokenator/claude-opus-4-8") _mk_run(tid, "reviewer", "2026-06-04 10:28:00", "2026-06-04 10:31:00", in_tok=300, out_tok=12900, cache_read=1_200_000, cost=1.53, model="vibecode/claude-sonnet-4.6") _mk_run(tid, "tester", "2026-06-04 10:31:00", "2026-06-04 10:36:00", in_tok=200, out_tok=19500, cache_read=1_200_000, cost=1.51, model="vibecode/claude-sonnet-4.6") # deployer started but not finished -> active "идёт" line. _mk_run(tid, "deployer", "2026-06-04 10:36:00", None, in_tok=0, out_tok=0, model=None, exit_code=None) text = N.render_task_tracker(tid) # Header in-progress assert text.startswith("\U0001f6e0\ufe0f ET-012 \u00b7 \u0422\u0440\u0435\u043a\u0438") # Per-stage format: in↓/out↑ · cost · model assert "\u2705 Анализ" in text assert "10\u043c" in text # analysis duration assert "39.6k\u2191" in text # analysis out assert "$2.38" in text assert "opus-4-8" in text assert "sonnet-4.6" in text # reviewer/tester model # BRD review line (human time, ended) assert "Подтверждение BRD" in text assert "\u0442\u0432\u043e\u0451 \u0432\u0440\u0435\u043c\u044f" in text # Active stage assert "\U0001f504 Внедрение" in text assert "\u0438\u0434\u0451\u0442" in text # Totals line present with 💰 assert "\U0001f4b0" in text # In-progress: no final ⏱️ line assert "\u0412\u0441\u0435\u0433\u043e" not in text def test_render_brd_review_waiting_shows_hourglass(): tid = _mk_task(stage="analysis", brd_start="2026-06-04 10:00:00", brd_end=None) _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38, model="tokenator/claude-opus-4-8") text = N.render_task_tracker(tid) assert "Подтверждение BRD" in text assert "\u23f3" in text # hourglass while waiting def test_render_done_has_times_and_links(): tid = _mk_task(stage="done", brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 10:08:00") # set created/updated to compute wall clock conn = get_db() conn.execute( "UPDATE tasks SET created_at='2026-06-04 09:00:00', " "updated_at='2026-06-04 09:56:00' WHERE id=?", (tid,)) conn.commit() conn.close() _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=1000, out_tok=39600, cache_read=1_100_000, cost=2.38, model="tokenator/claude-opus-4-8") _mk_run(tid, "deployer", "2026-06-04 09:50:00", "2026-06-04 09:56:00", in_tok=400, out_tok=22400, cache_read=1_600_000, cost=1.73, model="tokenator/claude-opus-4-8") with patch("src.notifications.httpx") as _hx: # No PR found -> just "📦 deployed" _resp = MagicMock(status_code=200) _resp.json.return_value = [] _hx.get.return_value = _resp text = N.render_task_tracker(tid) assert text.startswith("\U0001f389 ET-012") assert "\u0413\u041e\u0422\u041e\u0412\u041e" in text # ⏱️ with three times assert "\u23f1\ufe0f" in text # ORCH-087 (BR-G5): three explicitly-labelled metrics # "\u0410\u0433\u0435\u043d\u0442\u044b \u2026 \u00b7 \u0442\u0432\u043e\u0451 \u2026 \u00b7 \u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c \u2026" (was "\u0412\u0441\u0435\u0433\u043e \u2026 \u00b7 \u0430\u0433\u0435\u043d\u0442\u044b \u2026 \u00b7 \u0442\u0432\u043e\u0451 \u2026"). assert "\u0410\u0433\u0435\u043d\u0442\u044b" in text # \u0410\u0433\u0435\u043d\u0442\u044b assert "\u0442\u0432\u043e\u0451" in text # \u0442\u0432\u043e\u0451 # \u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c assert "\u043e\u0431\u0449\u0435\u0435 \u0441 \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u0435\u043c" in text assert "\u0412\u0441\u0435\u0433\u043e" not in text # old "\u0412\u0441\u0435\u0433\u043e" label gone # 📦 deployed line assert "\U0001f4e6" in text def test_render_escapes_html_in_title(): tid = _mk_task(stage="analysis", title="A & B") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.0) text = N.render_task_tracker(tid) assert "<b>" in text assert "&" in text def test_render_omits_model_when_unknown(): tid = _mk_task(stage="analysis") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.0, model=None) text = N.render_task_tracker(tid) # No trailing " · " — line ends at cost. line = [l for l in text.splitlines() if l.startswith("\u2705 Анализ")][0] assert line.rstrip().endswith("$0.00") # --------------------------------------------------------------------------- # # tracker send / edit / fallback # --------------------------------------------------------------------------- # def test_first_call_sends_message_and_stores_id(monkeypatch): tid = _mk_task(stage="analysis") _mk_run(tid, "analyst", "2026-06-04 09:00:00", None, in_tok=0, out_tok=0, exit_code=None) sent = {} def _fake_send(text, disable_notification=False): sent["text"] = text sent["silent"] = disable_notification return 555 monkeypatch.setattr(N, "send_telegram", _fake_send) monkeypatch.setattr(N, "edit_telegram", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not edit on first call"))) N.update_task_tracker(tid) from src.db import get_tracker_message_id assert get_tracker_message_id(tid) == 555 assert sent["silent"] is True # tracker is silent def test_second_call_edits_existing_message(monkeypatch): # ORCH-067: the default flipped to bump; this case asserts the edit-mode # contract, so pin edit mode explicitly. monkeypatch.setattr(N._get_settings(), "tracker_mode", "edit", raising=False) tid = _mk_task(stage="development") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) from src.db import set_tracker_message_id set_tracker_message_id(tid, 777) edited = {} monkeypatch.setattr(N, "edit_telegram", lambda mid, text: edited.update(mid=mid) or N.EDIT_OK) monkeypatch.setattr(N, "send_telegram", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send when edit succeeds"))) N.update_task_tracker(tid) assert edited["mid"] == 777 def test_fallback_to_new_message_when_edit_gone(monkeypatch): """edit returns 'gone' (message deleted/too old) -> send NEW + update id.""" tid = _mk_task(stage="development") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) from src.db import set_tracker_message_id, get_tracker_message_id set_tracker_message_id(tid, 100) monkeypatch.setattr(N, "edit_telegram", lambda mid, text: N.EDIT_GONE) monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: 200) N.update_task_tracker(tid) assert get_tracker_message_id(tid) == 200 # id updated to the new message def test_not_modified_does_not_send_new_message(monkeypatch): """edit returns 'not_modified' -> NO new message, id unchanged (no dupe).""" tid = _mk_task(stage="development") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) from src.db import set_tracker_message_id, get_tracker_message_id set_tracker_message_id(tid, 100) monkeypatch.setattr(N, "edit_telegram", lambda mid, text: N.EDIT_NOT_MODIFIED) monkeypatch.setattr(N, "send_telegram", lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not send on not_modified"))) N.update_task_tracker(tid) assert get_tracker_message_id(tid) == 100 # unchanged, no duplicate def test_transient_edit_failure_does_not_send_new_message(monkeypatch): """edit returns 'failed' (network/timeout/5xx) -> NO new message (no dupe).""" tid = _mk_task(stage="development") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) from src.db import set_tracker_message_id, get_tracker_message_id set_tracker_message_id(tid, 100) monkeypatch.setattr(N, "edit_telegram", lambda mid, text: N.EDIT_FAILED) monkeypatch.setattr(N, "send_telegram", lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not send on transient failure"))) N.update_task_tracker(tid) assert get_tracker_message_id(tid) == 100 # unchanged, no duplicate # --------------------------------------------------------------------------- # # edit_telegram outcome classification (httpx mocked) # --------------------------------------------------------------------------- # def _edit_resp(ok, description=None): resp = MagicMock() body = {"ok": ok} if description is not None: body["description"] = description resp.json.return_value = body return resp def _patch_tg_creds(monkeypatch): monkeypatch.setattr(N._get_settings(), "telegram_bot_token", "T", raising=False) monkeypatch.setattr(N._get_settings(), "telegram_chat_id", "C", raising=False) def test_edit_telegram_ok(monkeypatch): _patch_tg_creds(monkeypatch) with patch("src.notifications.httpx") as hx: hx.post.return_value = _edit_resp(True) assert N.edit_telegram(1, "x") == N.EDIT_OK def test_edit_telegram_not_modified_is_success(monkeypatch): # 400 "message is not modified" -> success, not gone, no duplicate _patch_tg_creds(monkeypatch) with patch("src.notifications.httpx") as hx: hx.post.return_value = _edit_resp( False, "Bad Request: message is not modified: ...") assert N.edit_telegram(1, "x") == N.EDIT_NOT_MODIFIED def test_edit_telegram_exactly_the_same_is_not_modified(monkeypatch): _patch_tg_creds(monkeypatch) with patch("src.notifications.httpx") as hx: hx.post.return_value = _edit_resp( False, "Bad Request: specified new message content and reply markup " "are exactly the same") assert N.edit_telegram(1, "x") == N.EDIT_NOT_MODIFIED def test_edit_telegram_message_not_found_is_gone(monkeypatch): _patch_tg_creds(monkeypatch) with patch("src.notifications.httpx") as hx: hx.post.return_value = _edit_resp( False, "Bad Request: message to edit not found") assert N.edit_telegram(1, "x") == N.EDIT_GONE def test_edit_telegram_cant_be_edited_is_gone(monkeypatch): _patch_tg_creds(monkeypatch) with patch("src.notifications.httpx") as hx: hx.post.return_value = _edit_resp( False, "Bad Request: message can't be edited") assert N.edit_telegram(1, "x") == N.EDIT_GONE def test_edit_telegram_unknown_400_is_failed(monkeypatch): # unknown 400 -> failed (NOT gone) -> caller won't duplicate _patch_tg_creds(monkeypatch) with patch("src.notifications.httpx") as hx: hx.post.return_value = _edit_resp( False, "Bad Request: some other unexpected error") assert N.edit_telegram(1, "x") == N.EDIT_FAILED def test_edit_telegram_timeout_is_failed(monkeypatch): _patch_tg_creds(monkeypatch) with patch("src.notifications.httpx") as hx: hx.post.side_effect = Exception("read timeout") assert N.edit_telegram(1, "x") == N.EDIT_FAILED def test_edit_telegram_5xx_is_failed(monkeypatch): # Telegram 5xx still returns ok:false w/o gone/not_modified markers _patch_tg_creds(monkeypatch) with patch("src.notifications.httpx") as hx: hx.post.return_value = _edit_resp(False, "Internal Server Error") assert N.edit_telegram(1, "x") == N.EDIT_FAILED # --------------------------------------------------------------------------- # # render: repeated stage attempt shows "попытка N" # --------------------------------------------------------------------------- # _POPYTKA = "\u043f\u043e\u043f\u044b\u0442\u043a\u0430" # popytka def test_render_active_stage_shows_attempt_on_second_run(): # Two reviewer runs while in review -> active line shows attempt 2. tid = _mk_task(stage="review") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") _mk_run(tid, "developer", "2026-06-04 09:10:00", "2026-06-04 09:20:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") # First review run finished (sent back to dev), second review run active. _mk_run(tid, "reviewer", "2026-06-04 09:20:00", "2026-06-04 09:25:00", in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6", exit_code=0) _mk_run(tid, "reviewer", "2026-06-04 09:30:00", None, in_tok=0, out_tok=0, exit_code=None) text = N.render_task_tracker(tid) active = [l for l in text.splitlines() if l.startswith("\U0001f504") and "Код ревью" in l][0] assert _POPYTKA in active assert "2" in active assert "\u0438\u0434\u0451\u0442" in active def test_render_active_stage_no_attempt_on_first_run(): # Single reviewer run -> active line has NO attempt marker. tid = _mk_task(stage="review") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") _mk_run(tid, "developer", "2026-06-04 09:10:00", "2026-06-04 09:20:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") _mk_run(tid, "reviewer", "2026-06-04 09:20:00", None, in_tok=0, out_tok=0, exit_code=None) text = N.render_task_tracker(tid) active = [l for l in text.splitlines() if l.startswith("\U0001f504") and "Код ревью" in l][0] assert _POPYTKA not in active assert "\u0438\u0434\u0451\u0442" in active def test_render_finished_lines_unaffected_by_attempt_logic(): # Completed (checkmark) lines never carry an attempt marker. tid = _mk_task(stage="review") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") # developer ran twice (retry) but is a FINISHED stage now. _mk_run(tid, "developer", "2026-06-04 09:10:00", "2026-06-04 09:15:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") _mk_run(tid, "developer", "2026-06-04 09:16:00", "2026-06-04 09:20:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") text = N.render_task_tracker(tid) for l in text.splitlines(): if l.startswith("\u2705"): assert _POPYTKA not in l # --------------------------------------------------------------------------- # # which alerts are SEPARATE vs tracker-only # --------------------------------------------------------------------------- # def test_approve_gate_sends_separate_message_and_starts_brd_clock(monkeypatch): tid = _mk_task(stage="analysis") calls = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: calls.append((text, disable_notification)) or 1) monkeypatch.setattr(N, "update_task_tracker", lambda task_id: None) N.notify_approve_requested(tid) # exactly one SEPARATE (notifying) send for the approve gate assert len(calls) == 1 assert calls[0][1] is False # notifying assert "Approved" in calls[0][0] # BRD clock started conn = get_db() row = conn.execute("SELECT brd_review_started_at FROM tasks WHERE id=?", (tid,)).fetchone() conn.close() assert row[0] is not None def test_error_sends_separate_message(monkeypatch): tid = _mk_task(stage="development") calls = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: calls.append((text, disable_notification)) or 1) N.notify_error(tid, "boom") assert len(calls) == 1 assert calls[0][1] is False # notifying assert "ERROR" in calls[0][0] def test_stage_change_does_not_send_separate_message(monkeypatch): tid = _mk_task(stage="development") sent = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: sent.append(text) or 1) # tracker refresh is allowed (edit/send silent) but must NOT use send_telegram # for a separate notification; stub update to isolate. refreshed = [] monkeypatch.setattr(N, "update_task_tracker", lambda task_id: refreshed.append(task_id)) N.notify_stage_change(tid, "development", "review") assert sent == [] # no separate message assert refreshed == [tid] # tracker refreshed instead def test_agent_started_does_not_send_separate_message(monkeypatch): tid = _mk_task(stage="analysis") sent = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: sent.append(text) or 1) refreshed = [] monkeypatch.setattr(N, "update_task_tracker", lambda task_id: refreshed.append(task_id)) N.notify_agent_started(1, "analyst", tid) assert sent == [] assert refreshed == [tid] def test_qg_failure_does_not_send_separate_message(monkeypatch): tid = _mk_task(stage="development") sent = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: sent.append(text) or 1) N.notify_qg_failure(tid, "development", "check_ci_green", "CI state: pending") assert sent == [] # QG-pending is log-only, never a separate ping # --------------------------------------------------------------------------- # # ORCH-042: mode resolution + text changes (edit-mode default) # --------------------------------------------------------------------------- # def _brd_line(text): return [ln for ln in text.splitlines() if "Подтверждение BRD" in ln][0] def test_unknown_mode_falls_back_to_edit_branch(monkeypatch): """TC-02/AC-2: garbage mode -> edit branch, no exception, no extra send.""" monkeypatch.setattr(N._get_settings(), "tracker_mode", "garbage", raising=False) tid = _mk_task(stage="development") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) from src.db import set_tracker_message_id, get_tracker_message_id set_tracker_message_id(tid, 777) edited = {} monkeypatch.setattr(N, "edit_telegram", lambda mid, text: edited.update(mid=mid) or N.EDIT_OK) monkeypatch.setattr(N, "send_telegram", lambda *a, **k: (_ for _ in ()).throw( AssertionError("garbage mode must take edit branch"))) monkeypatch.setattr(N, "delete_telegram", lambda *a, **k: (_ for _ in ()).throw( AssertionError("garbage mode must NOT bump-delete"))) N.update_task_tracker(tid) # must not raise assert edited["mid"] == 777 assert get_tracker_message_id(tid) == 777 # unchanged def test_render_brd_label_is_confirmation_not_review(): """TC-18/AC-15: 'Подтверждение BRD' present, 'Ревью БРД' absent.""" tid = _mk_task(stage="architecture", brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 10:08:00") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) text = N.render_task_tracker(tid) assert "Подтверждение BRD" in text assert "Ревью БРД" not in text def test_render_brd_passed_uses_check_not_pause(): """TC-19/AC-16: approve-gate passed (ended set) -> BRD line starts with ✅.""" tid = _mk_task(stage="architecture", brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 10:08:00") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) line = _brd_line(N.render_task_tracker(tid)) assert line.startswith("✅") # ✅ assert not line.startswith("⏸") # not ⏸️ assert "⏳" not in line # no hourglass once passed def test_render_brd_waiting_keeps_pause_and_hourglass(): """TC-20/AC-16: still waiting (ended empty) -> ⏳ indicator, not ✅.""" tid = _mk_task(stage="analysis", brd_start="2026-06-04 10:00:00", brd_end=None) _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1) line = _brd_line(N.render_task_tracker(tid)) assert "⏳" in line # ⏳ still waiting assert not line.startswith("✅") # NOT ✅ yet def test_render_stage_labels_are_russian(): """TC-21/AC-17: russian stage labels in both ✅- and 🔄-lines; no english.""" tid = _mk_task(stage="deploy") _mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") _mk_run(tid, "architect", "2026-06-04 09:10:00", "2026-06-04 09:20:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") _mk_run(tid, "developer", "2026-06-04 09:20:00", "2026-06-04 09:30:00", in_tok=10, out_tok=5, cost=0.1, model="tokenator/claude-opus-4-8") _mk_run(tid, "reviewer", "2026-06-04 09:30:00", "2026-06-04 09:35:00", in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6") _mk_run(tid, "tester", "2026-06-04 09:35:00", "2026-06-04 09:40:00", in_tok=10, out_tok=5, cost=0.1, model="vibecode/claude-sonnet-4.6") _mk_run(tid, "deployer", "2026-06-04 09:40:00", None, in_tok=0, out_tok=0, exit_code=None) text = N.render_task_tracker(tid) for ru in ("Анализ", "Архитектура", "Разработка", "Код ревью", "Тестирование", "Внедрение"): assert ru in text, f"missing russian label {ru!r}" # ORCH-067: the new '📍 ' line intentionally carries the ENGLISH # ORCH-066 Plane status name (e.g. 'Awaiting Deploy'); the russian-only rule # (BR-11) applies to the STAGE label lines, so exclude the status line here. stage_lines = "\n".join( ln for ln in text.splitlines() if not ln.startswith("\U0001f4cd") ) for en in ("Analysis", "Architecture", "Development", "Review", "Testing", "Deploy"): assert en not in stage_lines, f"english label leaked: {en!r}" def test_render_done_says_vnedreno_not_deployed(): """TC-22/AC-18: final line says '📦 Внедрено', not 'deployed'.""" tid = _mk_task(stage="done") conn = get_db() conn.execute( "UPDATE tasks SET created_at='2026-06-04 09:00:00', " "updated_at='2026-06-04 09:56:00' WHERE id=?", (tid,)) conn.commit() conn.close() _mk_run(tid, "deployer", "2026-06-04 09:50:00", "2026-06-04 09:56:00", in_tok=400, out_tok=22400, cost=1.73, model="tokenator/claude-opus-4-8") with patch("src.notifications.httpx") as _hx: _resp = MagicMock(status_code=200) _resp.json.return_value = [] # no PR _hx.get.return_value = _resp text = N.render_task_tracker(tid) assert "\U0001f4e6 Внедрено" in text # 📦 Внедрено assert "deployed" not in text