@@ -56,6 +56,143 @@ def test_redact_strategies(strategy: str) -> None:
5656 assert result .mapping
5757
5858
59+ def _entity_at (text : str , value : str , entity_type : str = "EMAIL" ) -> Entity :
60+ start = text .index (value )
61+ return Entity (
62+ type = entity_type ,
63+ text = value ,
64+ start = start ,
65+ end = start + len (value ),
66+ confidence = 1.0 ,
67+ engine = "regex" ,
68+ )
69+
70+
71+ def test_redact_token_numbering_follows_document_order () -> None :
72+ text = "first alpha@example.com then beta@example.com end"
73+ entities = [
74+ _entity_at (text , "alpha@example.com" ),
75+ _entity_at (text , "beta@example.com" ),
76+ ]
77+
78+ result = redact (text = text , entities = entities , strategy = "token" )
79+
80+ assert result .redacted_text == "first [EMAIL_1] then [EMAIL_2] end"
81+ assert result .mapping == {
82+ "[EMAIL_1]" : "alpha@example.com" ,
83+ "[EMAIL_2]" : "beta@example.com" ,
84+ }
85+
86+
87+ def test_redact_pseudonymize_numbering_follows_document_order () -> None :
88+ text = "first alpha@example.com then beta@example.com end"
89+ entities = [
90+ _entity_at (text , "alpha@example.com" ),
91+ _entity_at (text , "beta@example.com" ),
92+ ]
93+
94+ result = redact (text = text , entities = entities , strategy = "pseudonymize" )
95+
96+ assert result .redacted_text == "first [EMAIL_PSEUDO_1] then [EMAIL_PSEUDO_2] end"
97+ assert result .mapping == {
98+ "[EMAIL_PSEUDO_1]" : "alpha@example.com" ,
99+ "[EMAIL_PSEUDO_2]" : "beta@example.com" ,
100+ }
101+
102+
103+ def _span (
104+ text : str ,
105+ start : int ,
106+ end : int ,
107+ entity_type : str = "PHONE" ,
108+ confidence : float = 1.0 ,
109+ engine : str = "regex" ,
110+ ) -> Entity :
111+ return Entity (
112+ type = entity_type ,
113+ text = text [start :end ],
114+ start = start ,
115+ end = end ,
116+ confidence = confidence ,
117+ engine = engine ,
118+ )
119+
120+
121+ @pytest .mark .parametrize ("strategy" , ["token" , "mask" , "hash" , "pseudonymize" ])
122+ def test_redact_overlapping_spans_match_longest_only (strategy : str ) -> None :
123+ text = "call 555-000-1111 now"
124+ full = _span (text , 5 , 17 )
125+ suffix = _span (text , 9 , 17 )
126+
127+ result = redact (text = text , entities = [full , suffix ], strategy = strategy )
128+ expected = redact (text = text , entities = [full ], strategy = strategy )
129+
130+ assert result .redacted_text == expected .redacted_text
131+ assert result .mapping == expected .mapping
132+ assert result .entities == [full ]
133+
134+
135+ @pytest .mark .parametrize ("strategy" , ["token" , "mask" , "hash" , "pseudonymize" ])
136+ def test_redact_nested_spans_keep_outer (strategy : str ) -> None :
137+ text = "mail alice@example.com today"
138+ outer = _span (text , 5 , 22 , entity_type = "EMAIL" )
139+ inner = _span (text , 11 , 18 , entity_type = "EMAIL" )
140+
141+ result = redact (text = text , entities = [inner , outer ], strategy = strategy )
142+ expected = redact (text = text , entities = [outer ], strategy = strategy )
143+
144+ assert result .redacted_text == expected .redacted_text
145+ assert result .mapping == expected .mapping
146+ assert result .entities == [outer ]
147+
148+
149+ def test_redact_overlapping_spans_produce_clean_token_output () -> None :
150+ text = "call 555-000-1111 now"
151+ entities = [_span (text , 5 , 17 ), _span (text , 9 , 17 )]
152+
153+ result = redact (text = text , entities = entities , strategy = "token" )
154+
155+ assert result .redacted_text == "call [PHONE_1] now"
156+ assert result .mapping == {"[PHONE_1]" : text [5 :17 ]}
157+
158+
159+ def test_redact_duplicate_spans_applied_once () -> None :
160+ text = "mail alice@example.com today"
161+ entity = _entity_at (text , "alice@example.com" )
162+
163+ result = redact (text = text , entities = [entity , entity ], strategy = "token" )
164+
165+ assert result .redacted_text == "mail [EMAIL_1] today"
166+ assert result .entities == [entity ]
167+
168+
169+ def test_redact_overlap_tiebreak_prefers_higher_confidence () -> None :
170+ text = "Acme Corporation announced"
171+ org = _span (text , 0 , 16 , entity_type = "ORGANIZATION" , confidence = 0.7 , engine = "spacy" )
172+ person = _span (text , 0 , 16 , entity_type = "PERSON" , confidence = 0.9 , engine = "gliner" )
173+
174+ result = redact (text = text , entities = [org , person ], strategy = "token" )
175+
176+ assert result .redacted_text == "[PERSON_1] announced"
177+ assert result .entities == [person ]
178+
179+
180+ def test_redact_mask_mapping_distinguishes_same_length_values () -> None :
181+ text = "a alice@example.com b bobby@example.com c"
182+ entities = [
183+ _entity_at (text , "alice@example.com" ),
184+ _entity_at (text , "bobby@example.com" ),
185+ ]
186+
187+ result = redact (text = text , entities = entities , strategy = "mask" )
188+
189+ assert result .redacted_text == "a " + "*" * 17 + " b " + "*" * 17 + " c"
190+ assert result .mapping == {
191+ "[EMAIL_MASK_1]" : "alice@example.com" ,
192+ "[EMAIL_MASK_2]" : "bobby@example.com" ,
193+ }
194+
195+
59196def test_redact_invalid_strategy_raises_value_error () -> None :
60197 with pytest .raises (ValueError , match = "strategy must be one of" ):
61198 redact ("test" , entities = [], strategy = "invalid" )
0 commit comments