1212from __future__ import annotations
1313
1414import csv
15+ import json
1516import os
1617import shutil
18+ import sys
1719import tempfile
1820
1921import pytest
2224from gen_fraud_graph .embeddings import EmbeddingGenerator
2325from gen_fraud_graph .evaluate import (
2426 FraudRing ,
27+ discover_account_universe ,
2528 evaluate ,
2629 evaluate_dataset ,
2730 load_flagged_accounts ,
2831 load_fraud_rings ,
32+ main ,
33+ run_cli ,
2934)
3035from gen_fraud_graph .typologies import FraudRingGenerator
3136
@@ -123,9 +128,7 @@ def test_load_fraud_rings_roundtrip(tmp_dir):
123128 path = os .path .join (tmp_dir , "fraud_cases.csv" )
124129 with open (path , "w" , newline = "" ) as fh :
125130 w = csv .writer (fh )
126- w .writerow (
127- ["pattern_id" , "start_acc_id" , "pattern_type" , "depth" , "involved_accounts" ]
128- )
131+ w .writerow (["pattern_id" , "start_acc_id" , "pattern_type" , "depth" , "involved_accounts" ])
129132 w .writerow (["pat_0" , "acc_1" , "cycle" , "3" , "acc_1|acc_2|acc_3" ])
130133 rings = load_fraud_rings (path )
131134 assert len (rings ) == 1
@@ -149,15 +152,134 @@ def test_load_flagged_accounts_plain_and_csv(tmp_dir):
149152 assert load_flagged_accounts (csv_path ) == {"acc_9" , "acc_8" }
150153
151154
155+ def test_load_flagged_accounts_edge_csv (tmp_dir ):
156+ """A CSV with src_id/dst_id columns flags both endpoints of each edge."""
157+ csv_path = os .path .join (tmp_dir , "edges.csv" )
158+ with open (csv_path , "w" , newline = "" ) as fh :
159+ w = csv .writer (fh )
160+ w .writerow (["src_id" , "dst_id" , "amount" ])
161+ w .writerow (["acc_1" , "acc_2" , "9999.00" ])
162+ w .writerow (["acc_2" , "acc_3" , "9999.00" ])
163+ w .writerow (["" , "acc_4" , "0" ]) # blank src is ignored, dst still flagged
164+ assert load_flagged_accounts (csv_path ) == {"acc_1" , "acc_2" , "acc_3" , "acc_4" }
165+
166+
167+ def test_load_flagged_accounts_csv_without_header (tmp_dir ):
168+ """A comma-bearing file with no recognised id column reads the first column."""
169+ csv_path = os .path .join (tmp_dir , "unknown.csv" )
170+ with open (csv_path , "w" , newline = "" ) as fh :
171+ w = csv .writer (fh )
172+ w .writerow (["foo" , "bar" ]) # unrecognised header -> treated as first-column data
173+ w .writerow (["acc_7" , "ignored" ])
174+ w .writerow (["" , "skip" ]) # blank first cell is skipped
175+ assert load_flagged_accounts (csv_path ) == {"foo" , "acc_7" }
176+
177+
178+ def test_discover_account_universe (tmp_dir ):
179+ """discover_account_universe reads account ids from <data>/accounts/*.csv."""
180+ assert discover_account_universe (tmp_dir ) is None # no accounts dir yet
181+
182+ acc_dir = os .path .join (tmp_dir , "accounts" )
183+ os .makedirs (acc_dir )
184+ with open (os .path .join (acc_dir , "accounts_0.csv" ), "w" , newline = "" ) as fh :
185+ w = csv .writer (fh )
186+ w .writerow (["account_id" , "name" ])
187+ w .writerow (["acc_1" , "Alice" ])
188+ w .writerow (["acc_2" , "Bob" ])
189+ # A non-CSV file in the directory is ignored.
190+ with open (os .path .join (acc_dir , "README.txt" ), "w" ) as fh :
191+ fh .write ("not a csv" )
192+
193+ assert discover_account_universe (tmp_dir ) == {"acc_1" , "acc_2" }
194+
195+
196+ def test_evaluate_ignores_empty_ring ():
197+ """A ring with no involved accounts is skipped at the ring level."""
198+ rings = [
199+ FraudRing ("R1" , frozenset ({"a1" , "a2" })),
200+ FraudRing ("R_empty" , frozenset ()),
201+ ]
202+ m = evaluate (rings , {"a1" , "a2" })
203+ assert m ["ring" ]["detected" ] == 1
204+ assert m ["ring" ]["total" ] == 2
205+
206+
207+ def test_evaluate_dataset_missing_fraud_cases (tmp_dir ):
208+ """evaluate_dataset raises a clear error when fraud_cases.csv is absent."""
209+ flagged = os .path .join (tmp_dir , "flagged.txt" )
210+ with open (flagged , "w" ) as fh :
211+ fh .write ("acc_1\n " )
212+ with pytest .raises (FileNotFoundError , match = "fraud_cases.csv" ):
213+ evaluate_dataset (tmp_dir , flagged )
214+
215+
216+ def _build_dataset (root ):
217+ """Write a minimal dataset (fraud/ + accounts/) and a flagged file.
218+
219+ Ground truth: one ring {acc_1, acc_2}. Universe adds one legit acc_3.
220+ Flagged: acc_1, acc_2 (perfect detection, one true negative).
221+ """
222+ fraud_dir = os .path .join (root , "fraud" )
223+ os .makedirs (fraud_dir )
224+ with open (os .path .join (fraud_dir , "fraud_cases.csv" ), "w" , newline = "" ) as fh :
225+ w = csv .writer (fh )
226+ w .writerow (["pattern_id" , "start_acc_id" , "pattern_type" , "depth" , "involved_accounts" ])
227+ w .writerow (["pat_0" , "acc_1" , "cycle" , "2" , "acc_1|acc_2" ])
228+ acc_dir = os .path .join (root , "accounts" )
229+ os .makedirs (acc_dir )
230+ with open (os .path .join (acc_dir , "accounts_0.csv" ), "w" , newline = "" ) as fh :
231+ w = csv .writer (fh )
232+ w .writerow (["account_id" ])
233+ for aid in ("acc_1" , "acc_2" , "acc_3" ):
234+ w .writerow ([aid ])
235+ flagged = os .path .join (root , "flagged.txt" )
236+ with open (flagged , "w" ) as fh :
237+ fh .write ("acc_1\n acc_2\n " )
238+ return flagged
239+
240+
241+ def test_run_cli_human_summary (tmp_dir , capsys ):
242+ """run_cli prints a human-readable summary and returns exit code 0."""
243+ flagged = _build_dataset (tmp_dir )
244+ rc = run_cli (["--data" , tmp_dir , "--flagged" , flagged ])
245+ assert rc == 0
246+ out = capsys .readouterr ().out
247+ assert "gen_fraud_graph evaluate" in out
248+ assert "Account level:" in out
249+ assert "Ring level" in out
250+ assert "tn=1" in out # acc_3 is the single true negative
251+
252+
253+ def test_run_cli_json_output (tmp_dir , capsys ):
254+ """run_cli --json prints valid JSON metrics."""
255+ flagged = _build_dataset (tmp_dir )
256+ rc = run_cli (["--data" , tmp_dir , "--flagged" , flagged , "--json" , "--ring-threshold" , "0.5" ])
257+ assert rc == 0
258+ payload = json .loads (capsys .readouterr ().out )
259+ assert payload ["account" ]["f1" ] == pytest .approx (1.0 )
260+ assert payload ["ring_threshold" ] == pytest .approx (0.5 )
261+ assert payload ["confusion" ]["true_negatives" ] == 1
262+
263+
264+ def test_main_entrypoint (tmp_dir , capsys , monkeypatch ):
265+ """main() parses sys.argv and exits 0 on success."""
266+ flagged = _build_dataset (tmp_dir )
267+ monkeypatch .setattr (
268+ sys , "argv" , ["gen-fraud-graph-evaluate" , "--data" , tmp_dir , "--flagged" , flagged ]
269+ )
270+ with pytest .raises (SystemExit ) as exc :
271+ main ()
272+ assert exc .value .code == 0
273+ assert "gen_fraud_graph evaluate" in capsys .readouterr ().out
274+
275+
152276def test_evaluate_dataset_end_to_end (tmp_dir ):
153277 """evaluate_dataset reads fraud_cases.csv and a flagged file from disk."""
154278 fraud_dir = os .path .join (tmp_dir , "fraud" )
155279 os .makedirs (fraud_dir )
156280 with open (os .path .join (fraud_dir , "fraud_cases.csv" ), "w" , newline = "" ) as fh :
157281 w = csv .writer (fh )
158- w .writerow (
159- ["pattern_id" , "start_acc_id" , "pattern_type" , "depth" , "involved_accounts" ]
160- )
282+ w .writerow (["pattern_id" , "start_acc_id" , "pattern_type" , "depth" , "involved_accounts" ])
161283 w .writerow (["pat_0" , "acc_1" , "cycle" , "2" , "acc_1|acc_2" ])
162284 flagged = os .path .join (tmp_dir , "flagged.txt" )
163285 with open (flagged , "w" ) as fh :
0 commit comments