feat(m15): opt-in logging + per-diagnostic storage + Report bundles — 0.30.0
One `logging_enabled` toggle (default off) gates everything (D25): - core/applog.py: rotating app.log (no-op unless enabled); setup() at GUI/CLI start. - core/diagstore.py: each diagnostic stored in DATA_DIR/diagnostics/<id>/ (capture, result.json, report.txt, scoped gamelogs, ai/ records of exactly what was sent to the model + which model + the reply). make_report() zips a diagnostic (+ app.log) into DATA_DIR/reports/. - diagnostic.finish()/analyze_crash() store when enabled; DiagnosticResult.dir. - GUI: Settings → Logging toggle; "Report" button on the diagnostic dialog; AI interactions recorded into the diagnostic dir on "Explain with AI". - CLI: `rigdoctor bundle` (report is taken by the M4 health report). - Tests for store/record_ai/make_report + applog gating; docs (D25, M15, Phase 8). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
"""Tests for M15 per-diagnostic storage + Report bundles + app logging."""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from rigdoctor.core import applog, diagstore
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeSummary:
|
||||
start: float = 1.0
|
||||
end: float = 2.0
|
||||
samples: int = 3
|
||||
events: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeFinding:
|
||||
severity: str = "ok"
|
||||
category: str = "GPU"
|
||||
title: str = "Looks fine"
|
||||
detail: str = "no issues"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeResult:
|
||||
game: str = "Path of Exile 2"
|
||||
summary: FakeSummary = field(default_factory=FakeSummary)
|
||||
findings: list = field(default_factory=lambda: [FakeFinding()])
|
||||
dir: str | None = None
|
||||
|
||||
|
||||
class StoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp())
|
||||
|
||||
def test_disabled_returns_none(self):
|
||||
with mock.patch.object(diagstore, "enabled", return_value=False):
|
||||
self.assertIsNone(diagstore.store(FakeResult()))
|
||||
|
||||
def test_store_writes_artifacts(self):
|
||||
with mock.patch.object(diagstore, "enabled", return_value=True), \
|
||||
mock.patch("rigdoctor.render.render_summary", return_value="SUMMARY-TEXT"), \
|
||||
mock.patch("rigdoctor.core.gamelogs.collect", return_value="LOG-TEXT"), \
|
||||
mock.patch.object(diagstore.config, "DIAGNOSTICS_DIR", self.tmp / "diagnostics"):
|
||||
directory = diagstore.store(FakeResult())
|
||||
self.assertTrue((directory / "result.json").exists())
|
||||
self.assertTrue((directory / "report.txt").exists())
|
||||
self.assertEqual((directory / "gamelogs.txt").read_text(), "LOG-TEXT")
|
||||
data = json.loads((directory / "result.json").read_text())
|
||||
self.assertEqual(data["game"], "Path of Exile 2")
|
||||
self.assertEqual(len(data["findings"]), 1)
|
||||
|
||||
def test_record_ai_then_report_includes_ai_and_applog(self):
|
||||
diag = self.tmp / "20260522-poe2"
|
||||
diag.mkdir()
|
||||
diagstore.record_ai(diag, provider="claude", model="claude-opus-4-7",
|
||||
system="SYS", prompt="EXACT DATA SENT", response="THE REPLY")
|
||||
ai_files = list((diag / "ai").glob("explain-*.json"))
|
||||
self.assertTrue(ai_files)
|
||||
record = json.loads(ai_files[0].read_text())
|
||||
self.assertEqual(record["model"], "claude-opus-4-7")
|
||||
self.assertEqual(record["data_sent_to_model"], "EXACT DATA SENT")
|
||||
self.assertEqual(record["model_reply"], "THE REPLY")
|
||||
|
||||
app_log = self.tmp / "app.log"
|
||||
app_log.write_text("app log line")
|
||||
with mock.patch.object(diagstore.config, "REPORTS_DIR", self.tmp / "reports"), \
|
||||
mock.patch.object(diagstore.config, "APP_LOG", app_log):
|
||||
out = diagstore.make_report(diag)
|
||||
self.assertTrue(out.exists())
|
||||
with zipfile.ZipFile(out) as zf:
|
||||
names = zf.namelist()
|
||||
self.assertTrue(any(n.endswith("app.log") for n in names))
|
||||
self.assertTrue(any("/ai/explain-" in n for n in names))
|
||||
|
||||
|
||||
class AppLogTests(unittest.TestCase):
|
||||
def test_disabled_is_noop(self):
|
||||
with mock.patch.object(applog.config, "load_config", return_value={"logging_enabled": False}):
|
||||
self.assertFalse(applog.setup(force=True))
|
||||
|
||||
def test_enabled_writes_file(self):
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
with mock.patch.object(applog.config, "load_config", return_value={"logging_enabled": True}), \
|
||||
mock.patch.object(applog.config, "STATE_DIR", tmp), \
|
||||
mock.patch.object(applog.config, "APP_LOG", tmp / "app.log"):
|
||||
self.assertTrue(applog.setup(force=True))
|
||||
applog.get_logger("test").info("hello world")
|
||||
applog.setup(force=True) # cleanup path: re-run detaches/reattaches cleanly
|
||||
self.assertTrue((tmp / "app.log").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user