@@ -822,6 +822,138 @@ def test_presession_rewrite_does_not_claim_split_tail(self, temp_docx):
822822 assert "BETA" in text and "beta" not in text
823823 assert "alpha" in text and "gamma" in text
824824
825+ def test_middle_delete_of_dateless_own_insertion_leaves_tail_ungrouped (self , temp_xml ):
826+ # The origin insertion is ours but carries no w:date, so
827+ # reconstruction left it ungrouped. Its split-off tail must then be
828+ # registered as *explicitly* ungrouped (None) rather than left
829+ # unregistered — otherwise the delete's own capture would claim it,
830+ # and rejecting the delete's group would rip a leftover piece out of
831+ # a pre-existing insertion.
832+ body = (
833+ "<w:p><w:r><w:t>Before </w:t></w:r>"
834+ f'<w:ins w:id="1" w:author="{ AUTHOR_B } "><w:r><w:t>AAA MID BBB</w:t></w:r></w:ins>'
835+ "<w:r><w:t> after</w:t></w:r></w:p>"
836+ )
837+ manager = _make_manager (temp_xml (body ))
838+ assert manager ._groups == {}
839+ assert manager .group_id_of (1 ) is None
840+
841+ manager .suggest_deletion ("MID " )
842+
843+ # No new group: the delete created no revisions of its own, and the
844+ # tail it split off did not become a phantom group either.
845+ assert manager ._groups == {}
846+ revisions = manager .list_revisions ()
847+ assert sorted (rev .id for rev in revisions ) == [1 , 2 ] # truncated head + split-off tail
848+ assert all (rev .group_id is None for rev in revisions )
849+ assert manager ._revision_groups == {2 : None }
850+
851+
852+ class TestGroupedCaptureFilter :
853+ """``_grouped`` keeps only our own, attached, numerically-identified, unseen revisions."""
854+
855+ def test_capture_skips_foreign_detached_nonconforming_and_repeated_elements (self , temp_xml ):
856+ # The injector only ever reports elements whose numeric w:id it just
857+ # assigned, so the collector is fed directly here: this pins the
858+ # filter's contract — (i) not ours, (ii) no longer in the DOM,
859+ # (iii) already registered, a non-numeric id, or the same id reported
860+ # twice — as the last line of defence, independent of the injector.
861+ manager = _make_manager (temp_xml ("<w:p><w:r><w:t>text</w:t></w:r></w:p>" ))
862+ dom = manager .editor .dom
863+ paragraph = dom .getElementsByTagName ("w:p" )[0 ]
864+
865+ def make_ins (rev_id , author : str = AUTHOR_B , attached : bool = True ):
866+ elem = dom .createElement ("w:ins" )
867+ elem .setAttribute ("w:id" , str (rev_id ))
868+ elem .setAttribute ("w:author" , author )
869+ elem .setAttribute ("w:date" , DATE_A )
870+ if attached :
871+ paragraph .appendChild (elem )
872+ return elem
873+
874+ with manager ._grouped () as capture :
875+ collected = manager .editor ._tracked_change_collector
876+ assert collected is not None
877+ collected .append (make_ins (10 , author = AUTHOR_A )) # a split half of a foreign insertion
878+ collected .append (make_ins (11 , attached = False )) # create-then-remove churn
879+ collected .append (make_ins ("oops" )) # nonconforming producer's id
880+ collected .append (make_ins (12 ))
881+ collected .append (make_ins (12 )) # same id reported twice
882+ collected .append (make_ins (13 ))
883+
884+ assert capture .group_id == 1
885+ assert manager .group_revisions (1 ) == (12 , 13 )
886+ assert manager .group_id_of (10 ) is None
887+ assert manager .group_id_of (11 ) is None
888+ assert manager ._revision_groups == {12 : 1 , 13 : 1 }
889+
890+ def test_capture_with_nothing_kept_allocates_no_group (self , temp_xml ):
891+ manager = _make_manager (temp_xml ("<w:p><w:r><w:t>text</w:t></w:r></w:p>" ))
892+ paragraph = manager .editor .dom .getElementsByTagName ("w:p" )[0 ]
893+
894+ with manager ._grouped () as capture :
895+ foreign = manager .editor .dom .createElement ("w:ins" )
896+ foreign .setAttribute ("w:id" , "10" )
897+ foreign .setAttribute ("w:author" , AUTHOR_A )
898+ foreign .setAttribute ("w:date" , DATE_A )
899+ paragraph .appendChild (foreign )
900+ collected = manager .editor ._tracked_change_collector
901+ assert collected is not None
902+ collected .append (foreign )
903+
904+ assert capture .group_id is None
905+ assert manager ._groups == {}
906+ assert manager ._group_counter == 1
907+
908+
909+ class TestGroupLiveness :
910+ """``groups_are_dead`` and ``group_spans`` answer from the document, not the registry."""
911+
912+ def test_empty_and_unknown_queries_answer_without_a_walk (self , temp_xml , monkeypatch ):
913+ manager = _make_manager (temp_xml (f"<w:p>{ _ins_xml (1 , 'one' )} </w:p>" ))
914+ # Neither method may build the w:id index or walk the DOM when there
915+ # is nothing to look up: the index costs a full-document pass.
916+ monkeypatch .setattr (manager , "_revision_element_index" , lambda : pytest .fail ("built the w:id index" ))
917+ monkeypatch .setattr (
918+ "docx_editor.track_changes.registry._revision_elements" , lambda _root : pytest .fail ("walked the document" )
919+ )
920+
921+ assert manager .groups_are_dead ([]) == set ()
922+ assert manager .groups_are_dead (iter (())) == set ()
923+ assert manager .group_spans ([]) == {}
924+ assert manager .group_spans ([99 ]) == {} # unknown group: no members to look for
925+
926+ def test_unknown_live_and_resolved_groups (self , temp_xml ):
927+ body = f"<w:p>{ _ins_xml (1 , 'one ' )} </w:p><w:p>{ _ins_xml (2 , 'two' , date = DATE_B )} </w:p>"
928+ manager = _make_manager (temp_xml (body ))
929+ assert manager ._groups == {1 : (1 ,), 2 : (2 ,)}
930+
931+ # An id this manager never registered reads as dead; live groups do not.
932+ assert manager .groups_are_dead ([1 , 2 , 99 ]) == {99 }
933+ spans = manager .group_spans ([1 , 2 , 99 ])
934+ assert set (spans ) == {1 , 2 }
935+ first , last = spans [1 ]
936+ assert first is last and first .getAttribute ("w:id" ) == "1"
937+ # Revisions of groups not asked about are skipped, not reported.
938+ assert set (manager .group_spans ([2 ])) == {2 }
939+
940+ manager .accept_group (1 )
941+
942+ assert manager .groups_are_dead ([1 , 2 ]) == {1 }
943+ assert set (manager .group_spans ([1 , 2 ])) == {2 }
944+
945+ def test_paragraph_mark_only_group_is_live_but_has_no_span (self , temp_xml ):
946+ # A pure tracked split: the group's only member is a paragraph-mark
947+ # insertion, which cannot hold a comment marker. The group is alive
948+ # (something is left to resolve) yet offers no span to anchor on.
949+ body = f"<w:p><w:pPr><w:rPr>{ _mark_ins_xml (7 )} </w:rPr></w:pPr><w:r><w:t>plain</w:t></w:r></w:p>"
950+ manager = _make_manager (temp_xml (body ))
951+ gid = manager .group_id_of (7 )
952+ assert gid is not None and manager .group_revisions (gid ) == (7 ,)
953+
954+ assert manager .groups_are_dead ([gid ]) == set ()
955+ assert manager .group_spans ([gid ]) == {}
956+
825957
826958class TestGroupReconstruction :
827959 """Parse-time inference of groups for revisions already in the file (#46).
0 commit comments