mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
9469fc4d07
With the manifest present but pyyaml unimportable, _load_manifest returned None and main() misdiagnosed it as "manifest ... empty / null / non-mapping", pointing the reader at the wrong file. The import failure is now a distinct _YamlUnavailableError; the lint exits 1 naming pyyaml and the requirements-dev.txt remedy. Regression test pins the message (45 tests, all green; lint itself still passes). Also de-enumerate the stale "(PyYAML + jsonschema ...)" dependency parenthetical in docs/SETUP.md and docs/SETUP.zh-TW.md (both language files together, per bilingual-parity discipline). Claude-Session: https://claude.ai/code/session_013R81d1YwGvJAznkPKk9gNw Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
441 lines
22 KiB
Python
441 lines
22 KiB
Python
"""Tests for check_surface_form_parity.py (#216).
|
|
|
|
Three layers:
|
|
1. shipped gold set passes,
|
|
2. mutation tests — each integrity/provenance/pair invariant goes RED when broken (non-vacuous),
|
|
3. serializer-strip tests — render_judge_view leaks NOTHING the §F.3.6 decision keeps blind
|
|
(the #216 decision-#2 enforcement point: framing_style / provenance_type / verdict labels /
|
|
nested authorship signal all absent from the judge's view).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from scripts import check_surface_form_parity as csp
|
|
|
|
|
|
def _load() -> dict:
|
|
return json.loads(csp.DEFAULT_GOLD_SET.read_text(encoding="utf-8"))
|
|
|
|
|
|
def _manifest() -> dict | None:
|
|
return csp._load_manifest(csp.DEFAULT_MANIFEST)
|
|
|
|
|
|
class TestShipped(unittest.TestCase):
|
|
def test_shipped_gold_set_passes(self) -> None:
|
|
self.assertEqual(csp.validate(_load(), _manifest()), [], msg="shipped gold set should be clean")
|
|
|
|
|
|
class TestMutations(unittest.TestCase):
|
|
"""Each test breaks ONE invariant in a deep copy and asserts a matching violation."""
|
|
|
|
def setUp(self) -> None:
|
|
self.data = _load()
|
|
self.manifest = _manifest()
|
|
|
|
def _v(self, data: dict, manifest: dict | None = ...) -> list[str]: # type: ignore[assignment]
|
|
return csp.validate(data, self.manifest if manifest is ... else manifest)
|
|
|
|
def test_wrong_task_type_fails(self) -> None:
|
|
self.data["metadata"]["task_type"] = "detector"
|
|
self.assertTrue(any("task_type" in e for e in self._v(self.data)))
|
|
|
|
def test_missing_judge_blind_entry_fails(self) -> None:
|
|
self.data["metadata"]["judge_blind_fields"] = ["pair_id"] # drop the rest
|
|
self.assertTrue(any("judge_blind_fields missing" in e for e in self._v(self.data)))
|
|
|
|
def test_dropping_verdict_label_from_blind_fields_fails(self) -> None:
|
|
"""codex P2: an answer-leaking label (expert_verdict / meta_reviewer_verdict /
|
|
asymmetry_direction) dropped from judge_blind_fields must fail — else the serializer-strip
|
|
test (which reads the same list) silently stops checking it too."""
|
|
for leaky in ("expert_verdict", "meta_reviewer_verdict", "asymmetry_direction"):
|
|
data = _load()
|
|
data["metadata"]["judge_blind_fields"] = [
|
|
f for f in data["metadata"]["judge_blind_fields"] if f != leaky
|
|
]
|
|
errors = csp.validate(data, self.manifest)
|
|
self.assertTrue(
|
|
any("judge_blind_fields missing" in e for e in errors),
|
|
msg=f"dropping {leaky} should fail: {errors!r}",
|
|
)
|
|
|
|
def test_duplicate_id_fails(self) -> None:
|
|
self.data["items"].append(copy.deepcopy(self.data["items"][0]))
|
|
self.assertTrue(any("duplicate id" in e for e in self._v(self.data)))
|
|
|
|
def test_bad_framing_style_fails(self) -> None:
|
|
self.data["items"][0]["framing_style"] = "neutral"
|
|
self.assertTrue(any("framing_style=" in e for e in self._v(self.data)))
|
|
|
|
def test_bad_provenance_type_fails(self) -> None:
|
|
self.data["items"][0]["provenance_type"] = "made_up"
|
|
self.assertTrue(any("provenance_type=" in e for e in self._v(self.data)))
|
|
|
|
def test_bad_expected_correctness_fails(self) -> None:
|
|
self.data["items"][0]["expected_correctness"] = "Maybe"
|
|
self.assertTrue(any("expected_correctness=" in e for e in self._v(self.data)))
|
|
|
|
def test_paper_verbatim_without_anchor_fails(self) -> None:
|
|
# find a paper_verbatim item, blank its anchor
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "paper_verbatim":
|
|
it["provenance"]["verbatim_anchor"] = ""
|
|
break
|
|
self.assertTrue(any("verbatim_anchor missing" in e for e in self._v(self.data)))
|
|
|
|
def test_paper_verbatim_with_derived_from_fails(self) -> None:
|
|
"""codex P2 round 11: a maintainer marker (derived_from) on a paper_verbatim item must
|
|
fail — a relabeled -cf rewrite must not be certified as first-party."""
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "paper_verbatim":
|
|
it["derived_from"] = "sfp-002"
|
|
break
|
|
self.assertTrue(any("must NOT carry derived_from" in e for e in self._v(self.data)))
|
|
|
|
def test_paper_verbatim_with_maintainer_source_fails(self) -> None:
|
|
"""codex P2 round 11: a paper_verbatim must have a human/ai reviewer_source, not a
|
|
maintainer source."""
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "paper_verbatim":
|
|
it["provenance"]["reviewer_source"] = "maintainer_rewrite"
|
|
break
|
|
self.assertTrue(any("must be 'human'" in e for e in self._v(self.data)))
|
|
|
|
def test_counterfactual_claiming_verbatim_anchor_fails(self) -> None:
|
|
"""Provenance honesty (P1.5): a counterfactual must NOT carry a verbatim_anchor."""
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "counterfactual_rewrite":
|
|
it["provenance"]["verbatim_anchor"] = "fake quote"
|
|
break
|
|
self.assertTrue(any("must NOT carry a verbatim_anchor" in e for e in self._v(self.data)))
|
|
|
|
def test_counterfactual_missing_derived_from_fails(self) -> None:
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "counterfactual_rewrite":
|
|
del it["derived_from"]
|
|
break
|
|
self.assertTrue(any("derived_from" in e for e in self._v(self.data)))
|
|
|
|
def test_counterfactual_non_string_derived_from_fails(self) -> None:
|
|
"""codex P2 round 9: derived_from: null/false must fail — a str()-coerced presence check
|
|
would pass it while the source-invariant loop skips non-strings, letting it escape."""
|
|
for bad in (None, False, ["sfp-001"]):
|
|
data = _load()
|
|
for it in data["items"]:
|
|
if it["provenance_type"] == "counterfactual_rewrite":
|
|
it["derived_from"] = bad
|
|
break
|
|
errors = csp.validate(data, self.manifest)
|
|
self.assertTrue(
|
|
any("non-empty string derived_from" in e for e in errors),
|
|
msg=f"derived_from={bad!r} should fail: {errors!r}",
|
|
)
|
|
|
|
def test_maintainer_boundary_missing_mechanism_anchor_fails(self) -> None:
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "maintainer_boundary":
|
|
it["provenance"]["mechanism_anchor"] = ""
|
|
break
|
|
self.assertTrue(any("mechanism_anchor" in e for e in self._v(self.data)))
|
|
|
|
def test_counterfactual_with_human_source_fails(self) -> None:
|
|
"""codex P2 round 12: a maintainer-authored counterfactual must carry maintainer_rewrite,
|
|
not human/ai — else a synthetic rewrite gets certified source-authored."""
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "counterfactual_rewrite":
|
|
it["provenance"]["reviewer_source"] = "human"
|
|
break
|
|
self.assertTrue(any("must be 'maintainer_rewrite'" in e for e in self._v(self.data)))
|
|
|
|
def test_boundary_with_ai_source_fails(self) -> None:
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "maintainer_boundary":
|
|
it["provenance"]["reviewer_source"] = "ai"
|
|
break
|
|
self.assertTrue(any("must be 'maintainer_synthetic'" in e for e in self._v(self.data)))
|
|
|
|
def test_null_mechanism_anchor_fails(self) -> None:
|
|
"""codex P2 round 12: a null mechanism_anchor must fail — str(None) would read as present."""
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "maintainer_boundary":
|
|
it["provenance"]["mechanism_anchor"] = None
|
|
break
|
|
self.assertTrue(any("non-empty string provenance.mechanism_anchor" in e for e in self._v(self.data)))
|
|
|
|
def test_null_exception_reason_fails(self) -> None:
|
|
"""codex P2 round 12: a null exception_reason on an exception item must fail — str(None)
|
|
would otherwise read as a present reason."""
|
|
for it in self.data["items"]:
|
|
if it.get("exception") is True:
|
|
it["exception_reason"] = None
|
|
break
|
|
self.assertTrue(any("exception_reason missing" in e for e in self._v(self.data)))
|
|
|
|
def test_pdftotext_line_anchor_fails(self) -> None:
|
|
self.data["items"][0]["provenance"]["section"] = "§F.3.6 L4753"
|
|
self.assertTrue(any("pdftotext line-number anchor" in e for e in self._v(self.data)))
|
|
|
|
def test_pdftotext_plural_range_anchor_fails(self) -> None:
|
|
"""codex P2: the common 'lines 4753-4756' plural+range form must also be rejected."""
|
|
self.data["items"][0]["provenance"]["section"] = "§F.3.6 lines 4753-4756"
|
|
self.assertTrue(any("pdftotext line-number anchor" in e for e in self._v(self.data)))
|
|
|
|
def test_paper_verbatim_anchor_not_in_review_text_fails(self) -> None:
|
|
"""codex P2: a paper_verbatim whose anchor is NOT a substring of the (possibly paraphrased)
|
|
review_item_text must fail — otherwise maintainer text gets a paper-verbatim label."""
|
|
for it in self.data["items"]:
|
|
if it["provenance_type"] == "paper_verbatim":
|
|
it["review_item_text"] = "a totally paraphrased concern with none of the quote"
|
|
break
|
|
self.assertTrue(
|
|
any("not a substring of" in e for e in self._v(self.data)),
|
|
msg=f"{self._v(self.data)!r}",
|
|
)
|
|
|
|
def test_pair_claim_drift_fails(self) -> None:
|
|
"""A pair whose members hold different canonical_claim must fail."""
|
|
for it in self.data["items"]:
|
|
if it.get("pair_id") == "pair-01" and it["id"].endswith("-cf"):
|
|
it["canonical_claim"] = "a totally different claim"
|
|
break
|
|
self.assertTrue(any("different canonical_claim" in e for e in self._v(self.data)))
|
|
|
|
def test_pair_verdict_drift_fails(self) -> None:
|
|
"""A pair whose members hold different expected_correctness must fail — framing must NOT
|
|
change the expected verdict."""
|
|
for it in self.data["items"]:
|
|
if it.get("pair_id") == "pair-01" and it["id"].endswith("-cf"):
|
|
it["expected_correctness"] = "Not Correct"
|
|
break
|
|
self.assertTrue(any("different expected_correctness" in e for e in self._v(self.data)))
|
|
|
|
def test_pair_same_style_fails(self) -> None:
|
|
"""A pair whose members share framing_style is not a contrast pair."""
|
|
for it in self.data["items"]:
|
|
if it.get("pair_id") == "pair-01" and it["id"].endswith("-cf"):
|
|
# set to match its partner's style
|
|
it["framing_style"] = "informal_vague"
|
|
break
|
|
self.assertTrue(any("share framing_style" in e for e in self._v(self.data)))
|
|
|
|
def test_lonely_pair_member_fails(self) -> None:
|
|
"""A pair_id with only one member must fail."""
|
|
for it in self.data["items"]:
|
|
if it["id"] == "sfp-001-cf":
|
|
it["pair_id"] = None
|
|
break
|
|
self.assertTrue(any("must have exactly 2" in e for e in self._v(self.data)))
|
|
|
|
def test_exception_without_reason_fails(self) -> None:
|
|
for it in self.data["items"]:
|
|
if it.get("exception") is True:
|
|
it["exception_reason"] = ""
|
|
break
|
|
self.assertTrue(any("exception_reason missing" in e for e in self._v(self.data)))
|
|
|
|
def test_ambiguous_id_without_flag_fails(self) -> None:
|
|
"""An id ending -ambiguous must keep exception=true, else it reverts to a clean case."""
|
|
for it in self.data["items"]:
|
|
if it["id"].endswith("-ambiguous"):
|
|
it["exception"] = False
|
|
it["exception_reason"] = ""
|
|
break
|
|
self.assertTrue(any("must stay marked" in e for e in self._v(self.data)))
|
|
|
|
def test_dangling_derived_from_fails(self) -> None:
|
|
for it in self.data["items"]:
|
|
if it.get("derived_from"):
|
|
it["derived_from"] = "sfp-nonexistent"
|
|
break
|
|
self.assertTrue(any("does not match any item id" in e for e in self._v(self.data)))
|
|
|
|
def test_counterfactual_deriving_from_non_paper_source_fails(self) -> None:
|
|
"""codex P2: a rewrite must derive from a paper_verbatim item, not another maintainer
|
|
item. Point a counterfactual's derived_from at the maintainer_boundary item (matching its
|
|
claim/verdict/framing so only the provenance check can catch it)."""
|
|
boundary = next(it for it in self.data["items"] if it["provenance_type"] == "maintainer_boundary")
|
|
cf = next(it for it in self.data["items"] if it["provenance_type"] == "counterfactual_rewrite")
|
|
# align claim/verdict/framing so ONLY the paper_verbatim-source check fires
|
|
cf["derived_from"] = boundary["id"]
|
|
cf["canonical_claim"] = boundary["canonical_claim"]
|
|
cf["expected_correctness"] = boundary["expected_correctness"]
|
|
cf["framing_style"] = "technical_precise" if boundary["framing_style"] == "informal_vague" else "informal_vague"
|
|
errors = self._v(self.data)
|
|
self.assertTrue(
|
|
any("must derive from a paper_verbatim item" in e for e in errors),
|
|
msg=f"non-paper derived_from should fail: {errors!r}",
|
|
)
|
|
|
|
def test_counterfactual_drift_caught_even_without_pair_id(self) -> None:
|
|
"""codex P2: a counterfactual that loses its pair_id (and whose manifest pair is dropped)
|
|
must STILL be validated against its derived_from source — the pair loop would miss it, but
|
|
the derived_from invariant check catches the claim/verdict/framing drift."""
|
|
# drop the pair so the pair-invariant loop never runs for this rewrite...
|
|
for it in self.data["items"]:
|
|
if it["id"] == "sfp-001-cf":
|
|
it["pair_id"] = None
|
|
it["expected_correctness"] = "Not Correct" # drift away from the source verdict
|
|
break
|
|
for it in self.data["items"]:
|
|
if it["id"] == "sfp-001":
|
|
it["pair_id"] = None
|
|
break
|
|
man = copy.deepcopy(self.manifest)
|
|
man["pairs"] = [p for p in man["pairs"] if p["pair_id"] != "pair-01"]
|
|
errors = self._v(self.data, man)
|
|
self.assertTrue(
|
|
any("expected_correctness differs from its derived_from" in e for e in errors),
|
|
msg=f"drift must be caught via derived_from even without pair_id: {errors!r}",
|
|
)
|
|
|
|
def test_manifest_sample_n_drift_fails(self) -> None:
|
|
man = copy.deepcopy(self.manifest)
|
|
man["sample_n"] = 99
|
|
self.assertTrue(any("sample_n" in e for e in self._v(self.data, man)))
|
|
|
|
def test_manifest_provenance_dist_drift_fails(self) -> None:
|
|
man = copy.deepcopy(self.manifest)
|
|
man["provenance_distribution"] = [{"provenance_type": "paper_verbatim", "n": 99}]
|
|
self.assertTrue(any("provenance_distribution" in e for e in self._v(self.data, man)))
|
|
|
|
|
|
class TestMainGuards(unittest.TestCase):
|
|
def test_missing_manifest_makes_main_fail(self) -> None:
|
|
"""codex P2 round 7: a missing manifest must FAIL the lint, not silently skip the
|
|
agreement checks — otherwise a deleted/renamed manifest (fixture undiscoverable by
|
|
run_evals) passes CI."""
|
|
rc = csp.main([str(csp.DEFAULT_GOLD_SET), "--manifest", "/tmp/does-not-exist-216.yaml"])
|
|
self.assertEqual(rc, 1)
|
|
|
|
def test_empty_manifest_makes_main_fail(self) -> None:
|
|
"""codex P2 round 8: a present-but-empty/null manifest must FAIL too — yaml null returns
|
|
None and would silently disable all agreement checks (and crash run_evals later)."""
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
|
|
f.write("\n") # empty -> yaml.safe_load returns None
|
|
path = f.name
|
|
rc = csp.main([str(csp.DEFAULT_GOLD_SET), "--manifest", path])
|
|
Path(path).unlink(missing_ok=True)
|
|
self.assertEqual(rc, 1)
|
|
|
|
def test_non_mapping_manifest_fails_without_traceback(self) -> None:
|
|
"""codex P3 round 9: a YAML scalar/list manifest must produce a lint error (rc 1), NOT an
|
|
AttributeError traceback from validate() calling manifest.get on a non-dict."""
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as f:
|
|
f.write("- just\n- a\n- list\n") # YAML list -> non-dict
|
|
path = f.name
|
|
try:
|
|
rc = csp.main([str(csp.DEFAULT_GOLD_SET), "--manifest", path]) # must not raise
|
|
finally:
|
|
Path(path).unlink(missing_ok=True)
|
|
self.assertEqual(rc, 1)
|
|
|
|
def test_missing_pyyaml_fails_naming_the_environment_not_the_manifest(self) -> None:
|
|
"""#801 follow-up: with the manifest PRESENT but pyyaml unimportable, the lint must
|
|
fail (rc 1) naming the broken environment — the old None return made main() report
|
|
the manifest as "empty / null / non-mapping", sending the reader to the wrong file."""
|
|
import contextlib
|
|
import io
|
|
import sys as _sys
|
|
_missing = object()
|
|
saved = _sys.modules.get("yaml", _missing)
|
|
_sys.modules["yaml"] = None # type: ignore[assignment] # forces `import yaml` to raise
|
|
stderr = io.StringIO()
|
|
try:
|
|
with contextlib.redirect_stderr(stderr):
|
|
rc = csp.main([str(csp.DEFAULT_GOLD_SET)])
|
|
finally:
|
|
if saved is _missing:
|
|
_sys.modules.pop("yaml", None)
|
|
else:
|
|
_sys.modules["yaml"] = saved # type: ignore[assignment]
|
|
self.assertEqual(rc, 1)
|
|
self.assertIn("pyyaml is not installed", stderr.getvalue())
|
|
self.assertNotIn("non-mapping", stderr.getvalue())
|
|
|
|
def test_shipped_main_passes(self) -> None:
|
|
self.assertEqual(csp.main([str(csp.DEFAULT_GOLD_SET)]), 0)
|
|
|
|
|
|
class TestSerializerStrip(unittest.TestCase):
|
|
"""The #216 decision-#2 enforcement point: render_judge_view must leak NOTHING blind."""
|
|
|
|
def setUp(self) -> None:
|
|
self.items = _load()["items"]
|
|
|
|
def test_judge_view_only_whitelisted_keys(self) -> None:
|
|
allowed = {"handle", csp.JUDGE_VISIBLE_CONTENT_KEY}
|
|
for i, it in enumerate(self.items):
|
|
view = csp.render_judge_view(it, i)
|
|
self.assertEqual(
|
|
set(view.keys()) - allowed,
|
|
set(),
|
|
msg=f"judge view for {it['id']} leaked non-whitelisted keys: {view.keys()}",
|
|
)
|
|
|
|
def test_judge_view_strips_all_blind_fields(self) -> None:
|
|
blind = set(_load()["metadata"]["judge_blind_fields"])
|
|
for i, it in enumerate(self.items):
|
|
view = csp.render_judge_view(it, i)
|
|
leaked = blind & set(view.keys())
|
|
self.assertEqual(leaked, set(), msg=f"judge view for {it['id']} leaked blind fields: {leaked}")
|
|
|
|
def test_judge_view_does_not_leak_semantic_id(self) -> None:
|
|
"""The fixture id encodes the answer (`-cf` = counterfactual, `-ambiguous` = expected
|
|
boundary verdict). It must NOT appear in the judge view — the handle is DERIVED INTERNALLY
|
|
from the index, so even the caller cannot inject the id. (codex review P2.)"""
|
|
for i, it in enumerate(self.items):
|
|
view = csp.render_judge_view(it, i)
|
|
serialized = json.dumps(view)
|
|
self.assertNotIn(it["id"], serialized, msg=f"{it['id']}: semantic id leaked into judge view")
|
|
for suffix in ("-cf", "-ambiguous"):
|
|
self.assertNotIn(suffix, serialized, msg=f"{it['id']}: leaked id suffix {suffix}")
|
|
# the handle is opaque and derived from the index, NOT caller-controlled
|
|
self.assertEqual(view["handle"], f"item-{i}")
|
|
|
|
def test_judge_view_rejects_non_int_index(self) -> None:
|
|
"""codex P2 round 10: a string/bool index would be interpolated into the handle and leak
|
|
an answer-encoding suffix. The helper must raise (a runtime check, not an -O-stripped
|
|
assert) instead of producing a leaky handle."""
|
|
sample = self.items[0]
|
|
for bad in ("sfp-001-cf", "pair-01", True, 1.0):
|
|
with self.assertRaises(TypeError, msg=f"index={bad!r} should raise"):
|
|
csp.render_judge_view(sample, bad)
|
|
|
|
def test_judge_handle_is_index_derived_not_caller_controlled(self) -> None:
|
|
"""The helper derives the handle from the index; it does not accept an arbitrary string.
|
|
Passing an int index always yields item-<index>, so a caller cannot smuggle the fixture
|
|
id in as the handle (the codex-P2 leak path is closed at the API level)."""
|
|
sample = self.items[0]
|
|
self.assertEqual(csp.render_judge_view(sample, 3)["handle"], "item-3")
|
|
# the answer-encoding id is never echoed regardless of which item is rendered
|
|
for i, it in enumerate(self.items):
|
|
if it["id"].endswith(("-cf", "-ambiguous")):
|
|
self.assertNotIn(it["id"], json.dumps(csp.render_judge_view(it, i)))
|
|
|
|
def test_judge_view_strips_nested_authorship_signal(self) -> None:
|
|
"""The nested provenance.reviewer_source (human/ai/maintainer_rewrite) is the author
|
|
label §F.3.6 is built to hide. It must not appear anywhere in the serialized view."""
|
|
for i, it in enumerate(self.items):
|
|
view = csp.render_judge_view(it, i)
|
|
serialized = json.dumps(view)
|
|
self.assertNotIn("reviewer_source", serialized, msg=f"{it['id']}: leaked reviewer_source")
|
|
for label in ("\"human\"", "\"ai\"", "maintainer_rewrite", "maintainer_synthetic"):
|
|
self.assertNotIn(label, serialized, msg=f"{it['id']}: leaked author label {label}")
|
|
|
|
def test_judge_view_keeps_the_item_to_judge(self) -> None:
|
|
"""Strip must not be so aggressive it removes the thing being evaluated."""
|
|
for i, it in enumerate(self.items):
|
|
view = csp.render_judge_view(it, i)
|
|
self.assertIn("review_item_text", view, msg=f"{it['id']}: judge view lost review_item_text")
|
|
self.assertTrue(view["review_item_text"].strip())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|