personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-3std5nvh-stats-contract-test'

+217
+217
tests/test_stats_contract.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import importlib 5 + import json 6 + from pathlib import Path 7 + 8 + 9 + CONTRACT_FIELDS = [ 10 + ("days", "stats.days"), 11 + ("totals", "stats.totals"), 12 + ("heatmap", "stats.heatmap"), 13 + ("totals.day_bytes", "totals.day_bytes"), 14 + ("totals.pending_segments", "totals.pending_segments"), 15 + ("totals.outputs_processed", "totals.outputs_processed"), 16 + ("totals.outputs_pending", "totals.outputs_pending"), 17 + ("days.*.day_bytes", "dayData.day_bytes"), 18 + ("totals.total_transcript_duration", "total_audio_duration"), 19 + ("totals.total_percept_duration", "total_screen_duration"), 20 + ("totals.transcript_sessions", "audio_sessions"), 21 + ("tokens.by_model", "token_totals_by_model"), 22 + ("tokens.by_day", "token_usage_by_day"), 23 + ("facets.counts_by_day", "facet_counts_by_day"), 24 + ("agents.counts_by_day", "agent_counts_by_day"), 25 + ("days.*.transcript_duration", "audio_duration"), 26 + ("days.*.percept_duration", "screen_duration"), 27 + ("tokens.by_day.*.*.input_tokens", "input_tokens"), 28 + ("tokens.by_day.*.*.output_tokens", "output_tokens"), 29 + ("tokens.by_day.*.*.reasoning_tokens", "reasoning_tokens"), 30 + ("tokens.by_model.*.total_tokens", "total_tokens"), 31 + ] 32 + 33 + JS_PATH = ( 34 + Path(__file__).resolve().parent.parent 35 + / "apps" 36 + / "stats" 37 + / "static" 38 + / "dashboard.js" 39 + ) 40 + 41 + 42 + def _resolve_path(data, path): 43 + stack = [(data, path.split("."))] 44 + while stack: 45 + current, parts = stack.pop() 46 + if not parts: 47 + return True 48 + if not isinstance(current, dict): 49 + continue 50 + part, rest = parts[0], parts[1:] 51 + if part == "*": 52 + stack.extend((value, rest) for value in current.values()) 53 + elif part in current: 54 + stack.append((current[part], rest)) 55 + return False 56 + 57 + 58 + def _build_journal(base_path): 59 + journal = base_path 60 + day = journal / "20240101" 61 + seg1 = day / "default" / "123456_300" 62 + seg2 = day / "default" / "134500_300" 63 + seg1.mkdir(parents=True) 64 + seg2.mkdir(parents=True) 65 + (day / "agents").mkdir(parents=True) 66 + 67 + audio_lines = [ 68 + {"raw": "raw.flac"}, 69 + {"start": "10:00:00", "text": "hello"}, 70 + {"start": "10:05:00", "text": "world"}, 71 + ] 72 + (seg1 / "audio.jsonl").write_text( 73 + "\n".join(json.dumps(line) for line in audio_lines) + "\n" 74 + ) 75 + 76 + screen_lines = [ 77 + {"raw": "screen.webm"}, 78 + {"frame_id": 1, "timestamp": 1000.0, "text": "frame one"}, 79 + {"frame_id": 2, "timestamp": 1060.0, "text": "frame two"}, 80 + ] 81 + (seg1 / "screen.jsonl").write_text( 82 + "\n".join(json.dumps(line) for line in screen_lines) + "\n" 83 + ) 84 + 85 + (seg2 / "audio.flac").write_bytes(b"fLaC") 86 + (day / "agents" / "flow.md").write_text("") 87 + 88 + events_dir = journal / "facets" / "work" / "events" 89 + events_dir.mkdir(parents=True) 90 + event = { 91 + "type": "meeting", 92 + "start": "09:00:00", 93 + "end": "09:30:00", 94 + "title": "standup", 95 + "summary": "daily sync", 96 + "work": True, 97 + "participants": [], 98 + "details": "", 99 + "facet": "work", 100 + "agent": "meetings", 101 + "occurred": True, 102 + "source": "20240101/agents/meetings.md", 103 + } 104 + (events_dir / "20240101.jsonl").write_text(json.dumps(event) + "\n") 105 + 106 + tokens_dir = journal / "tokens" 107 + tokens_dir.mkdir() 108 + token = { 109 + "timestamp": 1704067200.0, 110 + "timestamp_str": "20240101_120000", 111 + "model": "test-model", 112 + "context": "test", 113 + "usage": { 114 + "input_tokens": 100, 115 + "output_tokens": 50, 116 + "cached_tokens": 10, 117 + "reasoning_tokens": 5, 118 + "total_tokens": 165, 119 + }, 120 + } 121 + (tokens_dir / "20240101.jsonl").write_text(json.dumps(token) + "\n") 122 + 123 + return journal 124 + 125 + 126 + def _scan_output(journal, stats_mod): 127 + js = stats_mod.JournalStats() 128 + js.scan(str(journal)) 129 + return js.to_dict() 130 + 131 + 132 + def test_generated_stats_pass_schema(tmp_path, monkeypatch): 133 + stats_mod = importlib.import_module("think.journal_stats") 134 + schema_mod = importlib.import_module("think.stats_schema") 135 + journal = _build_journal(tmp_path) 136 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 137 + 138 + output = _scan_output(journal, stats_mod) 139 + 140 + errors = schema_mod.validate(output) 141 + assert errors == [], f"Validation errors: {errors}" 142 + assert output["schema_version"] == schema_mod.SCHEMA_VERSION 143 + 144 + 145 + def test_contract_fields_exist_in_output(tmp_path, monkeypatch): 146 + stats_mod = importlib.import_module("think.journal_stats") 147 + journal = _build_journal(tmp_path) 148 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 149 + 150 + output = _scan_output(journal, stats_mod) 151 + 152 + for python_path, _ in CONTRACT_FIELDS: 153 + assert _resolve_path(output, python_path), f"{python_path} missing from stats output" 154 + 155 + 156 + def test_contract_fields_referenced_in_js(): 157 + js_source = JS_PATH.read_text() 158 + 159 + for _, js_ref in CONTRACT_FIELDS: 160 + assert ( 161 + js_ref in js_source 162 + ), f"{js_ref} not found in dashboard.js — contract field may be stale" 163 + 164 + 165 + def test_all_day_fields_have_nonzero_values(tmp_path, monkeypatch): 166 + stats_mod = importlib.import_module("think.journal_stats") 167 + schema_mod = importlib.import_module("think.stats_schema") 168 + journal = _build_journal(tmp_path) 169 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 170 + 171 + output = _scan_output(journal, stats_mod) 172 + day_entry = next(iter(output["days"].values())) 173 + 174 + for field in schema_mod.DAY_FIELDS: 175 + assert day_entry[field] > 0, f"{field} should be non-zero in fixture output" 176 + 177 + 178 + def test_schema_rejects_missing_required_key(): 179 + schema_mod = importlib.import_module("think.stats_schema") 180 + output = { 181 + "schema_version": schema_mod.SCHEMA_VERSION, 182 + "generated_at": "2026-04-10T00:00:00+00:00", 183 + "day_count": 1, 184 + "days": {}, 185 + "totals": {}, 186 + "heatmap": [], 187 + "tokens": {}, 188 + "agents": {}, 189 + "facets": {}, 190 + } 191 + del output["totals"] 192 + 193 + errors = schema_mod.validate(output) 194 + 195 + assert errors, "validate() should reject missing required keys" 196 + assert any("totals" in error for error in errors) 197 + 198 + 199 + def test_schema_rejects_wrong_version(): 200 + schema_mod = importlib.import_module("think.stats_schema") 201 + 202 + errors = schema_mod.validate( 203 + { 204 + "schema_version": 99, 205 + "generated_at": "2026-04-10T00:00:00+00:00", 206 + "day_count": 0, 207 + "days": {}, 208 + "totals": {}, 209 + "heatmap": [], 210 + "tokens": {}, 211 + "agents": {}, 212 + "facets": {}, 213 + } 214 + ) 215 + 216 + assert errors, "validate() should reject the wrong schema version" 217 + assert any("schema_version" in error for error in errors)