# -*- coding: utf-8 -*- """数值层的测试。只用标准库:python -m unittest discover -s tests""" import glob import json import os import sys import unittest ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(ROOT, "app")) sys.path.insert(0, os.path.join(ROOT, "tests")) import numeric as N # noqa: E402 import mock_laya # noqa: E402 RECIPE = { "subject": "番茄", "measurements": { "ripe": {"label": "成熟色占比", "unit": "%", "decimals": 1, "guard": 2, "bands": [{"max": 30, "label": "未熟"}, {"max": 70, "label": "转色"}, {"label": "成熟"}]}, "spot": {"label": "病斑占比", "unit": "%", "decimals": 1}, "dia": {"label": "果径", "unit": "mm", "decimals": 0, "range": [0, 150]}, "kind": {"label": "识别类别"}, }, "rules": { "pick": {"label": "可采摘", "when": {"all": [{"m": "ripe", "op": ">=", "value": 70}, {"m": "spot", "op": "<=", "value": 3}, {"m": "dia", "op": ">=", "value": 50}]}}, "grade": {"label": "等级", "cases": [ {"value": "A", "when": {"all": [{"m": "ripe", "op": ">=", "value": 85}, {"m": "spot", "op": "<=", "value": 1}]}}, {"value": "B", "when": {"m": "ripe", "op": ">=", "value": 70}}, {"value": "C"}]}, }, "questions": { "pick": {"type": "noul", "instructions": "是否可以采摘?"}, "grade": {"type": "choice", "instructions": "等级?", "criteria": {"A": "", "B": "", "C": ""}}, "free": {"type": "noul", "instructions": "外观是否适合鲜食?"}, }, "bindings": {"pick": {"rule": "pick"}, "grade": {"rule": "grade", "mode": "advise"}}, } def shipped_recipes(): out = [] for lang in ("zh", "en"): for path in sorted(glob.glob(os.path.join(ROOT, "recipes", lang, "*.json"))): with open(path, encoding="utf-8") as handle: out.append((lang, os.path.basename(path), json.load(handle))) return out class CompareTest(unittest.TestCase): def test_operators(self): for op, value, expected in ((">=", 70, True), (">", 70, False), ("<=", 70, True), ("<", 70, False), ("==", 70, True), ("!=", 70, False)): got = N.compare(RECIPE, {"m": "spot", "op": op, "value": 70}, {"spot": 70}) self.assertEqual(got["ok"], expected, op) self.assertTrue(N.compare(RECIPE, {"m": "spot", "op": "between", "value": [1, 3]}, {"spot": 2})["ok"]) self.assertFalse(N.compare(RECIPE, {"m": "spot", "op": "between", "value": [1, 3]}, {"spot": 3.1})["ok"]) self.assertTrue(N.compare(RECIPE, {"m": "kind", "op": "in", "value": ["ripe", "turning"]}, {"kind": "Ripe"})["ok"]) self.assertIsNone(N.compare(RECIPE, {"m": "spot", "op": ">=", "value": 1}, {})["ok"]) def test_guard_band(self): self.assertTrue(N.compare(RECIPE, {"m": "ripe", "op": ">=", "value": 70}, {"ripe": 71.5})["edge"]) self.assertTrue(N.compare(RECIPE, {"m": "ripe", "op": ">=", "value": 70}, {"ripe": 68.2})["edge"]) self.assertFalse(N.compare(RECIPE, {"m": "ripe", "op": ">=", "value": 70}, {"ripe": 75})["edge"]) self.assertFalse(N.compare(RECIPE, {"m": "spot", "op": "<=", "value": 3}, {"spot": 3})["edge"]) # 没设 guard 的测量值没有临界带 def test_rules(self): good = {"ripe": 90, "spot": 0.5, "dia": 60} self.assertTrue(N.run_rule(RECIPE, "pick", good)["result"]) self.assertEqual(N.run_rule(RECIPE, "grade", good)["result"], "A") self.assertEqual(N.run_rule(RECIPE, "grade", dict(good, spot=2))["result"], "B") self.assertEqual(N.run_rule(RECIPE, "grade", dict(good, ripe=40))["result"], "C") self.assertFalse(N.run_rule(RECIPE, "pick", dict(good, dia=49))["result"]) edge = N.run_rule(RECIPE, "pick", dict(good, ripe=71)) self.assertTrue(edge["result"]) self.assertTrue(edge["marginal"]) self.assertFalse(N.run_rule(RECIPE, "pick", dict(good, ripe=71, dia=10))["marginal"]) # 果径已经不合格,成熟度临界不影响结论 missing = N.run_rule(RECIPE, "pick", {"ripe": 90, "spot": 1}) self.assertIsNone(missing["result"]) self.assertEqual(missing["missing"], ["dia"]) def test_not_and_any(self): recipe = {"measurements": {"a": {"guard": 1}, "b": {}}, "rules": {"r": {"when": {"not": {"any": [{"m": "a", "op": ">", "value": 10}, {"m": "b", "op": ">=", "value": 2}]}}}}} self.assertTrue(N.run_rule(recipe, "r", {"a": 5, "b": 1})["result"]) self.assertFalse(N.run_rule(recipe, "r", {"a": 5, "b": 2})["result"]) self.assertTrue(N.run_rule(recipe, "r", {"a": 9.5, "b": 1})["marginal"]) def test_normalize(self): values = N.normalize(RECIPE, {"ripe": 69.96, "dia": 54.5, "kind": "ripe", "_sharpness": 12.345}) self.assertEqual(values["ripe"], 70.0) self.assertEqual(values["kind"], "ripe") self.assertEqual(values["_sharpness"], 12.345) self.assertTrue(N.run_rule(RECIPE, "pick", dict(values, spot=0, dia=60))["result"]) class TextTest(unittest.TestCase): def test_state_mirrors_standard(self): values = {"ripe": 71.2, "spot": 4.7, "dia": 62} for lang in ("zh", "en"): state = N.build_state(RECIPE, values, lang) block = [v for v in state.values() if isinstance(v, dict)][0] questions = N.bound_questions(RECIPE, lang) for rule_id in RECIPE["rules"]: rule = RECIPE["rules"][rule_id] nodes = [rule.get("when")] + [c.get("when") for c in rule.get("cases") or []] for node in nodes: for leaf in N.leaves(node): clause = N.threshold_text(RECIPE, leaf["m"], leaf["op"], leaf["value"]) self.assertIn(clause, block[N.label_of(RECIPE, leaf["m"])]) self.assertIn(clause, questions["pick"]["instructions"] + questions["grade"]["instructions"]) self.assertNotIn(N.tx(lang, "standard"), questions["free"]["instructions"]) def test_phrases(self): text = N.describe(RECIPE, "ripe", 71.2, "zh") self.assertIn("≥ 70 %:是,高出 1.2,临界", text) self.assertIn("≥ 85 %:否,低 13.8", text) self.assertIn("档位:成熟", text) self.assertEqual(N.describe(RECIPE, "ripe", 71.2, "zh", "raw"), "71.2 %") self.assertIn("≤ 3 %: no, 1.7 above", N.describe(RECIPE, "spot", 4.7, "en")) self.assertIn("正好相等", N.describe(RECIPE, "dia", 50, "zh")) self.assertEqual(N.describe(RECIPE, "kind", "ripe", "zh", detail={"prob": 0.934}), "ripe(置信度 0.93)") def test_state_skips_unmeasured_optional(self): state = N.build_state(RECIPE, {"ripe": 80, "spot": 1, "dia": None, "kind": None}, "zh") block = [v for v in state.values() if isinstance(v, dict)][0] self.assertNotIn("识别类别", block) # 规则用不到、又没有值:不写 self.assertIn("果径", block) # 规则要用:写明没有测到 class DecideTest(unittest.TestCase): values = {"ripe": 90, "spot": 0.5, "dia": 60} def test_agree(self): out = N.decide(RECIPE, self.values, {"pick": {"noul": 0.95}, "grade": {"choice": "A"}, "free": {"noul": 0.8}}) self.assertEqual(out["verdicts"]["pick"]["source"], "rule") self.assertTrue(out["verdicts"]["pick"]["agree"]) self.assertEqual(out["verdicts"]["grade"]["source"], "model") # advise:模型为准 self.assertEqual(out["verdicts"]["free"]["source"], "model") self.assertFalse(out["review"]) def test_conflict_rule_wins(self): out = N.decide(RECIPE, self.values, {"pick": {"noul": 0.1}, "grade": {"choice": "A"}, "free": {"noul": 0.8}}) item = out["verdicts"]["pick"] self.assertIs(item["final"], True) self.assertIs(item["agree"], False) self.assertTrue(out["review"]) def test_low_confidence_only_matters_for_model_verdicts(self): answers = {"pick": {"noul": 0.55}, "grade": {"choice": "A", "probabilities": {"A": 0.5, "B": 0.3, "C": 0.2}}, "free": {"noul": 0.9}} out = N.decide(RECIPE, self.values, answers, min_confidence=0.7) self.assertFalse(out["verdicts"]["pick"]["review"]) self.assertTrue(out["verdicts"]["grade"]["review"]) def test_model_down(self): out = N.decide(RECIPE, self.values, None, model_error=True) self.assertIs(out["verdicts"]["pick"]["final"], True) self.assertEqual(out["verdicts"]["grade"]["final"], "A") # advise 模式下模型没答:退回规则 self.assertIsNone(out["verdicts"]["free"]["final"]) self.assertTrue(out["review"]) def test_quality_forces_review(self): quality = N.check_quality({"quality": {"min_sharpness": 50, "brightness": [40, 220]}}, {"_sharpness": 10, "_brightness": 30}) self.assertFalse(quality["ok"]) self.assertEqual(len(quality["problems"]), 2) out = N.decide(RECIPE, self.values, {"pick": {"noul": 0.95}, "grade": {"choice": "A"}, "free": {"noul": 0.8}}, quality=quality) self.assertTrue(out["review"]) class ProbeTest(unittest.TestCase): def test_cases_follow_rules(self): cases = N.probe_cases(RECIPE, limit=48) self.assertGreaterEqual(len(cases), 24) seen = set() for case in cases: self.assertEqual(case["gold"]["pick"], N.run_rule(RECIPE, "pick", case["values"])["result"]) self.assertEqual(case["gold"]["grade"], N.run_rule(RECIPE, "grade", case["values"])["result"]) self.assertLessEqual(case["values"]["ripe"], 100) seen.add((case["gold"]["pick"], case["gold"]["grade"])) self.assertGreaterEqual(len(seen), 4) # 各种结果都有用例 self.assertTrue(any(c["distance"] is not None and c["distance"] <= 0.01 for c in cases)) def test_scoring(self): cases = N.probe_cases(RECIPE, limit=24) perfect = [{"pick": {"noul": 0.9 if c["gold"]["pick"] else 0.1}, "grade": {"choice": c["gold"]["grade"]}} for c in cases] report = N.score_probe(RECIPE, cases, perfect) self.assertEqual(report["pick"]["accuracy"], 1.0) self.assertEqual(report["grade"]["accuracy"], 1.0) always_yes = [{"pick": {"noul": 0.9}, "grade": {"choice": "A"}} for _ in cases] report = N.score_probe(RECIPE, cases, always_yes) self.assertLess(report["pick"]["accuracy"], 1.0) self.assertTrue(report["pick"]["misses"]) def test_finetune_records(self): records = N.finetune_records(RECIPE, N.probe_cases(RECIPE, limit=8)) self.assertTrue(records) self.assertEqual(set(records[0]), {"state", "questions", "gold"}) self.assertEqual(set(records[0]["gold"]), set(records[0]["questions"])) class ShippedRecipesTest(unittest.TestCase): def test_valid(self): recipes = shipped_recipes() self.assertEqual(len(recipes), 10) for lang, name, recipe in recipes: self.assertEqual(N.validate(recipe), [], "%s/%s" % (lang, name)) self.assertTrue(N.probe_cases(recipe, limit=16), "%s/%s has no probe cases" % (lang, name)) def test_same_structure_in_both_languages(self): by_file = {} for lang, name, recipe in shipped_recipes(): by_file.setdefault(name, {})[lang] = recipe for name, pair in by_file.items(): self.assertEqual(json.dumps(pair["zh"]["pipeline"], sort_keys=True), json.dumps(pair["en"]["pipeline"], sort_keys=True), name) self.assertEqual(list(pair["zh"]["measurements"]), list(pair["en"]["measurements"]), name) self.assertEqual(list(pair["zh"]["questions"]), list(pair["en"]["questions"]), name) def test_written_comparisons_are_machine_readable(self): """素材里的比较结论和题目里的判定标准必须能逐条对上:用一个只会查字面的假模型来验证。""" for lang, name, recipe in shipped_recipes(): cases = N.probe_cases(recipe, limit=32) bound = {q: s for q, s in N.bound_questions(recipe, lang).items() if q in (recipe.get("bindings") or {})} answers = [mock_laya.predict(N.build_state(recipe, c["values"], lang, "compared"), bound)["answers"] for c in cases] report = N.score_probe(recipe, cases, answers) for qid, row in report.items(): self.assertEqual(row["accuracy"], 1.0, "%s/%s %s: %s" % (lang, name, qid, row["misses"][:1])) raw = [mock_laya.predict(N.build_state(recipe, c["values"], lang, "raw"), bound)["answers"] for c in cases] worst = min(row["accuracy"] for row in N.score_probe(recipe, cases, raw).values()) self.assertLess(worst, 1.0, "%s/%s: bare numbers should not be readable by a literal matcher" % (lang, name)) class ValidateTest(unittest.TestCase): def test_catches_mistakes(self): bad = json.loads(json.dumps(RECIPE)) bad["rules"]["pick"]["when"]["all"][0]["m"] = "nope" bad["bindings"]["free"] = {"rule": "missing"} bad["bindings"]["grade"] = {"rule": "pick"} problems = "\n".join(N.validate(bad)) self.assertIn("unknown measurement 'nope'", problems) self.assertIn("unknown rule 'missing'", problems) self.assertIn("returns yes/no", problems) if __name__ == "__main__": unittest.main()