migration: preserve review journal alerts and assistant slice

This commit is contained in:
leefer
2026-07-31 11:55:27 +08:00
parent b3df070481
commit 38de3de0a3
20 changed files with 1323 additions and 773 deletions
@@ -0,0 +1,211 @@
from __future__ import annotations
import ast
import hashlib
import unittest
from pathlib import Path
import assistant_agent
from backend.features.review import agent as canonical_agent
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
ALERT_SERVICE_METHODS = {
"alert_center",
"create_alert",
"mark_alert_read",
"mark_all_alerts_read",
"delete_alert",
}
REVIEW_SERVICE_METHODS = {
"trade_entries",
"review_watchlist",
"save_trade_entry",
"delete_trade_entry",
"assistant_messages",
"clear_assistant_messages",
"assistant_stream",
"_assistant_context",
}
ALERT_REPOSITORY_METHODS = {
"save_alert",
"list_alerts",
"count_unread_alerts",
"mark_alert_read",
"mark_all_alerts_read",
"delete_alert",
}
REVIEW_REPOSITORY_METHODS = {
"list_watchlist",
"save_watchlist",
"watchlist_price_history",
"delete_watchlist",
"list_notes",
"save_note",
"delete_note",
"save_trade_entry",
"list_trade_entries",
"delete_trade_entry",
"save_assistant_exchange",
"list_assistant_messages",
"delete_assistant_messages",
}
ALERT_HTTP_METHODS = {"save_alert"}
REVIEW_HTTP_METHODS = {
"save_trade_entry",
"stream_assistant_chat",
"save_watchlist",
"save_note",
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def class_methods(path: Path, class_name: str) -> dict[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
owner = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
)
return {
node.name: ast.dump(node, include_attributes=False)
for node in owner.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def top_level_definition(path: Path, name: str) -> str:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
node = next(
item
for item in tree.body
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and item.name == name
)
return ast.dump(node, include_attributes=False)
class ReviewAlertsSliceSourceEquivalenceTests(unittest.TestCase):
def assert_methods_equal(
self,
original_path: Path,
original_class: str,
migrated_path: Path,
migrated_class: str,
expected: set[str],
) -> None:
original = class_methods(original_path, original_class)
migrated = class_methods(migrated_path, migrated_class)
self.assertEqual(set(migrated), expected)
for name in sorted(expected):
self.assertEqual(migrated[name], original[name], name)
def test_review_assistant_agent_is_an_exact_file(self) -> None:
self.assertEqual(
sha256(ORIGINAL_ROOT / "assistant_agent.py"),
sha256(APP_ROOT / "backend" / "features" / "review" / "agent.py"),
)
def test_review_assistant_compatibility_module_is_canonical(self) -> None:
self.assertIs(assistant_agent, canonical_agent)
def test_alert_and_review_service_methods_are_exact_original_ast(self) -> None:
self.assert_methods_equal(
ORIGINAL_ROOT / "server.py",
"DashboardService",
APP_ROOT / "backend" / "features" / "alerts" / "facade.py",
"AlertServiceMixin",
ALERT_SERVICE_METHODS,
)
self.assert_methods_equal(
ORIGINAL_ROOT / "server.py",
"DashboardService",
APP_ROOT / "backend" / "features" / "review" / "service.py",
"ReviewServiceMixin",
REVIEW_SERVICE_METHODS,
)
def test_alert_and_review_repository_methods_are_exact_original_ast(self) -> None:
self.assert_methods_equal(
ORIGINAL_ROOT / "database.py",
"ReviewDatabase",
APP_ROOT / "backend" / "features" / "alerts" / "repository.py",
"AlertRepositoryMixin",
ALERT_REPOSITORY_METHODS,
)
self.assert_methods_equal(
ORIGINAL_ROOT / "database.py",
"ReviewDatabase",
APP_ROOT / "backend" / "features" / "review" / "repository.py",
"ReviewRepositoryMixin",
REVIEW_REPOSITORY_METHODS,
)
def test_existing_alert_and_trade_journal_services_preserve_original_classes(self) -> None:
self.assertEqual(
top_level_definition(
ORIGINAL_ROOT / "backend" / "features" / "alerts" / "service.py",
"AlertService",
),
top_level_definition(
APP_ROOT / "backend" / "features" / "alerts" / "service.py",
"AlertService",
),
)
self.assertEqual(
top_level_definition(
ORIGINAL_ROOT / "backend" / "features" / "review" / "trade_journal.py",
"TradeJournalService",
),
top_level_definition(
APP_ROOT / "backend" / "features" / "review" / "trade_journal.py",
"TradeJournalService",
),
)
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
remaining_service = class_methods(
APP_ROOT / "backend" / "application.py", "DashboardService"
)
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
remaining_http = class_methods(
APP_ROOT / "backend" / "application.py", "RequestHandler"
)
self.assertTrue(ALERT_SERVICE_METHODS.isdisjoint(remaining_service))
self.assertTrue(REVIEW_SERVICE_METHODS.isdisjoint(remaining_service))
self.assertTrue(ALERT_REPOSITORY_METHODS.isdisjoint(remaining_database))
self.assertTrue(REVIEW_REPOSITORY_METHODS.isdisjoint(remaining_database))
self.assertTrue(ALERT_HTTP_METHODS.isdisjoint(remaining_http))
self.assertTrue(REVIEW_HTTP_METHODS.isdisjoint(remaining_http))
def test_http_mixins_preserve_all_endpoints_without_global_service(self) -> None:
alerts = class_methods(
APP_ROOT / "backend" / "features" / "alerts" / "http.py",
"AlertHttpMixin",
)
review = class_methods(
APP_ROOT / "backend" / "features" / "review" / "http.py",
"ReviewHttpMixin",
)
self.assertEqual(set(alerts), ALERT_HTTP_METHODS)
self.assertEqual(set(review), REVIEW_HTTP_METHODS)
for path in (
APP_ROOT / "backend" / "features" / "alerts" / "http.py",
APP_ROOT / "backend" / "features" / "review" / "http.py",
):
source = path.read_text(encoding="utf-8")
self.assertNotIn("SERVICE.", source)
self.assertIn("self.application_service.", source)
if __name__ == "__main__":
unittest.main()