公開前の確認用サイト
日常訳NICHIJOYAKUEnglish ↗

OPEN SOURCE

software/tests/test_coverage_final.py

このサイトで読み、必要なファイルを保存できます。

日常訳はAIを用いて作成された翻訳です。人間の聖書学・原語の専門家による全訳の検証は確認されていません。既存の翻訳や原語資料と照らし合わせてお読みください。

← オープンソースの案内へ

software/tests/test_coverage_final.py768 行このファイルを保存 ↓

ZIPに含まれるものと同じ公開ソースです。行番号から、その行へのリンクを保存できます。

ファイルの確認情報SHA-256 392a0c75dab187a8ab1a59954825681c8a9248b2e628b5ab3147b611e692b2e5
1"""Final coverage tests for remaining gaps."""23import asyncio4import json5import subprocess6import time7import pytest8from pathlib import Path9from tempfile import TemporaryDirectory10from unittest.mock import AsyncMock, MagicMock, patch1112from src.context.builder import ContextBuilder13from src.context.cross_references import CrossReference, CrossReferenceManager, ReferenceType14from src.context.glossary import GlossaryManager15from src.context.pericope_generator import PericopeGenerator16from src.data.loader import DataLoader17from src.data.storage import Storage18from src.evaluation.evaluator import TranslationEvaluator19from src.evaluation.storage import EvaluationStorage20from src.exceptions import (21    ClaudeAPIError,22    CLINotFoundError,23    EvaluationError,24    QualityThresholdError,25    RateLimitError,26    TranslationError,27)28from src.models.evaluation import EvaluationScore, VerseEvaluation29from src.models.verse import VerseReference30from src.pipeline.prompt import PromptBuilder31from src.pipeline.translator import Translator32from src.utils.retry import (33    async_retry_with_backoff,34    retry_with_backoff,35    RetryConfig,36    RetryState,37)383940@pytest.fixture41def temp_data_dir(tmp_path: Path) -> Path:42    """Create temporary data directory."""43    sources_dir = tmp_path / "sources"44    (sources_dir / "greek").mkdir(parents=True)45    (sources_dir / "english" / "kjv").mkdir(parents=True)46    (tmp_path / "metadata").mkdir(parents=True)47    (tmp_path / "output").mkdir(parents=True)4849    greek_data = {50        "1:1": {"text": "Ἐν ἀρχῇ", "translit": "En archē"},51        "1:2": {"text": "οὗτος ἦν", "translit": "houtos ēn"},52    }53    with (sources_dir / "greek" / "john.json").open("w") as f:54        json.dump(greek_data, f)5556    kjv_data = {"1:1": "In the beginning", "1:2": "The same was"}57    with (sources_dir / "english" / "kjv" / "john.json").open("w") as f:58        json.dump(kjv_data, f)5960    return tmp_path616263class TestRetryDecoratorRateLimitRetryAfter:64    """Tests for retry decorator using rate limit retry_after."""6566    def test_sync_retry_uses_rate_limit_retry_after(self) -> None:67        """Test sync retry decorator uses RateLimitError.retry_after."""68        config = RetryConfig(69            max_attempts=3,70            base_delay=10.0,  # Very long base delay71            jitter=False,72            retryable_exceptions=(RateLimitError,),73        )7475        call_count = 076        times = []7778        @retry_with_backoff(config)79        def func_with_retry() -> str:80            nonlocal call_count81            call_count += 182            times.append(time.time())83            if call_count < 2:84                # Short retry_after should override base_delay85                raise RateLimitError("Rate limit", retry_after=0.001)86            return "success"8788        result = func_with_retry()89        assert result == "success"90        assert call_count == 291        # Verify delay was short (not 10 seconds)92        elapsed = times[1] - times[0]93        assert elapsed < 1.09495    @pytest.mark.asyncio96    async def test_async_retry_uses_rate_limit_retry_after(self) -> None:97        """Test async retry decorator uses RateLimitError.retry_after."""98        config = RetryConfig(99            max_attempts=3,100            base_delay=10.0,  # Very long base delay101            jitter=False,102            retryable_exceptions=(RateLimitError,),103        )104105        call_count = 0106        times = []107108        @async_retry_with_backoff(config)109        async def async_func_with_retry() -> str:110            nonlocal call_count111            call_count += 1112            times.append(time.time())113            if call_count < 2:114                raise RateLimitError("Rate limit", retry_after=0.001)115            return "async success"116117        result = await async_func_with_retry()118        assert result == "async success"119        assert call_count == 2120        elapsed = times[1] - times[0]121        assert elapsed < 1.0122123124class TestRetryDecoratorEdgeCases:125    """Tests for edge cases in retry decorators."""126127    def test_sync_retry_no_exception_captured(self) -> None:128        """Test sync retry handles no exception scenario."""129        # This tests line 109 - but the code path is unreachable in normal operation130        # since last_exception is always set when retrying. We test success case.131        config = RetryConfig(max_attempts=1, retryable_exceptions=())132133        @retry_with_backoff(config)134        def always_succeeds() -> str:135            return "success"136137        result = always_succeeds()138        assert result == "success"139140    @pytest.mark.asyncio141    async def test_async_retry_no_exception_captured(self) -> None:142        """Test async retry handles no exception scenario."""143        config = RetryConfig(max_attempts=1, retryable_exceptions=())144145        @async_retry_with_backoff(config)146        async def async_always_succeeds() -> str:147            return "async success"148149        result = await async_always_succeeds()150        assert result == "async success"151152153class TestRetryStateNonRateLimitWait:154    """Tests for RetryState wait with non-RateLimitError."""155156    def test_wait_with_non_rate_limit_error(self) -> None:157        """Test wait() uses get_delay when not RateLimitError."""158        config = RetryConfig(159            max_attempts=3,160            base_delay=0.001,161            jitter=False,162            retryable_exceptions=(ConnectionError,),163        )164        state = RetryState(config)165166        # Trigger with non-RateLimitError167        state.should_retry(ConnectionError("Connection failed"))168169        start = time.time()170        state.wait()171        elapsed = time.time() - start172173        assert elapsed < 1.0174        assert state.attempt == 1175176    @pytest.mark.asyncio177    async def test_async_wait_with_non_rate_limit_error(self) -> None:178        """Test async_wait() uses get_delay when not RateLimitError."""179        config = RetryConfig(180            max_attempts=3,181            base_delay=0.001,182            jitter=False,183            retryable_exceptions=(ConnectionError,),184        )185        state = RetryState(config)186187        state.should_retry(ConnectionError("Connection failed"))188189        start = time.time()190        await state.async_wait()191        elapsed = time.time() - start192193        assert elapsed < 1.0194        assert state.attempt == 1195196197class TestTranslatorTimeoutRetry:198    """Tests for translator timeout retry paths."""199200    @patch("src.pipeline.translator.subprocess.run")201    def test_call_claude_timeout_retry_logging(202        self, mock_run: MagicMock, temp_data_dir: Path203    ) -> None:204        """Test sync Claude call logs and retries on timeout."""205        loader = DataLoader(temp_data_dir)206        storage = Storage(temp_data_dir)207        glossary = GlossaryManager()208        context_builder = ContextBuilder(loader, glossary)209        prompt_builder = PromptBuilder(glossary)210211        retry_config = RetryConfig(212            max_attempts=3,213            base_delay=0.001,214            jitter=False,215            retryable_exceptions=(ClaudeAPIError,),  # Timeout is converted to ClaudeAPIError216        )217        translator = Translator(218            context_builder,219            prompt_builder,220            storage,221            retry_config=retry_config,222            subprocess_timeout=1,223        )224225        call_count = 0226227        def timeout_then_succeed(*args, **kwargs):228            nonlocal call_count229            call_count += 1230            if call_count < 3:231                raise subprocess.TimeoutExpired(cmd="claude", timeout=1)232            return MagicMock(returncode=0, stdout="成功", stderr="")233234        mock_run.side_effect = timeout_then_succeed235236        result = translator._call_claude("system", "user")237        assert result == "成功"238        assert call_count == 3239240    @patch("src.pipeline.translator.asyncio.create_subprocess_exec")241    @pytest.mark.asyncio242    async def test_call_claude_async_timeout_retry_logging(243        self, mock_subprocess: AsyncMock, temp_data_dir: Path244    ) -> None:245        """Test async Claude call logs and retries on timeout."""246        loader = DataLoader(temp_data_dir)247        storage = Storage(temp_data_dir)248        glossary = GlossaryManager()249        context_builder = ContextBuilder(loader, glossary)250        prompt_builder = PromptBuilder(glossary)251252        retry_config = RetryConfig(253            max_attempts=3,254            base_delay=0.001,255            jitter=False,256            retryable_exceptions=(ClaudeAPIError,),257        )258        translator = Translator(259            context_builder,260            prompt_builder,261            storage,262            retry_config=retry_config,263        )264265        call_count = 0266267        async def timeout_then_succeed(*args, **kwargs):268            nonlocal call_count269            call_count += 1270            if call_count < 3:271                raise asyncio.TimeoutError()272            mock_process = AsyncMock()273            mock_process.returncode = 0274            mock_process.communicate.return_value = ("成功".encode(), b"")275            return mock_process276277        mock_subprocess.side_effect = timeout_then_succeed278279        result = await translator._call_claude_async("system", "user")280        assert result == "成功"281        assert call_count == 3282283    @patch("src.pipeline.translator.asyncio.create_subprocess_exec")284    @pytest.mark.asyncio285    async def test_call_claude_async_file_not_found(286        self, mock_subprocess: AsyncMock, temp_data_dir: Path287    ) -> None:288        """Test async Claude call raises CLINotFoundError."""289        loader = DataLoader(temp_data_dir)290        storage = Storage(temp_data_dir)291        glossary = GlossaryManager()292        context_builder = ContextBuilder(loader, glossary)293        prompt_builder = PromptBuilder(glossary)294295        translator = Translator(296            context_builder,297            prompt_builder,298            storage,299        )300301        mock_subprocess.side_effect = FileNotFoundError("claude not found")302303        with pytest.raises(CLINotFoundError):304            await translator._call_claude_async("system", "user")305306307class TestTranslatorQualityGate:308    """Tests for translator quality gate enforcement."""309310    @patch("src.pipeline.translator.subprocess.run")311    def test_quality_gate_overall_fails(312        self, mock_run: MagicMock, temp_data_dir: Path313    ) -> None:314        """Test quality gate fails when overall score too low."""315        from datetime import datetime316317        loader = DataLoader(temp_data_dir)318        storage = Storage(temp_data_dir)319        glossary = GlossaryManager()320        context_builder = ContextBuilder(loader, glossary)321        prompt_builder = PromptBuilder(glossary)322323        # Create evaluator mock without spec to allow arbitrary method calls324        mock_evaluator = MagicMock()325        mock_evaluator.build_verse_prompt.return_value = "eval prompt"326327        # This evaluation will fail the overall score threshold328        low_score_eval = VerseEvaluation(329            verse_number=1,330            evaluator_model="test",331            timestamp=datetime.utcnow(),332            scores={333                "theological_accuracy": EvaluationScore(334                    dimension="theological_accuracy",335                    score=95.0,336                    confidence=90.0,337                    reasoning="Good",338                ),339                "source_fidelity": EvaluationScore(340                    dimension="source_fidelity",341                    score=40.0,342                    confidence=90.0,343                    reasoning="Poor",344                ),345                "naturalness": EvaluationScore(346                    dimension="naturalness",347                    score=40.0,348                    confidence=90.0,349                    reasoning="Poor",350                ),351                "readability": EvaluationScore(352                    dimension="readability",353                    score=40.0,354                    confidence=90.0,355                    reasoning="Poor",356                ),357                "glossary_consistency": EvaluationScore(358                    dimension="glossary_consistency",359                    score=40.0,360                    confidence=90.0,361                    reasoning="Poor",362                ),363                "literary_quality": EvaluationScore(364                    dimension="literary_quality",365                    score=40.0,366                    confidence=90.0,367                    reasoning="Poor",368                ),369            },370            flags=[],371            notes=None,372        )373        mock_evaluator.create_verse_evaluation.return_value = low_score_eval374375        translator = Translator(376            context_builder,377            prompt_builder,378            storage,379            quality_threshold=70.0,380            enforce_quality_gate=True,381            auto_evaluate=True,382            evaluator=mock_evaluator,383        )384385        # Mock translation call to succeed386        mock_run.return_value = MagicMock(returncode=0, stdout="翻訳", stderr="")387388        # Also need to mock the second _call_claude for evaluation389        # The first call translates, second call evaluates390        translation_response = "翻訳"391        eval_json = json.dumps({392            "theological_accuracy": {"score": 95, "confidence": 90, "reasoning": "Good"},393            "source_fidelity": {"score": 40, "confidence": 90, "reasoning": "Poor"},394            "naturalness": {"score": 40, "confidence": 90, "reasoning": "Poor"},395            "readability": {"score": 40, "confidence": 90, "reasoning": "Poor"},396            "glossary_consistency": {"score": 40, "confidence": 90, "reasoning": "Poor"},397            "literary_quality": {"score": 40, "confidence": 90, "reasoning": "Poor"},398            "style_adherence": {"score": 40, "confidence": 90, "reasoning": "Poor"},399            "formality_level": {"score": 40, "confidence": 90, "reasoning": "Poor"},400            "archaic_avoidance": {"score": 40, "confidence": 90, "reasoning": "Poor"},401        })402403        mock_run.side_effect = [404            MagicMock(returncode=0, stdout=translation_response, stderr=""),405            MagicMock(returncode=0, stdout=eval_json, stderr=""),406        ]407408        ref = VerseReference(book="john", chapter=1, verse=1)409        with pytest.raises(QualityThresholdError, match="failed quality gate"):410            translator.translate_verse(ref, skip_existing=False)411412    @patch("src.pipeline.translator.subprocess.run")413    def test_quality_gate_theological_fails(414        self, mock_run: MagicMock, temp_data_dir: Path415    ) -> None:416        """Test quality gate fails when theological accuracy too low."""417        from datetime import datetime418419        loader = DataLoader(temp_data_dir)420        storage = Storage(temp_data_dir)421        glossary = GlossaryManager()422        context_builder = ContextBuilder(loader, glossary)423        prompt_builder = PromptBuilder(glossary)424425        mock_evaluator = MagicMock()426        mock_evaluator.build_verse_prompt.return_value = "eval prompt"427428        # Evaluation with low theological accuracy429        low_theo_eval = VerseEvaluation(430            verse_number=1,431            evaluator_model="test",432            timestamp=datetime.utcnow(),433            scores={434                "theological_accuracy": EvaluationScore(435                    dimension="theological_accuracy",436                    score=70.0,  # Below 85% theological threshold437                    confidence=90.0,438                    reasoning="Concern",439                ),440                "source_fidelity": EvaluationScore(441                    dimension="source_fidelity",442                    score=90.0,443                    confidence=90.0,444                    reasoning="Good",445                ),446                "naturalness": EvaluationScore(447                    dimension="naturalness",448                    score=90.0,449                    confidence=90.0,450                    reasoning="Good",451                ),452                "readability": EvaluationScore(453                    dimension="readability",454                    score=90.0,455                    confidence=90.0,456                    reasoning="Good",457                ),458                "glossary_consistency": EvaluationScore(459                    dimension="glossary_consistency",460                    score=90.0,461                    confidence=90.0,462                    reasoning="Good",463                ),464                "literary_quality": EvaluationScore(465                    dimension="literary_quality",466                    score=90.0,467                    confidence=90.0,468                    reasoning="Good",469                ),470            },471            flags=["theological_concern"],472            notes=None,473        )474        mock_evaluator.create_verse_evaluation.return_value = low_theo_eval475476        translator = Translator(477            context_builder,478            prompt_builder,479            storage,480            quality_threshold=70.0,481            enforce_quality_gate=True,482            auto_evaluate=True,483            evaluator=mock_evaluator,484        )485486        translation_response = "翻訳"487        eval_json = json.dumps({488            "theological_accuracy": {"score": 70, "confidence": 90, "reasoning": "Concern"},489            "source_fidelity": {"score": 90, "confidence": 90, "reasoning": "Good"},490            "naturalness": {"score": 90, "confidence": 90, "reasoning": "Good"},491            "readability": {"score": 90, "confidence": 90, "reasoning": "Good"},492            "glossary_consistency": {"score": 90, "confidence": 90, "reasoning": "Good"},493            "literary_quality": {"score": 90, "confidence": 90, "reasoning": "Good"},494            "style_adherence": {"score": 90, "confidence": 90, "reasoning": "Good"},495            "formality_level": {"score": 90, "confidence": 90, "reasoning": "Good"},496            "archaic_avoidance": {"score": 90, "confidence": 90, "reasoning": "Good"},497        })498499        mock_run.side_effect = [500            MagicMock(returncode=0, stdout=translation_response, stderr=""),501            MagicMock(returncode=0, stdout=eval_json, stderr=""),502        ]503504        ref = VerseReference(book="john", chapter=1, verse=1)505        with pytest.raises(QualityThresholdError, match="theological accuracy"):506            translator.translate_verse(ref, skip_existing=False)507508    @patch("src.pipeline.translator.subprocess.run")509    def test_evaluation_error_with_quality_gate_enforced(510        self, mock_run: MagicMock, temp_data_dir: Path511    ) -> None:512        """Test evaluation error raises when quality gate enforced."""513        loader = DataLoader(temp_data_dir)514        storage = Storage(temp_data_dir)515        glossary = GlossaryManager()516        context_builder = ContextBuilder(loader, glossary)517        prompt_builder = PromptBuilder(glossary)518519        # Evaluator that throws an exception520        mock_evaluator = MagicMock()521        mock_evaluator.build_verse_prompt.side_effect = Exception("Evaluation failed")522523        translator = Translator(524            context_builder,525            prompt_builder,526            storage,527            enforce_quality_gate=True,528            auto_evaluate=True,529            evaluator=mock_evaluator,530        )531532        mock_run.return_value = MagicMock(returncode=0, stdout="翻訳", stderr="")533534        ref = VerseReference(book="john", chapter=1, verse=1)535        with pytest.raises(EvaluationError, match="Evaluation failed"):536            translator.translate_verse(ref, skip_existing=False)537538539class TestCrossReferenceManagerEdgeCases:540    """Tests for CrossReferenceManager edge cases."""541542    def test_load_with_no_data_dir(self) -> None:543        """Test loading when data_dir is None."""544        manager = CrossReferenceManager()  # No data_dir545        manager._load_cross_references()  # Should return early546        assert len(manager) == 0547548    def test_save_with_no_data_dir_raises(self) -> None:549        """Test save raises ValueError when no data_dir."""550        manager = CrossReferenceManager()  # No data_dir551        manager.add_reference(552            CrossReference(553                source_book="john",554                source_chapter=1,555                source_verse=1,556                target_book="genesis",557                target_chapter=1,558                target_verse_start=1,559            )560        )561562        with pytest.raises(ValueError, match="No data directory"):563            manager.save()564565    def test_find_references_to_verse_with_range(self) -> None:566        """Test finding references targeting a verse within a range."""567        manager = CrossReferenceManager()568        # Reference with a range target569        manager.add_reference(570            CrossReference(571                source_book="matthew",572                source_chapter=4,573                source_verse=4,574                target_book="deuteronomy",575                target_chapter=8,576                target_verse_start=3,577                target_verse_end=5,  # Range578            )579        )580581        # Should find when querying start verse582        found = manager.find_references_to_verse("deuteronomy", 8, 3)583        assert len(found) == 1584585586class TestPericopeGeneratorEdgeCases:587    """Tests for PericopeGenerator edge cases."""588589    def test_update_pericope_range_empty_verses(self) -> None:590        """Test _update_pericope_range returns early for empty verses."""591        with TemporaryDirectory() as tmpdir:592            data_dir = Path(tmpdir)593            (data_dir / "sources" / "greek").mkdir(parents=True)594595            generator = PericopeGenerator(data_dir)596597            from src.models.pericope import Pericope598599            pericope = Pericope(600                id="test-1",601                name="Test",602                start_chapter=1,603                start_verse=1,604                end_chapter=1,605                end_verse=1,606                verses=[],  # Empty607            )608609            # Should return early without error610            generator._update_pericope_range(pericope)611            # Verses still empty612            assert pericope.verses == []613614    def test_generate_and_save_loads_existing_file(self) -> None:615        """Test generate_and_save loads existing pericopes file."""616        with TemporaryDirectory() as tmpdir:617            data_dir = Path(tmpdir)618            greek_dir = data_dir / "sources" / "greek"619            greek_dir.mkdir(parents=True)620            metadata_dir = data_dir / "metadata"621            metadata_dir.mkdir(parents=True)622623            # Create Greek data624            greek_data = {625                "1:1": {"text": "¶ Ἐν ἀρχῇ"},626                "1:2": {"text": "οὗτος ἦν"},627            }628            with (greek_dir / "john.json").open("w") as f:629                json.dump(greek_data, f)630631            # Pre-existing pericopes file632            existing = {633                "matthew": {634                    "name": "matthew",635                    "pericopes": [],636                    "auto_generated": True,637                }638            }639            output_path = metadata_dir / "pericopes_generated.json"640            with output_path.open("w") as f:641                json.dump(existing, f)642643            generator = PericopeGenerator(data_dir)644            index = generator.generate_and_save("john")645646            # Should have merged with existing647            with output_path.open("r") as f:648                data = json.load(f)649            assert "matthew" in data650            assert "john" in data651652    def test_generate_all_books_with_no_books(self) -> None:653        """Test generate_all_books scans directory when books is None."""654        with TemporaryDirectory() as tmpdir:655            data_dir = Path(tmpdir)656            greek_dir = data_dir / "sources" / "greek"657            greek_dir.mkdir(parents=True)658659            # Create multiple book files with enough verses to form pericopes660            for book in ["john", "mark"]:661                greek_data = {662                    "1:1": {"text": "¶ Test verse 1"},663                    "1:2": {"text": "Test verse 2"},664                    "1:3": {"text": "Test verse 3"},665                    "1:4": {"text": "¶ Test verse 4"},666                    "1:5": {"text": "Test verse 5"},667                }668                with (greek_dir / f"{book}.json").open("w") as f:669                    json.dump(greek_data, f)670671            generator = PericopeGenerator(data_dir)672            result = generator.generate_all_books()  # No books specified673674            # Should find at least one book with pericopes675            assert len(result) >= 1676677    def test_generate_all_books_no_greek_dir(self) -> None:678        """Test generate_all_books with no Greek directory."""679        with TemporaryDirectory() as tmpdir:680            data_dir = Path(tmpdir)681            # No greek directory created682683            generator = PericopeGenerator(data_dir)684            result = generator.generate_all_books()  # No books685686            assert result == {}687688    def test_generate_all_books_empty_pericope_index(self) -> None:689        """Test generate_all_books skips empty pericope indexes."""690        with TemporaryDirectory() as tmpdir:691            data_dir = Path(tmpdir)692            greek_dir = data_dir / "sources" / "greek"693            greek_dir.mkdir(parents=True)694695            # Create book with no paragraph markers696            greek_data = {}  # Empty book697            with (greek_dir / "philemon.json").open("w") as f:698                json.dump(greek_data, f)699700            generator = PericopeGenerator(data_dir)701            result = generator.generate_all_books(books=["philemon"])702703            # Empty index should not be included704            assert "philemon" not in result705706707class TestRetryOnRetryCallback:708    """Tests for on_retry callback in retry decorators."""709710    def test_sync_on_retry_callback_called(self) -> None:711        """Test on_retry callback is called during sync retry."""712        config = RetryConfig(713            max_attempts=3,714            base_delay=0.001,715            jitter=False,716            retryable_exceptions=(ValueError,),717        )718719        callback_calls = []720721        def on_retry(exc: Exception, attempt: int) -> None:722            callback_calls.append((str(exc), attempt))723724        call_count = 0725726        @retry_with_backoff(config, on_retry=on_retry)727        def func_with_callback() -> str:728            nonlocal call_count729            call_count += 1730            if call_count < 3:731                raise ValueError(f"Attempt {call_count}")732            return "done"733734        result = func_with_callback()735        assert result == "done"736        assert len(callback_calls) == 2737        assert callback_calls[0] == ("Attempt 1", 0)738        assert callback_calls[1] == ("Attempt 2", 1)739740    @pytest.mark.asyncio741    async def test_async_on_retry_callback_called(self) -> None:742        """Test on_retry callback is called during async retry."""743        config = RetryConfig(744            max_attempts=3,745            base_delay=0.001,746            jitter=False,747            retryable_exceptions=(ValueError,),748        )749750        callback_calls = []751752        def on_retry(exc: Exception, attempt: int) -> None:753            callback_calls.append((str(exc), attempt))754755        call_count = 0756757        @async_retry_with_backoff(config, on_retry=on_retry)758        async def async_func_with_callback() -> str:759            nonlocal call_count760            call_count += 1761            if call_count < 3:762                raise ValueError(f"Attempt {call_count}")763            return "async done"764765        result = await async_func_with_callback()766        assert result == "async done"767        assert len(callback_calls) == 2768