diff --git a/src/agentic_orchestrator/translation/translator.py b/src/agentic_orchestrator/translation/translator.py index 575ebe1..66b9d0a 100644 --- a/src/agentic_orchestrator/translation/translator.py +++ b/src/agentic_orchestrator/translation/translator.py @@ -178,8 +178,9 @@ async def translate_to_korean(self, text: str) -> str: translated = response.content.strip() - # Remove any wrapper text the model might add - if translated.startswith("Korean translation:"): + # Remove any wrapper text the model might add (case-insensitive, + # matching translate_to_english's handling of the prefix). + if translated.lower().startswith("korean translation:"): translated = translated[len("Korean translation:") :].strip() # Remove markdown separator markers that the model might add diff --git a/tests/test_plan_parser.py b/tests/test_plan_parser.py new file mode 100644 index 0000000..1eb4493 --- /dev/null +++ b/tests/test_plan_parser.py @@ -0,0 +1,107 @@ +"""Tests for PlanParser (project/parser.py). + +Previously had zero coverage. PlanParser.parse() is synchronous and regex-based +(no LLM), so it can be exercised end-to-end on a representative plan document. +""" + +from agentic_orchestrator.project.parser import PlanParser, TechStack + +SAMPLE_PLAN = """# Mossland NFT Valuation Tracker + +## Project Overview +A real-time dashboard for tracking Mossland NFT valuations using AI agents. +It serves NFT holders who want live, accurate price data and portfolio insights. + +## Technical Architecture +Frontend: Next.js with React. Backend: FastAPI (Python). Database: PostgreSQL. +Blockchain: Ethereum with Solidity smart contracts. + +## Features +- Real-time NFT price tracking +- Portfolio valuation dashboard +- Price alerts via webhooks + +## Risks +- External API rate limits +- On-chain data latency + +## KPIs +- 1000 monthly active users +- 99.9% uptime +""" + + +class TestParseEndToEnd: + def setup_method(self): + self.parser = PlanParser() + + def test_title_from_h1(self): + parsed = self.parser.parse(SAMPLE_PLAN) + assert parsed.title == "Mossland NFT Valuation Tracker" + + def test_explicit_title_overrides_extraction(self): + parsed = self.parser.parse(SAMPLE_PLAN, title="Explicit Title") + assert parsed.title == "Explicit Title" + + def test_tech_stack_detected(self): + ts = self.parser.parse(SAMPLE_PLAN).tech_stack + assert ts.frontend == "nextjs" + assert ts.backend == "fastapi" + assert ts.database == "postgresql" + assert ts.blockchain == "ethereum" + # react also appears but the primary frontend slot is taken -> additional + assert "react" in ts.additional + + def test_features_extracted(self): + features = self.parser.parse(SAMPLE_PLAN).features + assert len(features) == 3 + assert "Real-time NFT price tracking" in features + + def test_risks_and_kpis(self): + parsed = self.parser.parse(SAMPLE_PLAN) + assert len(parsed.risks) == 2 + assert len(parsed.kpis) == 2 + + def test_summary_non_empty(self): + summary = self.parser.parse(SAMPLE_PLAN).summary + assert "real-time dashboard" in summary.lower() + + def test_raw_content_preserved(self): + assert self.parser.parse(SAMPLE_PLAN).raw_content == SAMPLE_PLAN + + +class TestParseEdgeCases: + def setup_method(self): + self.parser = PlanParser() + + def test_empty_content_returns_untitled(self): + parsed = self.parser.parse("") + assert parsed.title == "Untitled Project" + assert parsed.features == [] + assert isinstance(parsed.tech_stack, TechStack) + + def test_empty_content_respects_given_title(self): + assert self.parser.parse("", title="Given").title == "Given" + + def test_title_from_plan_prefix_when_no_h1(self): + parsed = self.parser.parse("Plan: Decentralized Identity Vault\n\nSome body text here.") + assert parsed.title == "Decentralized Identity Vault" + + +class TestTechDetection: + def setup_method(self): + self.parser = PlanParser() + + def test_detects_vue_and_django_and_solana(self): + ts = self.parser._detect_tech_stack( + "We will use Vue.js on the frontend, Django backend, and the Solana blockchain." + ) + assert ts.frontend == "vue" + assert ts.backend == "django" + assert ts.blockchain == "solana" + + def test_no_tech_leaves_fields_empty(self): + ts = self.parser._detect_tech_stack("A plan with no concrete technology mentioned at all.") + assert ts.frontend is None + assert ts.backend is None + assert ts.blockchain is None diff --git a/tests/test_translator.py b/tests/test_translator.py new file mode 100644 index 0000000..23f8e65 --- /dev/null +++ b/tests/test_translator.py @@ -0,0 +1,125 @@ +"""Tests for ContentTranslator (translation/translator.py). + +Previously had zero coverage. The LLM call is replaced by a FakeRouter so the +language detection, already-in-target-language short-circuits, model-wrapper +stripping, and bilingual orchestration are exercised deterministically. +""" + +import pytest + +from agentic_orchestrator.translation.translator import ContentTranslator + + +class FakeResponse: + def __init__(self, content, model="fake-model"): + self.content = content + self.model = model + + +class FakeRouter: + """Records calls and returns a canned translation.""" + + def __init__(self, reply="TRANSLATED"): + self.reply = reply + self.calls = [] + + async def route(self, **kwargs): + self.calls.append(kwargs) + return FakeResponse(self.reply) + + +@pytest.fixture +def translator(): + return ContentTranslator(router=FakeRouter()) + + +class TestDetectLanguage: + def test_korean(self, translator): + assert translator._detect_language("안녕하세요 반갑습니다 여러분") == "ko" + + def test_english(self, translator): + assert translator._detect_language("Hello world this is English text") == "en" + + def test_empty_defaults_to_english(self, translator): + assert translator._detect_language("") == "en" + + def test_numbers_and_symbols_only(self, translator): + assert translator._detect_language("12345 !@#$% 67890") == "en" + + def test_mostly_english_with_a_little_korean(self, translator): + # 3 Korean chars vs many ASCII letters -> ratio <= 0.3 -> "en" + assert translator._detect_language("Mossland NFT portfolio 트래커") == "en" + + def test_mostly_korean(self, translator): + assert translator._detect_language("모스랜드 NFT 가치 평가 대시보드 서비스") == "ko" + + +class TestTranslateShortCircuits: + async def test_to_english_skips_when_already_english(self, translator): + out = await translator.translate_to_english("This text is already in English") + assert out == "This text is already in English" + assert translator.router.calls == [] # LLM not invoked + + async def test_to_korean_skips_when_already_korean(self, translator): + text = "이미 한국어로 작성된 텍스트입니다" + out = await translator.translate_to_korean(text) + assert out == text + assert translator.router.calls == [] + + async def test_to_english_empty_returns_empty(self, translator): + assert await translator.translate_to_english(" ") == "" + + async def test_to_korean_empty_returns_empty(self, translator): + assert await translator.translate_to_korean("") == "" + + +class TestWrapperStripping: + async def test_strips_prefix_and_trailing_separator_ko_to_en(self): + router = FakeRouter(reply="English translation: Hello world\n---") + t = ContentTranslator(router=router) + out = await t.translate_to_english("한국어 텍스트를 영어로 번역해 주세요 부탁합니다") + assert out == "Hello world" + assert len(router.calls) == 1 + + async def test_strips_prefix_en_to_ko(self): + router = FakeRouter(reply="Korean translation: 안녕하세요") + t = ContentTranslator(router=router) + out = await t.translate_to_korean("Please translate this English sentence to Korean") + assert out == "안녕하세요" + + async def test_strips_lowercase_prefix_en_to_ko(self): + # Prefix removal is case-insensitive (consistent with KO→EN). + router = FakeRouter(reply="korean translation: 안녕하세요") + t = ContentTranslator(router=router) + out = await t.translate_to_korean("Please translate this English sentence to Korean") + assert out == "안녕하세요" + + async def test_translation_failure_returns_empty(self): + class BoomRouter: + async def route(self, **kwargs): + raise RuntimeError("llm down") + + t = ContentTranslator(router=BoomRouter()) + out = await t.translate_to_english("한국어 텍스트 번역 요청 실패 케이스 테스트") + assert out == "" + + +class TestEnsureBilingual: + async def test_korean_input_keeps_korean_translates_english(self): + router = FakeRouter(reply="English version") + t = ContentTranslator(router=router) + korean = "한국어 원본 텍스트입니다 영어로 번역되어야 합니다" + en, ko = await t.ensure_bilingual(korean) + assert ko == korean + assert en == "English version" + + async def test_english_input_keeps_english_translates_korean(self): + router = FakeRouter(reply="한국어 번역본") + t = ContentTranslator(router=router) + english = "English original content that should be translated to Korean" + en, ko = await t.ensure_bilingual(english) + assert en == english + assert ko == "한국어 번역본" + + async def test_empty_returns_pair_of_empties(self, translator): + assert await translator.ensure_bilingual("") == ("", "")