"""T04 模拟案例生成与数据集划分测试。 先边界 fixture 固化逻辑(0 例、单组、近重复、跨时间边界、泄漏), 再验证批量生成的可复现性与配比(AC-35/36)。 全部使用临时目录;绝不写真实数据根。也不做任何模型调用。 """ import tempfile import unittest from pathlib import Path from contracts.prescreen import DatasetManifest, EventOutcome from services import simulation_dataset as sim def record(record_id, group, occurred, outcome="approved", label=True, near=None): return { "id": record_id, "synthetic": True, "data_domain": "demo", "customer": f"虚构客户{group}", "customer_group": group, "near_duplicate_of": near, "occurred_at": occurred, "outcome": outcome, "outcome_subtype": outcome, "label": label, "initial_facts": {}, "trace": {"generator_version": sim.GENERATOR_VERSION, "seed": 1}, } class BoundaryFixtureTests(unittest.TestCase): """边界先行:小样本手工 fixture,不依赖批量生成的规模。""" def test_empty_input_is_rejected(self): with self.assertRaises(ValueError) as ctx: sim.split([]) self.assertIn("没有可划分", str(ctx.exception)) def test_single_group_stays_in_one_split(self): records = [record(f"SIM-{i}", "CG-1", f"2024-01-0{i + 1}T00:00:00+00:00") for i in range(3)] result = sim.split(records) self.assertEqual(len({result["assignments"][r["id"]] for r in records}), 1) def test_group_never_splits_across_splits(self): records = [] for group in range(6): for member in range(4): day = group * 4 + member + 1 month = 1 + (day // 28) records.append(record(f"SIM-{group}-{member}", f"CG-{group}", f"2024-{month:02d}-{day % 28 + 1:02d}T00:00:00+00:00")) result = sim.split(records) sim.assert_group_integrity(records, result["assignments"]) def test_near_duplicate_must_share_a_split(self): records = [record("SIM-1", "CG-1", "2024-01-01T00:00:00+00:00"), record("SIM-2", "CG-1", "2024-06-01T00:00:00+00:00", near="SIM-1")] result = sim.split(records) self.assertEqual(result["assignments"]["SIM-1"], result["assignments"]["SIM-2"]) def test_near_duplicate_across_groups_is_detected(self): # 人为把近重复放到不同组 → 必须被发现 records = [record("SIM-1", "CG-1", "2024-01-01T00:00:00+00:00"), record("SIM-2", "CG-2", "2024-06-01T00:00:00+00:00", near="SIM-1")] assignments = {"SIM-1": "development", "SIM-2": "holdout", "CG-1": "development", "CG-2": "holdout"} with self.assertRaises(ValueError) as ctx: sim.assert_group_integrity(records, assignments) self.assertIn("跨集合", str(ctx.exception)) def test_group_straddling_split_is_detected(self): records = [record("SIM-1", "CG-1", "2024-01-01T00:00:00+00:00"), record("SIM-2", "CG-1", "2024-02-01T00:00:00+00:00")] with self.assertRaises(ValueError): sim.assert_group_integrity(records, {"SIM-1": "development", "SIM-2": "holdout"}) def test_holdout_source_cannot_be_used_for_extraction(self): split_result = {"assignments": {"SIM-1": "development", "SIM-2": "holdout"}} sim.assert_source_allowed(split_result, ["SIM-1"], "development") with self.assertRaises(ValueError) as ctx: sim.assert_source_allowed(split_result, ["SIM-1", "SIM-2"], "development") self.assertIn("保留集", str(ctx.exception)) self.assertIn("作废", str(ctx.exception)) def test_holdout_itself_is_never_an_extraction_target(self): with self.assertRaises(ValueError): sim.assert_source_allowed({"assignments": {}}, ["SIM-1"], "holdout") def test_unmapped_outcome_must_not_carry_a_label(self): sim.assert_labelled_outcomes_are_mapped([record("SIM-1", "CG-1", "x", "approved", True)]) with self.assertRaises(ValueError) as ctx: sim.assert_labelled_outcomes_are_mapped([record("SIM-1", "CG-1", "x", "conditional", True)]) self.assertIn("尚未确认口径", str(ctx.exception)) class GenerationTests(unittest.TestCase): def test_same_seed_and_version_reproduce_identical_records(self): first = sim.generate(seed=20260929, scale=0.02) second = sim.generate(seed=20260929, scale=0.02) self.assertEqual(first, second, "同 seed 同版本必须逐条一致") self.assertEqual(sim.source_hash(first), sim.source_hash(second)) def test_different_seed_changes_the_data(self): self.assertNotEqual(sim.source_hash(sim.generate(seed=1, scale=0.02)), sim.source_hash(sim.generate(seed=2, scale=0.02))) def test_records_cover_multiple_outcomes(self): records = sim.generate(seed=7, scale=0.02) outcomes = {r["outcome"] for r in records} self.assertGreaterEqual(len(outcomes), 4, f"结果过于单一:{outcomes}") def test_every_record_is_marked_synthetic_and_demo(self): for item in sim.generate(seed=7, scale=0.02): self.assertTrue(item["synthetic"]) self.assertEqual(item["data_domain"], "demo") def test_unsubmitted_and_conditional_have_no_label(self): # BR-21 / E-26:未送审、退出、待定、附条件一律不得当否决或通过 records = sim.generate(seed=7, scale=0.05) for item in records: if item["outcome"] in ("not_submitted", "withdrawn", "deferred", "conditional"): self.assertIsNone(item["label"], f'{item["outcome"]} 不应带标签') def test_supplement_and_direct_are_distinguished(self): records = sim.generate(seed=7, scale=0.05) subtypes = {r["outcome_subtype"] for r in records if r["outcome"] == "approved"} self.assertEqual(subtypes, {"direct", "after_supplement"}) def test_outcome_values_are_all_in_the_contract_enum(self): allowed = {item.value for item in EventOutcome} for item in sim.generate(seed=7, scale=0.02): self.assertIn(item["outcome"], allowed) def test_scale_validation(self): with self.assertRaises(ValueError): sim.generate(scale=0) with self.assertRaises(ValueError): sim.generate(seed=-1) def test_full_scale_matches_documented_mix(self): # 14 §8 的配比:通过 1800 / 未通过 600 / 不计入 600,合计 3000 records = sim.generate() self.assertEqual(len(records), 3000) by_outcome = {} for item in records: by_outcome[item["outcome"]] = by_outcome.get(item["outcome"], 0) + 1 self.assertEqual(by_outcome["approved"], 1800) self.assertEqual(by_outcome["rejected"], 600) self.assertEqual(by_outcome["conditional"] + by_outcome["not_submitted"] + by_outcome["withdrawn"] + by_outcome["deferred"], 600) class ManifestTests(unittest.TestCase): def test_manifest_is_contract_valid_and_marked_demo(self): records = sim.generate(seed=7, scale=0.05) body = sim.manifest(records, sim.split(records), seed=7) DatasetManifest(**body) # 不合法会抛 self.assertEqual(body["data_domain"], "demo") self.assertTrue(body["synthetic"]) self.assertEqual(body["split_policy_version"], sim.SPLIT_POLICY_VERSION) self.assertEqual(body["generator_version"], sim.GENERATOR_VERSION) self.assertEqual(len(body["source_hash"]), 64) def test_manifest_counts_match_records(self): records = sim.generate(seed=7, scale=0.05) body = sim.manifest(records, sim.split(records), seed=7) total = sum(item["count"] for item in body["counts"]) self.assertEqual(total, len(records)) def test_two_runs_produce_the_same_manifest_hash(self): # AC-35:同 seed/版本生成两次,规范化清单一致 first = sim.manifest(sim.generate(seed=11, scale=0.05), sim.split(sim.generate(seed=11, scale=0.05)), seed=11) second = sim.manifest(sim.generate(seed=11, scale=0.05), sim.split(sim.generate(seed=11, scale=0.05)), seed=11) self.assertEqual(first["source_hash"], second["source_hash"]) self.assertEqual(first["group_assignments_ref"], second["group_assignments_ref"]) self.assertEqual(first["counts"], second["counts"]) def test_synthetic_manifest_cannot_claim_production_domain(self): records = sim.generate(seed=7, scale=0.02) body = sim.manifest(records, sim.split(records), seed=7) body["data_domain"] = "production" with self.assertRaises(Exception): DatasetManifest(**body) class WriteTests(unittest.TestCase): def test_write_refuses_paths_outside_the_data_root(self): with tempfile.TemporaryDirectory() as tmp: with self.assertRaises(ValueError) as ctx: sim.write([], Path(tmp)) self.assertIn("xingbang_data", str(ctx.exception)) def test_write_only_targets_the_ignored_data_root(self): records = sim.generate(seed=7, scale=0.02) with tempfile.TemporaryDirectory() as tmp: target = Path(tmp) / "xingbang_data" / "simulation" path = sim.write(records, target) self.assertTrue(path.exists()) self.assertEqual(len(path.read_text(encoding="utf-8").strip().splitlines()), len(records)) if __name__ == "__main__": unittest.main()