diff --git a/formatters.py b/formatters.py index 03f8a31..59f4831 100644 --- a/formatters.py +++ b/formatters.py @@ -1,18 +1,21 @@ """Форматирование вывода: таблицы, JSON, Markdown.""" +from __future__ import annotations + import json import os +from typing import Any from config import IGNORE_DIRS from i18n import _ -def print_json(data) -> None: +def print_json(data: Any) -> None: """Выводит данные в JSON-формате.""" print(json.dumps(data, ensure_ascii=False, indent=2, default=str)) -def print_table_simple(headers, rows, sep=" ") -> None: +def print_table_simple(headers: list[str], rows: list[list], sep: str = " ") -> None: """Выводит таблицу с авто-шириной колонок.""" if not rows: print(_("empty")) @@ -37,7 +40,7 @@ def print_table_simple(headers, rows, sep=" ") -> None: print(_("fmt.rows_count", n=len(rows))) -def summarize_val(v, max_len=120) -> str: +def summarize_val(v: Any, max_len: int = 120) -> str: """Обрезает длинное значение.""" s = str(v) if len(s) > max_len: @@ -45,7 +48,7 @@ def summarize_val(v, max_len=120) -> str: return s -def format_part_to_md(part, full=False): +def format_part_to_md(part: dict, full: bool = False) -> str: """Преобразует часть сообщения в строку Markdown. Args: @@ -149,7 +152,7 @@ def format_part_to_md(part, full=False): return "" -def collect_snapshot(project_dir): +def collect_snapshot(project_dir: str) -> dict[str, Any]: """Собирает статистику по проекту для log.md. Returns: @@ -182,7 +185,14 @@ def collect_snapshot(project_dir): return snapshot -def update_log(output_dir, title, note, dialog_filename, snapshot, now_str) -> None: +def update_log( + output_dir: str, + title: str, + note: str | None, + dialog_filename: str | None, + snapshot: dict[str, Any], + now_str: str, +) -> None: """Создаёт или дополняет log.md в директории проекта.""" log_path = os.path.join(output_dir, "log.md") heading = f"{title}: {note}" if note else title diff --git a/pyproject.toml b/pyproject.toml index dc10361..af49312 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "opencode-db" -version = "0.6.1" +version = "0.6.2" description = "OpenCode database CLI manager — browse, export, analyze, clean up sessions" readme = "README_PYPI.md" requires-python = ">=3.12" diff --git a/tests/test_db.py b/tests/test_db.py index 55e8373..51b7425 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -275,3 +275,75 @@ def test_skips_empty_part_data(self, db) -> None: ) messages = get_messages(db, "ses_001") assert len(messages) == 2 + + +class TestEdgeCases: + def test_session_null_title_and_model(self, db) -> None: + db.execute( + "INSERT INTO session (id, time_created, title, model, cost) VALUES (?, ?, ?, ?, ?)", + ("ses_null_meta", 50000, None, None, 0.0), + ) + db.commit() + info = get_session_info(db, "ses_null_meta") + assert info["title"] is None + assert info["model"] is None + + def test_session_zero_cost(self, db) -> None: + db.execute( + "INSERT INTO session (id, time_created, cost) VALUES (?, ?, ?)", + ("ses_zero_cost", 55000, 0.0), + ) + db.commit() + info = get_session_info(db, "ses_zero_cost") + assert info["cost"] == 0.0 + + def test_session_null_parent(self, db) -> None: + db.execute( + "INSERT INTO session (id, time_created, parent_id) VALUES (?, ?, ?)", + ("ses_orphan", 60000, None), + ) + db.commit() + children = get_children_sessions(db, "ses_orphan") + assert children == [] + + def test_part_without_type_has_empty_parts(self, db) -> None: + db.execute( + "INSERT INTO session (id, time_created) VALUES (?, ?)", + ("ses_skip", 1000), + ) + db.execute( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + ("msg_skip", "ses_skip", 1000, json.dumps({"role": "user"})), + ) + db.execute( + "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)", + ("part_skip", "msg_skip", "ses_skip", 1000, json.dumps({"text": "no type"})), + ) + db.commit() + messages = get_messages(db, "ses_skip") + assert len(messages) == 1 + assert len(messages[0]["parts"]) == 0 + + def test_message_null_role(self, db) -> None: + db.execute( + "INSERT INTO session (id, time_created) VALUES (?, ?)", + ("ses_edge_b", 2000), + ) + db.execute( + "INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", + ("msg_edge_b", "ses_edge_b", 2000, json.dumps({"role": None})), + ) + db.execute( + "INSERT INTO part (id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)", + ( + "part_edge_b", + "msg_edge_b", + "ses_edge_b", + 2000, + json.dumps({"type": "text", "text": "hi"}), + ), + ) + db.commit() + messages = get_messages(db, "ses_edge_b") + assert len(messages) == 1 + assert messages[0]["role"] is None or messages[0]["role"] == "?" diff --git a/tests/test_formatters.py b/tests/test_formatters.py index e5b51d0..84f7d02 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -171,3 +171,48 @@ def test_full_mode_shows_full_output(self): full=True, ) assert "line" in result + + def test_unknown_type_returns_empty(self): + result = format_part_to_md({"type": "unknown_type"}) + assert result == "" + + def test_empty_part_returns_empty(self): + result = format_part_to_md({"type": "text", "text": ""}) + assert result == "" + + def test_tool_without_input_key(self): + result = format_part_to_md( + { + "type": "tool", + "tool": "bash", + } + ) + assert "bash" in result + + def test_tool_with_null_output(self): + result = format_part_to_md( + { + "type": "tool", + "tool": "bash", + "input": {"command": "ls"}, + "output": None, + "status": "completed", + } + ) + assert "bash" in result + + def test_tool_with_unknown_status(self): + result = format_part_to_md( + { + "type": "tool", + "tool": "bash", + "input": {"command": "ls"}, + "output": "", + "status": "unknown", + } + ) + assert "🔧" in result + + def test_reasoning_empty_text(self): + result = format_part_to_md({"type": "reasoning", "text": ""}) + assert result == ""