@@ -150,6 +150,114 @@ def test_restore_command_handles_multiple_files_and_explicit_root(self):
150150 self .assertEqual ((self .destination / "translated.po" ).read_bytes (), translated )
151151 self .assertEqual ((self .destination / "library/with space.po" ).read_bytes (), DATED_PO )
152152
153+ def test_normalize_references_and_dates_before_staging (self ):
154+ for newline in (b"\n " , b"\r \n " ):
155+ with self .subTest (newline = newline ):
156+ before = DATED_PO .replace (b"\n " , newline )
157+ changed = before .replace (b"example.rst:10" , b"example.rst:200" )
158+ changed = changed .replace (b"2026-09-29" , b"2026-10-24" )
159+ for name in ("unchanged.po" , "translated.po" , "deleted.po" ):
160+ (self .destination / name ).write_bytes (before )
161+ self .track ()
162+ (self .destination / "unchanged.po" ).write_bytes (changed )
163+ (self .destination / "translated.po" ).write_bytes (
164+ changed .replace ('翻訳' .encode (), '新しい訳' .encode ())
165+ )
166+ (self .destination / "deleted.po" ).unlink ()
167+ new = self .destination / ("new-crlf.po" if newline == b"\r \n " else "new-lf.po" )
168+ new .write_bytes (changed )
169+ subprocess .run (
170+ [sys .executable , translations .__file__ , "restore-timestamp-only" ,
171+ "--references" , str (self .destination )],
172+ check = True , stdout = subprocess .PIPE , cwd = self .source ,
173+ )
174+ self .assertEqual ((self .destination / "unchanged.po" ).read_bytes (), before )
175+ expected = before .replace ('翻訳' .encode (), '新しい訳' .encode ())
176+ expected = expected .replace (b"2026-09-29" , b"2026-10-24" )
177+ expected = expected .replace (b"#: example.rst:10" + newline , b"" )
178+ self .assertEqual ((self .destination / "translated.po" ).read_bytes (), expected )
179+ self .assertEqual (new .read_bytes (), changed .replace (b"#: example.rst:200" + newline , b"" ))
180+ self .assertFalse ((self .destination / "deleted.po" ).exists ())
181+ self .track ()
182+ staged = subprocess .check_output (
183+ ["git" , "-C" , str (self .destination ), "show" , ":translated.po" ]
184+ )
185+ self .assertEqual (staged , expected )
186+
187+ def test_normalize_references_preserves_other_entry_changes (self ):
188+ changed = DATED_PO .replace (b"example.rst:10" , b"example.rst:200" )
189+ for old , new , action in [
190+ (b'msgid "example"' , b'msgid "new source"' , "drop" ),
191+ (b'msgid "example"' , b'msgctxt "new context"\n msgid "example"' , "drop" ),
192+ (b"example.rst:200" , b"renamed.rst:200" , "keep" ),
193+ (b"example.rst:200" , b"example.rst:200 other.rst:30" , "keep" ),
194+ (b'#: example.rst:200\n ' , b'' , "keep" ),
195+ (b'msgid "example"' , b'#, fuzzy\n msgid "example"' , "restore" ),
196+ (b'msgid "example"' , b'#. new comment\n msgid "example"' , "restore" ),
197+ ('msgstr "翻訳"' .encode (), 'msgstr "新しい訳"' .encode (), "drop" ),
198+ ]:
199+ with self .subTest (change = new ):
200+ incoming = changed .replace (old , new )
201+ expected = incoming
202+ if action == "drop" :
203+ expected = incoming .replace (b"#: example.rst:200\n " , b"" )
204+ elif action == "restore" :
205+ expected = incoming .replace (b"example.rst:200" , b"example.rst:10" )
206+ self .assertEqual (translations .normalize_po_references (DATED_PO , incoming ),
207+ expected )
208+
209+ def test_restore_line_numbers_matches_context_plural_and_reordered_entries (self ):
210+ header = DATED_PO .split (b"#: example.rst:10" )[0 ]
211+ first = (b'#: one.rst:10 two.rst:20\n msgctxt "first"\n '
212+ b'msgid "same"\n msgstr "first translation"\n \n ' )
213+ second = (b'#: one.rst:30\n msgctxt "second"\n '
214+ b'msgid "same"\n msgid_plural "others"\n msgstr[0] "plural"\n \n ' )
215+ added = b'#: new.rst:100\n msgid "new"\n msgstr ""\n \n '
216+ obsolete = b'#~ msgid "old"\n #~ msgstr "old translation"\n '
217+ updated_first = first .replace (b"one.rst:10 two.rst:20" , b"one.rst:100\n #: two.rst:200" )
218+ updated_first = updated_first .replace (b'msgid "same"' , b'msgid ""\n "sa"\n "me"' )
219+ updated_second = second .replace (b"one.rst:30" , b"one.rst:300" )
220+ incoming = header + added + updated_second + updated_first + obsolete
221+ expected_first = updated_first .replace (b"one.rst:100\n #: two.rst:200" , b"one.rst:10 two.rst:20" )
222+ self .assertEqual (
223+ translations .normalize_po_references (header + first + second + obsolete , incoming ),
224+ header + added .replace (b"#: new.rst:100\n " , b"" ) + second + expected_first + obsolete ,
225+ )
226+ # A new plural message with a translation has its references removed.
227+ updated_second = updated_second .replace (b'"others"' , b'"new plural"' )
228+ self .assertEqual (
229+ translations .normalize_po_references (header + second , header + updated_second ),
230+ header + updated_second .replace (b"#: one.rst:300\n " , b"" ),
231+ )
232+
233+ def test_untranslated_source_changes_drop_references (self ):
234+ before = DATED_PO .replace ('msgstr "翻訳"' .encode (), b'msgstr ""' )
235+ for original , incoming in [
236+ (b"" , before ),
237+ (before , before .replace (b'msgid "example"' , b'msgid "new source"' )),
238+ (before , before .replace (b'msgid "example"' ,
239+ b'msgctxt "new context"\n msgid "example"' )),
240+ (before , before .replace (b'msgid "example"\n msgstr ""\n ' ,
241+ b'msgid "example"\n msgid_plural "examples"\n msgstr[0] ""\n ' )),
242+ ]:
243+ with self .subTest (incoming = incoming ):
244+ expected = incoming .replace (b"#: example.rst:10\n " , b"" )
245+ normalized = translations .normalize_po_references (original , incoming )
246+ self .assertEqual (normalized , expected )
247+ self .assertEqual (translations .normalize_po_references (normalized , incoming ), expected )
248+
249+ def test_removed_references_do_not_return_on_next_download (self ):
250+ updated = DATED_PO .replace ('翻訳' .encode (), '新しい訳' .encode ())
251+ updated = updated .replace (b"#: example.rst:10\n " , b"#: example.rst:200\n #: other.rst:300\n " )
252+ expected = updated .replace (b"#: example.rst:200\n #: other.rst:300\n " , b"" )
253+ normalized = translations .normalize_po_references (DATED_PO , updated )
254+ self .assertEqual (normalized , expected )
255+ self .assertEqual (translations .normalize_po_references (normalized , updated ), expected )
256+ # Translation text resembling a reference is not a comment.
257+ literal = updated .replace ('新しい訳' .encode (), b"#: example.rst:200" )
258+ self .assertIn (b'msgstr "#: example.rst:200"' ,
259+ translations .normalize_po_references (normalized , literal ))
260+
153261 def test_sync_removes_old_path_and_preserves_untracked_files (self ):
154262 catalogs = ["builtins/functions.po" , "library/builtins.po" , "bugs.po" ]
155263 write_config (self .source , catalogs )
0 commit comments