Files
AV-Watch/tests/test_core.py
T

397 lines
17 KiB
Python
Raw Normal View History

"""Offline tests for the logic that decides what the team is told.
Run with: python -m unittest discover -s tests -v
No network, no API key. These cover version comparison, evidence
classification and the scoring rules, because those are the parts that can
break silently and still produce a confident-looking report.
"""
from __future__ import annotations
import unittest
from datetime import timedelta
from avwatch.config import PolicyConfig
from avwatch.models import Evidence, Item, VersionCheck, utcnow
from avwatch.risk import heuristics
from avwatch.risk.classify import classify, relevance, severity
from avwatch.risk.collectors import clean_body, rank_and_filter
from avwatch.versions import bump_kind, compare, parse, pick_latest
class TestVersionParsing(unittest.TestCase):
def test_plain_versions(self):
self.assertEqual(parse("10.4.1").parts, (10, 4, 1))
self.assertEqual(parse("16.4").parts, (16, 4))
self.assertEqual(parse("v7.16.3").parts, (7, 16, 3))
self.assertEqual(parse("Version 2.2.5").parts, (2, 2, 5))
self.assertEqual(parse("firmware 9.0.2").parts, (9, 0, 2))
def test_invalid(self):
for bad in ("", None, "unknown", "latest", "n/a"):
self.assertFalse(parse(bad).valid, f"{bad!r} should not parse")
def test_ordering_across_widths(self):
# 10.4 vs 10.4.1 -- the classic off-by-one-segment trap
self.assertTrue(parse("10.4") < parse("10.4.1"))
self.assertTrue(parse("10.10") > parse("10.9"))
self.assertTrue(parse("2.0") > parse("1.99.99"))
self.assertEqual(parse("16.4"), parse("16.4.0"))
def test_prerelease_ranks_below_final(self):
self.assertTrue(parse("5.0.0-beta.2") < parse("5.0.0"))
self.assertTrue(parse("5.0.0-rc1") < parse("5.0.0"))
self.assertTrue(parse("5.0.0-alpha1") < parse("5.0.0-beta1"))
def test_compare(self):
self.assertEqual(compare("10.2", "10.4.1"), "behind")
self.assertEqual(compare("10.4.1", "10.4.1"), "current")
self.assertEqual(compare("10.5", "10.4.1"), "ahead")
self.assertEqual(compare(None, "10.4.1"), "unknown")
self.assertEqual(compare("garbage", "10.4.1"), "unknown")
def test_bump_kind(self):
self.assertEqual(bump_kind("31.0.1", "32.2.2"), "major")
self.assertEqual(bump_kind("10.2", "10.4.1"), "minor")
self.assertEqual(bump_kind("12.4.0", "12.4.1"), "patch")
self.assertEqual(bump_kind("12.4", "12.4"), "none")
def test_pick_latest_ignores_prereleases(self):
self.assertEqual(pick_latest(["7.15", "7.16.3", "7.14.1"]), "7.16.3")
self.assertEqual(pick_latest(["5.0.0", "5.1.0-beta.1"]), "5.0.0")
self.assertIsNone(pick_latest(["none", ""]))
class TestClassification(unittest.TestCase):
def test_brick_beats_crash(self):
sev, _ = severity("Device bricked after firmware update, crashes too")
self.assertEqual(sev, "brick")
def test_severity_tiers(self):
cases = [
("My ATEM won't boot after the update", "brick"),
("Console crashes randomly since 3.2", "crash"),
("No audio on channel 5 after update", "crash"),
("Had to roll back, this broke my presets", "regression"),
("Minor UI glitch in the new build", "annoyance"),
("New firmware released today", "info"),
]
for text, want in cases:
got, _ = severity(text)
self.assertEqual(got, want, f"{text!r} -> {got}, wanted {want}")
def test_howto_question_downgraded(self):
# Somebody asking for help is not a report that this release breaks.
for title in ("How do I fix a crash on startup?",
"Is it safe to update to 10.4.1?",
"Should I upgrade, anyone had problems?"):
_, sent, _ = classify(title)
self.assertNotEqual(sent, "negative", f"{title!r} should be softened")
def test_symptom_report_keeps_negative(self):
# A real failure report that happens to end in a question mark must
# NOT be softened -- that is the signal we are looking for.
for title in ("My ATEM won't boot after the update, what do I do?",
"Console crashes every time since 3.2 - anyone else?"):
_, sent, _ = classify(title)
self.assertEqual(sent, "negative", f"{title!r} should stay negative")
def test_sentiment(self):
_, sent, _ = classify("Avoid this update, it broke everything")
self.assertEqual(sent, "negative")
_, sent, _ = classify("Running 10.4.1, rock solid, no issues")
self.assertEqual(sent, "positive")
def test_relevance_rejects_offtopic(self):
terms = ["ATEM", "Constellation", "Blackmagic"]
on = relevance("ATEM Constellation firmware problem", terms, "10.4.1")
off = relevance("SKR1.4T 3D printer firmware upload", terms, "10.4.1")
self.assertGreater(on, 0.5)
self.assertLess(off, 0.34)
def test_version_mention_lifts_partial_relevance(self):
# relevance() saturates at 1.0 once every term matches, so the version
# boost is only observable when term coverage is partial. Ranking by
# version-specificity is handled separately by mentions_version --
# see TestEvidenceRanking.test_version_specific_ranks_first.
terms = ["Blackmagic", "Converters", "UpDownCross", "2110"]
with_v = relevance("Blackmagic 12.4 update", terms, "12.4")
without = relevance("Blackmagic update", terms, "12.4")
self.assertGreater(with_v, without)
self.assertLessEqual(with_v, 1.0)
class TestEvidenceRanking(unittest.TestCase):
def _ev(self, title, sev="crash", sent="negative", snippet=""):
return Evidence(source="github-issues", title=title, url=f"http://x/{title}",
snippet=snippet, severity=sev, sentiment=sent)
def test_offtopic_filtered_out(self):
evidence = [
self._ev("ATEM Constellation 10.4.1 crashes on boot"),
self._ev("Razer mouse firmware issue"),
]
kept = rank_and_filter(evidence, ["ATEM", "Constellation"], "10.4.1", limit=5)
self.assertEqual(len(kept), 1)
self.assertIn("ATEM", kept[0].title)
def test_version_specific_ranks_first(self):
evidence = [
self._ev("General crash report", sev="crash"),
self._ev("OBS Studio 32.2.2 profile import fails", sev="regression"),
]
kept = rank_and_filter(evidence, ["OBS", "Studio"], "32.2.2", limit=5)
self.assertTrue(kept[0].mentions_version)
def test_deduplicates_by_url(self):
a = self._ev("OBS Studio crash")
b = self._ev("OBS Studio crash")
kept = rank_and_filter([a, b], ["OBS", "Studio"], None, limit=5)
self.assertEqual(len(kept), 1)
def test_clean_body_strips_issue_template(self):
raw = ("### Operating System Info\nWindows 11\n\n### OBS Studio Version\n"
"32.2.2\n\n### Expected Behavior\n_No response_\n\n"
"```\nsome log\n```")
out = clean_body(raw)
self.assertNotIn("###", out)
self.assertNotIn("_No response_", out)
# The version must survive, or version matching breaks.
self.assertIn("32.2.2", out)
class TestHeuristics(unittest.TestCase):
def setUp(self):
self.policy = PolicyConfig()
def _item(self, **kw):
base = dict(id="test", name="Test Device", vendor="TestCo",
category="video", installed="1.0")
base.update(kw)
return Item(**base)
def _check(self, latest="2.0", age_days=None):
released = utcnow() - timedelta(days=age_days) if age_days is not None else None
return VersionCheck(item_id="test", latest=latest, released=released,
status="ok")
def _score(self, item, check, evidence=None, comparison="behind", bump=None):
if bump is None:
bump = bump_kind(item.installed, check.latest)
return heuristics.score(item, check, comparison, bump,
evidence or [], self.policy)
def test_freeze_always_avoid(self):
v = self._score(self._item(freeze=True), self._check())
self.assertEqual(v.recommendation, "avoid")
self.assertEqual(v.risk_score, 100.0)
def test_up_to_date_is_go(self):
item = self._item(installed="2.0")
v = self._score(item, self._check("2.0"), comparison="current")
self.assertEqual(v.recommendation, "go")
def test_missing_baseline_asks_for_it(self):
item = self._item(installed=None)
v = self._score(item, self._check("2.0"), comparison="unknown")
self.assertEqual(v.recommendation, "manual-check")
self.assertTrue(any("set-installed" in r for r in v.reasons))
def test_error_status_is_manual_check(self):
check = VersionCheck(item_id="test", status="error", detail="page changed")
v = self._score(self._item(), check, comparison="unknown")
self.assertEqual(v.recommendation, "manual-check")
def test_fresh_release_on_showstopper_waits(self):
item = self._item(criticality="showstopper", installed="1.9")
v = self._score(item, self._check("2.0", age_days=1))
self.assertIn(v.recommendation, ("wait", "avoid"))
self.assertIsNotNone(v.suggested_wait_days)
def test_aged_patch_on_low_criticality_is_go(self):
item = self._item(criticality="low", installed="1.0.1")
v = self._score(item, self._check("1.0.2", age_days=120))
self.assertEqual(v.recommendation, "go")
def test_brick_report_naming_version_forces_avoid(self):
item = self._item(criticality="high", installed="1.9")
ev = Evidence(source="reddit", title="2.0 bricked my unit", url="http://x",
severity="brick", sentiment="negative", mentions_version=True)
v = self._score(item, self._check("2.0", age_days=90), [ev])
self.assertEqual(v.recommendation, "avoid")
def test_generic_reports_discounted_vs_version_specific(self):
"""The calibration that matters: every active project has open crash
reports. Only ones naming the release are evidence about it."""
item = self._item(criticality="high", installed="1.9")
check = self._check("2.0", age_days=90)
generic = [Evidence(source="github-issues", title="crashes sometimes",
url=f"http://x/{i}", severity="crash",
sentiment="negative", mentions_version=False)
for i in range(4)]
specific = [Evidence(source="github-issues", title="2.0 crashes on load",
url=f"http://y/{i}", severity="crash",
sentiment="negative", mentions_version=True)
for i in range(4)]
v_generic = self._score(item, check, generic)
v_specific = self._score(item, check, specific)
self.assertLess(v_generic.risk_score, v_specific.risk_score)
self.assertTrue(any("background noise" in r for r in v_generic.reasons))
def test_criticality_raises_score(self):
check = self._check("2.0", age_days=5)
low = self._score(self._item(criticality="low", installed="1.9"), check)
crit = self._score(self._item(criticality="showstopper", installed="1.9"), check)
self.assertGreater(crit.risk_score, low.risk_score)
def test_dot_zero_release_penalised(self):
item = self._item(criticality="normal", installed="1.9")
v = self._score(item, self._check("2.0", age_days=60))
self.assertTrue(any(".0 release" in r for r in v.reasons))
def test_security_fix_lowers_risk(self):
item = self._item(criticality="normal", installed="1.9.1")
plain = self._check("1.9.2", age_days=5)
secure = self._check("1.9.2", age_days=5)
secure.release_notes = "Fixes a security vulnerability (CVE-2026-1234)."
self.assertLess(
self._score(item, secure).risk_score,
self._score(item, plain).risk_score,
)
def test_no_evidence_is_not_treated_as_safe(self):
item = self._item(criticality="normal", installed="1.9")
v = self._score(item, self._check("1.9.5", age_days=60), [])
self.assertTrue(any("absence of reports" in r for r in v.reasons))
def test_scores_stay_in_range(self):
for crit in ("low", "normal", "high", "showstopper"):
for age in (0, 1, 15, 400):
item = self._item(criticality=crit, installed="1.0")
ev = [Evidence(source="reddit", title="bricked", url="http://x",
severity="brick", sentiment="negative",
mentions_version=True)]
v = self._score(item, self._check("2.0", age_days=age), ev)
self.assertGreaterEqual(v.risk_score, 0.0)
self.assertLessEqual(v.risk_score, 100.0)
class TestInventoryEditing(unittest.TestCase):
def test_set_installed_preserves_comments(self):
import tempfile
from pathlib import Path
from avwatch import inventory
content = """\
# A header comment that must survive
defaults:
criticality: normal
items:
# a comment about this item
- id: thing-one
name: Thing One
vendor: Acme
category: video
installed:
source: {kind: manual}
- id: thing-two
name: Thing Two
vendor: Acme
category: audio
installed: '1.0'
source: {kind: manual}
"""
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "inv.yaml"
path.write_text(content, encoding="utf-8")
inventory.save_installed(path, "thing-one", "3.1.4")
after = path.read_text(encoding="utf-8")
self.assertIn("# A header comment that must survive", after)
self.assertIn("# a comment about this item", after)
self.assertIn("installed: '3.1.4'", after)
# thing-two must be untouched
self.assertIn("installed: '1.0'", after)
items = {i.id: i for i in inventory.load(path)}
self.assertEqual(items["thing-one"].installed, "3.1.4")
self.assertEqual(items["thing-two"].installed, "1.0")
def test_unknown_id_raises(self):
import tempfile
from pathlib import Path
from avwatch import inventory
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "inv.yaml"
path.write_text(
"items:\n - id: a\n name: A\n vendor: V\n"
" category: video\n source: {kind: manual}\n",
encoding="utf-8",
)
with self.assertRaises(inventory.InventoryError):
inventory.save_installed(path, "nope", "1.0")
class TestShippedInventory(unittest.TestCase):
"""Guards against a typo in inventory.yaml reaching a scheduled run."""
def test_loads_and_is_sane(self):
from pathlib import Path
from avwatch import inventory
path = Path(__file__).resolve().parent.parent / "inventory.yaml"
if not path.exists():
self.skipTest("inventory.yaml not present")
items = inventory.load(path)
self.assertGreater(len(items), 50)
ids = [i.id for i in items]
self.assertEqual(len(ids), len(set(ids)), "duplicate item ids")
for i in items:
if i.source_kind == "blackmagic":
self.assertIn("family", i.source, f"{i.id} missing family")
if i.source_kind == "github":
self.assertIn("repo", i.source, f"{i.id} missing repo")
self.assertIn("/", i.source["repo"], f"{i.id} repo not owner/name")
if i.source_kind == "html":
self.assertIn("url", i.source, f"{i.id} missing url")
self.assertIn("pattern", i.source, f"{i.id} missing pattern")
if i.source_kind == "websearch":
self.assertIn("query", i.source, f"{i.id} missing query")
def test_blackmagic_families_are_known(self):
from pathlib import Path
from avwatch import inventory
from avwatch.sources.blackmagic import FAMILY_PATTERNS
path = Path(__file__).resolve().parent.parent / "inventory.yaml"
if not path.exists():
self.skipTest("inventory.yaml not present")
for i in inventory.load(path):
if i.source_kind != "blackmagic":
continue
fam = i.source.get("family")
self.assertTrue(
fam in FAMILY_PATTERNS or "pattern" in i.source,
f"{i.id}: family {fam!r} is not a known BMD family and no "
f"explicit pattern was given",
)
if __name__ == "__main__":
unittest.main(verbosity=2)