diff --git a/tests/projects/test_lists_store.py b/tests/projects/test_lists_store.py new file mode 100644 index 000000000..4bd3e7083 --- /dev/null +++ b/tests/projects/test_lists_store.py @@ -0,0 +1,252 @@ +import time + +import pytest +import pytest_asyncio + +from tinyagentos.projects.lists_store import ProjectListsStore, ProjectListEntriesStore + + +@pytest_asyncio.fixture +async def lists_store(tmp_path): + s = ProjectListsStore(tmp_path / "lists.db") + await s.init() + yield s + await s.close() + + +@pytest_asyncio.fixture +async def entries_store(tmp_path): + s = ProjectListEntriesStore(tmp_path / "entries.db") + await s.init() + yield s + await s.close() + + +@pytest.mark.asyncio +async def test_create_list_assigns_lst_prefix(lists_store): + l = await lists_store.create_list( + project_id="prj-1", title="Shopping", created_by="user-1" + ) + assert l["id"].startswith("lst-") + assert l["project_id"] == "prj-1" + assert l["title"] == "Shopping" + assert l["status"] == "active" + assert l["created_by"] == "user-1" + assert l["created_at"] == l["updated_at"] + + +@pytest.mark.asyncio +async def test_get_list_returns_none_for_missing(lists_store): + assert await lists_store.get_list("lst-missing") is None + + +@pytest.mark.asyncio +async def test_list_lists_scoped_to_project(lists_store): + await lists_store.create_list(project_id="prj-1", title="A", created_by="u") + await lists_store.create_list(project_id="prj-2", title="B", created_by="u") + items = await lists_store.list_lists("prj-1") + assert len(items) == 1 + assert items[0]["title"] == "A" + + +@pytest.mark.asyncio +async def test_update_list_changes_fields(lists_store): + l = await lists_store.create_list( + project_id="prj-1", title="Original", created_by="u" + ) + updated = await lists_store.update_list(l["id"], title="Renamed", status="archived") + assert updated["title"] == "Renamed" + assert updated["status"] == "archived" + assert updated["updated_at"] > l["updated_at"] + + +@pytest.mark.asyncio +async def test_delete_list(lists_store): + l = await lists_store.create_list( + project_id="prj-1", title="Gone", created_by="u" + ) + assert await lists_store.delete_list(l["id"]) is True + assert await lists_store.get_list(l["id"]) is None + + +@pytest.mark.asyncio +async def test_delete_nonexistent_list(lists_store): + assert await lists_store.delete_list("lst-missing") is False + + +@pytest.mark.asyncio +async def test_add_entry_then_get_entry_round_trip(entries_store): + e = await entries_store.add_entry( + list_id="lst-1", + project_id="prj-1", + text="Buy milk", + original_text="Buy milk", + author_kind="agent", + author_id="agent-1", + position=0, + ) + assert e["id"].startswith("ent-") + assert e["list_id"] == "lst-1" + assert e["project_id"] == "prj-1" + assert e["text"] == "Buy milk" + assert e["original_text"] == "Buy milk" + assert e["position"] == 0 + + again = await entries_store.get_entry(e["id"]) + assert again["id"] == e["id"] + assert again["text"] == "Buy milk" + assert again["original_text"] == "Buy milk" + + +@pytest.mark.asyncio +async def test_list_entries_ordered_by_position(entries_store): + await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="B", original_text="B", + author_kind="agent", author_id="agent-1", position=1, + ) + await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="A", original_text="A", + author_kind="agent", author_id="agent-1", position=0, + ) + await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="C", original_text="C", + author_kind="agent", author_id="agent-1", position=2, + ) + items = await entries_store.list_entries(project_id="prj-1", list_id="lst-1") + assert [item["text"] for item in items] == ["A", "B", "C"] + assert [item["position"] for item in items] == [0, 1, 2] + + +@pytest.mark.asyncio +async def test_update_entry_preserves_original_text(entries_store): + e = await entries_store.add_entry( + list_id="lst-1", + project_id="prj-1", + text="Get groceries", + original_text="Get groceries", + author_kind="agent", + author_id="agent-1", + ) + updated = await entries_store.update_entry(e["id"], text="Get groceries tidied") + assert updated is not None + assert updated["text"] == "Get groceries tidied" + assert updated["original_text"] == "Get groceries" + + +@pytest.mark.asyncio +async def test_delete_entry(entries_store): + e = await entries_store.add_entry( + list_id="lst-1", + project_id="prj-1", + text="Buy milk", + original_text="Buy milk", + author_kind="agent", + author_id="agent-1", + ) + assert await entries_store.delete_entry(e["id"]) is True + assert await entries_store.get_entry(e["id"]) is None + + +@pytest.mark.asyncio +async def test_delete_nonexistent_entry(entries_store): + assert await entries_store.delete_entry("ent-missing") is False + + +@pytest.mark.asyncio +async def test_reorder_entries(entries_store): + a = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="A", original_text="A", + author_kind="agent", author_id="agent-1", position=0, + ) + b = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="B", original_text="B", + author_kind="agent", author_id="agent-1", position=1, + ) + c = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="C", original_text="C", + author_kind="agent", author_id="agent-1", position=2, + ) + await entries_store.reorder_entries( + project_id="prj-1", + entries=[ + {"id": c["id"], "position": 0}, + {"id": a["id"], "position": 1}, + {"id": b["id"], "position": 2}, + ], + ) + items = await entries_store.list_entries(project_id="prj-1", list_id="lst-1") + assert [item["text"] for item in items] == ["C", "A", "B"] + + +@pytest.mark.asyncio +async def test_get_next_position_returns_sequential_values(entries_store): + a = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="A", original_text="A", + author_kind="agent", author_id="agent-1", position=0, + ) + assert await entries_store._get_next_position("prj-1", "lst-1") == 1 + + b = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="B", original_text="B", + author_kind="agent", author_id="agent-1", position=1, + ) + assert await entries_store._get_next_position("prj-1", "lst-1") == 2 + + await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="C", original_text="C", + author_kind="agent", author_id="agent-1", position=2, + ) + assert await entries_store._get_next_position("prj-1", "lst-1") == 3 + + +@pytest.mark.asyncio +async def test_list_entries_scoped_to_list_and_status(entries_store): + await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="A", original_text="A", + author_kind="agent", author_id="agent-1", + ) + await entries_store.add_entry( + list_id="lst-2", project_id="prj-1", text="B", original_text="B", + author_kind="agent", author_id="agent-1", + ) + c = await entries_store.add_entry( + list_id="lst-1", project_id="prj-1", text="C", original_text="C", + author_kind="agent", author_id="agent-1", + ) + await entries_store.update_entry(c["id"], status="closed") + assert len(await entries_store.list_entries(project_id="prj-1", list_id="lst-1")) == 2 + assert len(await entries_store.list_entries(project_id="prj-1", status="closed")) == 1 + + +@pytest.mark.asyncio +async def test_add_entries_without_positions_gets_ascending(entries_store): + """Adding entries without explicit positions should auto-assign distinct + ascending positions via _get_next_position (not flat 0s).""" + e1 = await entries_store.add_entry( + list_id="lst-1", + project_id="prj-1", + text="First", + original_text="First", + author_kind="agent", + author_id="agent-1", + ) + e2 = await entries_store.add_entry( + list_id="lst-1", + project_id="prj-1", + text="Second", + original_text="Second", + author_kind="agent", + author_id="agent-1", + ) + e3 = await entries_store.add_entry( + list_id="lst-1", + project_id="prj-1", + text="Third", + original_text="Third", + author_kind="agent", + author_id="agent-1", + ) + positions = [e["position"] for e in (e1, e2, e3)] + assert positions == [0, 1, 2], ( + f"Expected [0, 1, 2] but got {positions}" + ) diff --git a/tinyagentos/projects/ids.py b/tinyagentos/projects/ids.py index 5f0496058..2f21aa20e 100644 --- a/tinyagentos/projects/ids.py +++ b/tinyagentos/projects/ids.py @@ -1,7 +1,7 @@ from __future__ import annotations import secrets -ID_PREFIXES = ("prj", "tsk", "cmt", "rel", "cve", "dec", "doc", "ent", "rev", "cs", "rtn", "elm") +ID_PREFIXES = ("prj", "tsk", "cmt", "rel", "cve", "dec", "doc", "ent", "lst", "rev", "cs", "rtn", "elm") _ALPHABET = "abcdefghijklmnopqrstuvwxyz234567" diff --git a/tinyagentos/projects/lists_store.py b/tinyagentos/projects/lists_store.py new file mode 100644 index 000000000..dd1dac381 --- /dev/null +++ b/tinyagentos/projects/lists_store.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import time + +from tinyagentos.base_store import BaseStore +from tinyagentos.projects.ids import new_id + +LISTS_SCHEMA = """ +CREATE TABLE IF NOT EXISTS project_lists ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_by TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_lists_project ON project_lists(project_id); +CREATE INDEX IF NOT EXISTS idx_lists_status ON project_lists(project_id, status); +""" + + +class ProjectListsStore(BaseStore): + SCHEMA = LISTS_SCHEMA + + async def create_list( + self, + project_id: str, + title: str, + created_by: str, + description: str = "", + status: str = "active", + ) -> dict: + list_id = new_id("lst") + now = time.time() + await self._db.execute( + "INSERT INTO project_lists " + "(id, project_id, title, description, status, created_by, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (list_id, project_id, title, description, status, created_by, now, now), + ) + await self._db.commit() + return await self.get_list(list_id) + + async def get_list(self, list_id: str) -> dict | None: + async with self._db.execute( + "SELECT * FROM project_lists WHERE id = ?", (list_id,) + ) as cur: + row = await cur.fetchone() + if row is None: + return None + keys = [d[0] for d in cur.description] + return dict(zip(keys, row)) + + async def list_lists(self, project_id: str) -> list[dict]: + async with self._db.execute( + "SELECT * FROM project_lists WHERE project_id = ? ORDER BY created_at ASC", + (project_id,), + ) as cur: + rows = await cur.fetchall() + keys = [d[0] for d in cur.description] + return [dict(zip(keys, r)) for r in rows] + + async def update_list( + self, + list_id: str, + title: str | None = None, + description: str | None = None, + status: str | None = None, + ) -> dict | None: + sets = [] + params = [] + if title is not None: + sets.append("title = ?") + params.append(title) + if description is not None: + sets.append("description = ?") + params.append(description) + if status is not None: + sets.append("status = ?") + params.append(status) + if not sets: + return await self.get_list(list_id) + sets.append("updated_at = ?") + params.append(time.time()) + params.append(list_id) + await self._db.execute( + f"UPDATE project_lists SET {', '.join(sets)} WHERE id = ?", params + ) + await self._db.commit() + return await self.get_list(list_id) + + async def delete_list(self, list_id: str) -> bool: + cursor = await self._db.execute( + "DELETE FROM project_lists WHERE id = ?", (list_id,) + ) + await self._db.commit() + return cursor.rowcount > 0 + + +class ProjectListEntriesStore(BaseStore): + SCHEMA = """ + CREATE TABLE IF NOT EXISTS project_list_entries ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL, + project_id TEXT NOT NULL, + text TEXT NOT NULL, + original_text TEXT NOT NULL, + category TEXT, + status TEXT NOT NULL DEFAULT 'new', + done INTEGER NOT NULL DEFAULT 0, + author_kind TEXT NOT NULL, + author_id TEXT NOT NULL, + edited_by TEXT, + position INTEGER NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_entries_list ON project_list_entries(list_id); + CREATE INDEX IF NOT EXISTS idx_entries_project ON project_list_entries(project_id); + CREATE INDEX IF NOT EXISTS idx_entries_list_project ON project_list_entries(list_id, project_id); + CREATE INDEX IF NOT EXISTS idx_entries_status ON project_list_entries(project_id, status); + """ + MIGRATIONS = [] + + async def add_entry( + self, + list_id: str, + project_id: str, + text: str, + original_text: str, + author_kind: str, + author_id: str, + category: str | None = None, + position: int | None = None, + ) -> dict: + entry_id = new_id("ent") + now = time.time() + + await self._db.execute( + "INSERT INTO project_list_entries " + "(id, list_id, project_id, text, original_text, category, status, " + "done, author_kind, author_id, position, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + entry_id, list_id, project_id, text, original_text, + category, "new", 0, author_kind, author_id, + position if position is not None else await self._get_next_position(project_id, list_id), now, now, + ), + ) + await self._db.commit() + return await self.get_entry(entry_id) + + async def get_entry(self, entry_id: str) -> dict | None: + async with self._db.execute( + "SELECT * FROM project_list_entries WHERE id = ?", (entry_id,) + ) as cur: + row = await cur.fetchone() + if row is None: + return None + keys = [d[0] for d in cur.description] + return dict(zip(keys, row)) + + async def list_entries( + self, + project_id: str, + list_id: str | None = None, + status: str | None = None, + category: str | None = None, + ) -> list[dict]: + where_parts = ["project_id = ?"] + params: list = [project_id] + + if list_id is not None: + where_parts.append("list_id = ?") + params.append(list_id) + if status is not None: + where_parts.append("status = ?") + params.append(status) + if category is not None: + where_parts.append("category = ?") + params.append(category) + + async with self._db.execute( + f"SELECT * FROM project_list_entries WHERE {' AND '.join(where_parts)} " + "ORDER BY position ASC, created_at ASC", + params, + ) as cur: + rows = await cur.fetchall() + keys = [d[0] for d in cur.description] + return [dict(zip(keys, r)) for r in rows] + + async def update_entry( + self, + entry_id: str, + text: str | None = None, + category: str | None = None, + status: str | None = None, + done: int | None = None, + position: int | None = None, + edited_by: str | None = None, + ) -> dict | None: + sets = [] + params = [] + if text is not None: + sets.append("text = ?") + params.append(text) + if category is not None: + sets.append("category = ?") + params.append(category) + if status is not None: + sets.append("status = ?") + params.append(status) + if done is not None: + sets.append("done = ?") + params.append(done) + if position is not None: + sets.append("position = ?") + params.append(position) + if edited_by is not None: + sets.append("edited_by = ?") + params.append(edited_by) + if not sets: + return await self.get_entry(entry_id) + existing = await self.get_entry(entry_id) + if existing is None: + return None + sets.append("updated_at = ?") + params.append(time.time()) + params.append(entry_id) + await self._db.execute( + f"UPDATE project_list_entries SET {', '.join(sets)} WHERE id = ?", + params, + ) + await self._db.commit() + return await self.get_entry(entry_id) + + async def delete_entry(self, entry_id: str) -> bool: + cursor = await self._db.execute( + "DELETE FROM project_list_entries WHERE id = ?", (entry_id,) + ) + await self._db.commit() + return cursor.rowcount == 1 + + async def reorder_entries(self, project_id: str, entries: list[dict]) -> None: + for entry in entries: + await self._db.execute( + "UPDATE project_list_entries SET position = ?, updated_at = ? " + "WHERE id = ? AND project_id = ?", + (entry["position"], time.time(), entry["id"], project_id), + ) + await self._db.commit() + + async def _get_next_position(self, project_id: str, list_id: str) -> int: + async with self._db.execute( + "SELECT MAX(position) + 1 FROM project_list_entries " + "WHERE project_id = ? AND list_id = ?", + (project_id, list_id), + ) as cur: + row = await cur.fetchone() + return row[0] if row[0] is not None else 0