Files

1 line
16 KiB
JSON

{"repo": "bohdandan/mandarin", "files": {"tests/test_sync_anki.py": {"ccfa55cb293842c869b4914b05a7660f0a434a5e": {"t": 1785846920.3389113, "n": 14554, "h": "fc7b3535d75708bc", "c": "import unittest\nfrom unittest.mock import Mock\n\nfrom scripts.sync_anki import (\n ANKI_FIELDS,\n build_anki_note,\n deck_name_for_entry,\n duplicate_query,\n equivalent_tags,\n example_audio_key,\n mandarin_vocabulary_css,\n mandarin_vocabulary_templates,\n match_stale_note,\n ordered_card_ids_for_entries,\n resolve_example_sound_ref,\n resolve_sound_ref,\n)\nfrom scripts.vocabulary import strip_html\n\n\nclass SyncAnkiTest(unittest.TestCase):\n def test_equivalent_tags_ignores_anki_tag_order(self):\n self.assertTrue(equivalent_tags([\"ADDED-2026\", \"CUSTOM\", \"HSK3\"], [\"HSK3\", \"CUSTOM\", \"ADDED-2026\"]))\n self.assertFalse(equivalent_tags([\"CUSTOM\", \"HSK3\"], [\"HSK3\", \"CUSTOM\", \"ADDED-2026\"]))\n\n def test_resolve_sound_ref_generates_audio_for_custom_entries_without_sound(self):\n entry = {\"id\": \"custom-word-xinwen\", \"hanzi\": \"新闻\", \"source\": \"custom\"}\n generator = Mock(return_value=\"[sound:custom-word-xinwen_google-cmn-cn-wavenet-c.mp3]\")\n\n sound = resolve_sound_ref(entry, \"\", generator)\n\n self.assertEqual(sound, \"[sound:custom-word-xinwen_google-cmn-cn-wavenet-c.mp3]\")\n generator.assert_called_once_with(entry)\n\n def test_resolve_sound_ref_keeps_existing_sound_for_custom_entries(self):\n entry = {\"id\": \"custom-word-xinwen\", \"hanzi\": \"新闻\", \"source\": \"custom\"}\n generator = Mock()\n\n sound = resolve_sound_ref(entry, \"[sound:existing.mp3]\", generator)\n\n self.assertEqual(sound, \"[sound:existing.mp3]\")\n generator.assert_not_called()\n\n def test_resolve_sound_ref_generates_audio_for_non_custom_entries_without_sound(self):\n entry = {\"id\": \"hsk1-0001-ai\", \"hanzi\": \"爱\", \"source\": \"hsk-1\"}\n generator = Mock(return_value=\"[sound:hsk1-0001-ai_google-cmn-cn-wavenet-c.mp3]\")\n\n sound = resolve_sound_ref(entry, \"\", generator)\n\n self.assertEqual(sound, \"[sound:hsk1-0001-ai_google-cmn-cn-wavenet-c.mp3]\")\n generator.assert_called_once_with(entry)\n\n def test_resolve_example_sound_ref_keeps_audio_when_sentence_key_matches(self):\n entry = {\"id\": \"hsk3-0001-ai\", \"example_sentence\": \"我<b>爱</b>学习中文。\"}\n generator = Mock()\n\n sound = resolve_example_sound_ref(\n entry,\n \"[sound:existing-example.mp3]\",\n example_audio_key(entry),\n generator,\n )\n\n self.assertEqual(sound, \"[sound:existing-example.mp3]\")\n generator.assert_not_called()\n\n def test_resolve_example_sound_ref_regenerates_audio_when_sentence_key_changes(self):\n entry = {\"id\": \"hsk3-0001-ai\", \"example_sentence\": \"我<b>爱</b>学习中文。\"}\n generator = Mock(return_value=\"[sound:new-example.mp3]\")\n\n sound = resolve_example_sound_ref(\n entry,\n \"[sound:old-example.mp3]\",\n \"old-sentence-key\",\n generator,\n )\n\n self.assertEqual(sound, \"[sound:new-example.mp3]\")\n generator.assert_called_once_with(entry)\n\n def test_builds_hsk_note_with_generated_display_fields_and_hsk30_deck(self):\n entry = {\n \"id\": \"hsk2-0200-shou-biao\",\n \"hanzi\": \"手表\",\n \"pinyin\": \"shǒu biǎo\",\n \"english\": \"watch\",\n \"example_sentence\": \"这是我的<b>手表</b>。\",\n \"sentence_pinyin\": \"zhè shì wǒ de shǒu biǎo\",\n \"sentence_translation\": \"This is my watch.\",\n \"hsk_level\": 2,\n \"source\": \"hsk-2\",\n \"lesson\": \"HSK:2.08\",\n \"notes\": \"\",\n \"created_at\": \"2026-04-19\",\n \"updated_at\": \"2026-04-19\",\n }\n\n note = build_anki_note(\n entry,\n model_name=\"Mandarin Vocabulary\",\n sound_ref=\"[sound:watch.mp3]\",\n example_sound_ref=\"[sound:sentence.mp3]\",\n )\n\n self.assertEqual(note[\"deckName\"], \"HSK3.0::HSK2\")\n self.assertEqual(note[\"modelName\"], \"Mandarin Vocabulary\")\n self.assertEqual(\n ANKI_FIELDS,\n [\n \"Vocabulary ID\",\n \"Hanzi\",\n \"Color\",\n \"Pinyin\",\n \"English\",\n \"Example Sentence\",\n \"Sentence Pinyin\",\n \"Sentence Translation\",\n \"Example Sound\",\n \"Example Audio Key\",\n \"Silhouette\",\n \"Sound\",\n ],\n )\n self.assertEqual(note[\"fields\"][\"Vocabulary ID\"], \"hsk2-0200-shou-biao\")\n self.assertEqual(note[\"fields\"][\"Hanzi\"], \"手表\")\n self.assertIn(\"tone3\", note[\"fields\"][\"Color\"])\n self.assertEqual(strip_html(note[\"fields\"][\"Pinyin\"]).strip(), \"shǒu biǎo\")\n self.assertEqual(note[\"fields\"][\"English\"], \"watch\")\n self.assertEqual(note[\"fields\"][\"Example Sentence\"], \"这是我的<b>手表</b>。\")\n self.assertEqual(note[\"fields\"][\"Sentence Pinyin\"], \"zhè shì wǒ de shǒu biǎo\")\n self.assertEqual(note[\"fields\"][\"Sentence Translation\"], \"This is my watch.\")\n self.assertEqual(note[\"fields\"][\"Example Sound\"], \"[sound:sentence.mp3]\")\n self.assertEqual(note[\"fields\"][\"Example Audio Key\"], example_audio_key(entry))\n self.assertEqual(note[\"fields\"][\"Silhouette\"], \"_ _\")\n self.assertEqual(note[\"fields\"][\"Sound\"], \"[sound:watch.mp3]\")\n self.assertEqual(note[\"tags\"], [\"HSK2\", \"HSK2::HSK:2.08\"])\n\n def test_deck_name_routes_hsk_and_custom_entries_into_hsk30_subdecks(self):\n self.assertEqual(deck_name_for_entry({\"source\": \"hsk-1\", \"hsk_level\": 1}), \"HSK3.0::HSK1\")\n self.assertEqual(deck_name_for_entry({\"source\": \"hsk-2\", \"hsk_level\": 2}), \"HSK3.0::HSK2\")\n self.assertEqual(deck_name_for_entry({\"source\": \"custom\", \"hsk_level\": 4}), \"HSK3.0::CUSTOM\")\n\n def test_deck_name_routes_series_entries_to_top_level_decks(self):\n self.assertEqual(deck_name_for_entry({\"source\": \"Pursuit of Jade\", \"hsk_level\": 4}), \"Pursuit of Jade\")\n self.assertEqual(deck_name_for_entry({\"source\": \"Scissor Seven\", \"hsk_level\": 5}), \"Scissor Seven\")\n\n def test_builds_pursuit_of_jade_note_with_episode_tag_and_series_deck(self):\n entry = {\n \"id\": \"pursuit-of-jade-e1-001-xiang-sheng-ban\",\n \"hanzi\": \"祥胜班\",\n \"pinyin\": \"xiáng shèng bān\",\n \"english\": \"Xiangsheng troupe\",\n \"example_sentence\": \"<b>祥胜班</b>在村里演出。\",\n \"hsk_level\": 4,\n \"source\": \"Pursuit of Jade\",\n \"lesson\": \"E1\",\n \"created_at\": \"2026-05-28\",\n \"updated_at\": \"2026-05-28\",\n }\n\n note = build_anki_note(entry, model_name=\"Mandarin Vocabulary\")\n\n self.assertEqual(note[\"deckName\"], \"Pursuit of Jade\")\n self.assertEqual(note[\"tags\"], [\"HSK4\", \"poj-1\"])\n\n def test_builds_scissor_seven_note_with_episode_tag_and_series_deck(self):\n entry = {\n \"id\": \"scissor-seven-e1-001-ci-ke\",\n \"hanzi\": \"刺客\",\n \"pinyin\": \"cì kè\",\n \"english\": \"assassin; killer\",\n \"example_sentence\": \"我们去做<b>刺客</b>吧。\",\n \"hsk_level\": 5,\n \"source\": \"Scissor Seven\",\n \"lesson\": \"S1E1\",\n \"created_at\": \"2026-05-28\",\n \"updated_at\": \"2026-05-28\",\n }\n\n note = build_anki_note(entry, model_name=\"Mandarin Vocabulary\")\n\n self.assertEqual(note[\"deckName\"], \"Scissor Seven\")\n self.assertEqual(note[\"tags\"], [\"HSK5\", \"ss-s1-e1\"])\n\n def test_duplicate_query_uses_stable_vocabulary_id(self):\n query = duplicate_query(\"hsk2-0200-shou-biao\", model_name=\"Mandarin Vocabulary\")\n\n self.assertEqual(query, 'note:\"Mandarin Vocabulary\" \"Vocabulary ID:hsk2-0200-shou-biao\"')\n\n def test_match_stale_note_preserves_unique_word_when_source_ownership_changes(self):\n stale_note = {\"noteId\": 42, \"fields\": {\"Hanzi\": {\"value\": \"啤酒\"}}}\n\n matched = match_stale_note(\n {\"id\": \"hsk3-0001-pijiu\", \"hanzi\": \"啤酒\"},\n desired_hanzi_counts={\"啤酒\": 1},\n stale_notes_by_hanzi={\"啤酒\": [stale_note]},\n )\n\n self.assertEqual(matched, stale_note)\n\n def test_match_stale_note_avoids_ambiguous_cross_source_words(self):\n stale_note = {\"noteId\": 42, \"fields\": {\"Hanzi\": {\"value\": \"病人\"}}}\n\n matched = match_stale_note(\n {\"id\": \"hsk3-0001-bingren\", \"hanzi\": \"病人\"},\n desired_hanzi_counts={\"病人\": 2},\n stale_notes_by_hanzi={\"病人\": [stale_note]},\n )\n\n self.assertIsNone(matched)\n\n def test_ordered_card_ids_follow_entry_order_and_card_order(self):\n entries = [\n {\"id\": \"scissor-seven-e1-001-ren-wu\"},\n {\"id\": \"scissor-seven-e1-002-huai-ren\"},\n ]\n notes_by_id = {\n \"scissor-seven-e1-002-huai-ren\": {\"cards\": [22, 21]},\n \"scissor-seven-e1-001-ren-wu\": {\"cards\": [12, 11]},\n }\n\n card_ids = ordered_card_ids_for_entries(entries, notes_by_id)\n\n self.assertEqual(card_ids, [11, 12, 21, 22])\n\n def test_templates_keep_single_meta_footer_lane_on_front_and_back(self):\n templates = mandarin_vocabulary_templates()\n\n for card_name in (\"Recognition\", \"Recall\"):\n front = templates[card_name][\"Front\"]\n back = templates[card_name][\"Back\"]\n\n self.assertIn('class=\"meta-row\"', front)\n self.assertIn('class=\"meta-row\"', back)\n self.assertIn('class=\"written-chinese-placeholder\"', front)\n self.assertIn('class=\"written-chinese-link\"', back)\n self.assertNotIn('class=\"card-footer\"', front)\n self.assertNotIn('class=\"card-footer\"', back)\n\n def test_templates_keep_anki_placeholders_escaped_after_footer_injection(self):\n templates = mandarin_vocabulary_templates()\n\n recognition_front = templates[\"Recognition\"][\"Front\"]\n recall_front = templates[\"Recall\"][\"Front\"]\n recall_back = templates[\"Recall\"][\"Back\"]\n\n self.assertIn(\"{{Hanzi}}\", recognition_front)\n self.assertIn(\"{{Sound}}\", recognition_front)\n self.assertIn(\"{{English}}\", recall_front)\n self.assertIn(\"{{Silhouette}}\", recall_front)\n self.assertIn(\"{{English}}\", recall_back)\n self.assertIn(\"{{Pinyin}}\", recall_back)\n self.assertIn(\"{{Color}}\", recall_back)\n self.assertIn(\"{{#Example Sentence}}\", recall_back)\n self.assertIn(\"{{Example Sentence}}\", recall_back)\n self.assertIn(\"{{/Example Sentence}}\", recall_back)\n self.assertIn(\"{{#Sentence Pinyin}}\", recall_back)\n self.assertIn(\"{{Sentence Pinyin}}\", recall_back)\n self.assertIn(\"{{#Sentence Translation}}\", recall_back)\n self.assertIn(\"{{Sentence Translation}}\", recall_back)\n self.assertIn(\"{{Example Sound}}\", recall_back)\n self.assertIn(\"{{Sound}}\", recall_back)\n\n def test_templates_label_word_and_example_audio_controls(self):\n templates = mandarin_vocabulary_templates()\n\n recognition_front = templates[\"Recognition\"][\"Front\"]\n recognition_back = templates[\"Recognition\"][\"Back\"]\n recall_back = templates[\"Recall\"][\"Back\"]\n\n self.assertIn('class=\"audio-control word-audio\"', recognition_front)\n self.assertIn('aria-label=\"Word audio\"', recognition_front)\n self.assertIn('<span class=\"audio-label\">Word</span>', recognition_front)\n self.assertIn('class=\"card-actions\"', recognition_front)\n self.assertIn('class=\"audio-control word-audio\"', recognition_back)\n self.assertIn('class=\"card-actions\"', recognition_back)\n self.assertIn('class=\"audio-control example-audio\"', recognition_back)\n self.assertIn('aria-label=\"Example sentence audio\"', recognition_back)\n self.assertIn('<span class=\"audio-label\">Example</span>', recognition_back)\n self.assertIn('class=\"example-block\"', recognition_back)\n self.assertIn('class=\"audio-control word-audio\"', recall_back)\n self.assertIn('class=\"card-actions\"', recall_back)\n self.assertIn('class=\"audio-control example-audio\"', recall_back)\n self.assertIn('class=\"example-block\"', recall_back)\n\n def test_css_uses_tighter_top_and_divider_spacing_with_mobile_fallback(self):\n css = mandarin_vocabulary_css()\n\n self.assertIn(\"padding: 16px 20px 18px;\", css)\n self.assertIn(\"margin-top: 8px;\", css)\n self.assertIn(\"margin: 14px 0;\", css)\n self.assertIn(\"justify-content: flex-start;\", css)\n self.assertIn(\"@media (max-width: 600px)\", css)\n self.assertIn(\"padding: 10px 14px 14px;\", css)\n self.assertIn(\"margin: 10px 0;\", css)\n\n def test_css_styles_labeled_audio_controls(self):\n css = mandarin_vocabulary_css()\n\n self.assertIn(\".audio-control\", css)\n self.assertIn(\".audio-label\", css)\n self.assertIn(\"grid-template-columns: auto auto;\", css)\n self.assertIn(\"border: 1px solid\", css)\n self.assertIn(\".example-audio\", css)\n self.assertIn(\".example-block\", css)\n self.assertIn(\".example-sound-row\", css)\n self.assertIn(\"background: rgba(98, 114, 164, 0.08);\", css)\n self.assertIn(\"border-left: 3px solid rgba(255, 184, 108, 0.65);\", css)\n self.assertIn(\"background: rgba(98, 114, 164, 0.08);\\n text-align: center;\", css)\n self.assertIn(\".example-block {\\n padding: 10px 10px 12px;\\n text-align: left;\", css)\n\n def test_css_keeps_word_audio_in_bottom_action_row_without_overlay(self):\n css = mandarin_vocabulary_css()\n\n self.assertIn(\".card-actions\", css)\n self.assertIn(\"justify-content: center;\", css)\n self.assertIn(\"margin-top: 10px;\", css)\n self.assertIn(\"margin-bottom: 0;\", css)\n self.assertIn(\"order: 98;\", css)\n self.assertIn(\"justify-content: flex-end;\", css)\n self.assertIn(\"margin-top: auto;\", css)\n self.assertIn(\"margin-bottom: 8px;\", css)\n self.assertIn(\"min-height: calc(100vh - 190px);\", css)\n self.assertIn(\"min-height: calc(100dvh - 190px);\", css)\n self.assertIn(\".card-content {\", css)\n self.assertIn(\"order: 99;\", css)\n self.assertIn(\"margin-top: 8px;\", css)\n self.assertIn(\".example-sound-row {\", css)\n self.assertNotIn(\"position: fixed;\", css)\n\n def test_css_makes_written_chinese_link_large_tap_target(self):\n css = mandarin_vocabulary_css()\n\n self.assertIn(\"display: inline-flex;\", css)\n self.assertIn(\"min-width: 44px;\", css)\n self.assertIn(\"min-height: 44px;\", css)\n self.assertIn(\"font-size: 22px;\", css)\n\n\nif __name__ == \"__main__\":\n unittest.main()\n"}}}}