From 82f2c98e7e05f7beec52bda755cffba2e634876a Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 22 Sep 2026 15:13:45 +0700 Subject: [PATCH] test(ui): cover decision log ingestion and attribution Add 34 offline integration checks for partial writes, malformed records, session joins, bounded history, file rotation, and recovery without game or model connections. --- ui/JevOverlay/Tests/JevOverlay.Tests.csproj | 12 ++ ui/JevOverlay/Tests/Program.cs | 140 ++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 ui/JevOverlay/Tests/JevOverlay.Tests.csproj create mode 100644 ui/JevOverlay/Tests/Program.cs diff --git a/ui/JevOverlay/Tests/JevOverlay.Tests.csproj b/ui/JevOverlay/Tests/JevOverlay.Tests.csproj new file mode 100644 index 0000000..70c62fe --- /dev/null +++ b/ui/JevOverlay/Tests/JevOverlay.Tests.csproj @@ -0,0 +1,12 @@ + + + Exe + net9.0 + enable + enable + + + + + + diff --git a/ui/JevOverlay/Tests/Program.cs b/ui/JevOverlay/Tests/Program.cs new file mode 100644 index 0000000..abd3025 --- /dev/null +++ b/ui/JevOverlay/Tests/Program.cs @@ -0,0 +1,140 @@ +using System.Text; +using System.Text.Json; +using JevOverlay.Core; + +// Executable integration checks: temporary JSONL file -> reader -> history -> display text. +string directory = Path.Combine(Path.GetTempPath(), "jev-overlay-" + Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(directory); +int checks = 0; +void Check(bool condition, string message) +{ + checks++; + if (!condition) throw new Exception(message); +} +string Row(string session, int step, string source = "jev") => JsonSerializer.Serialize(new +{ + @event = "decide", session, step, state_type = "combat", ts = "12:34:56", + action = "play_card", @params = new { card_index = 0 }, source, + reason = "Prefer the recorded candidate", confidence = 0.81, + jev = source == "jev" ? new + { + model = "stub", latency_s = 0.72, + questions = new { pick = new { type = "choice", choices = new[] { "Strike", "Defend" } } }, + answers = new { pick = new { kind = "choice", choice = "Strike", confidence = 0.81, gated = true } }, + } : null, +}) + "\n"; +try +{ + string path = Path.Combine(directory, "decisions.jsonl"); + string config = Path.Combine(directory, OverlayConfig.FileName); + File.WriteAllText(config, JsonSerializer.Serialize(new { log_path = path })); + Check(OverlayConfig.LoadLogPath(directory) == path, "Absolute configuration path"); + File.WriteAllText(config, "{\"log_path\":\"relative.jsonl\"}"); + bool rejected = false; + try { OverlayConfig.LoadLogPath(directory); } + catch (FormatException) { rejected = true; } + Check(rejected, "Reject ambiguous relative configuration paths"); + + var reader = new LogTail(path); + var history = new DecisionHistory(); + Check(reader.Poll().Error is not null, "Missing log does not throw"); + string first = Row("alpha", 1); + File.WriteAllText(path, first[..^1]); + var partial = reader.Poll(); + history.Apply(partial); + Check(history.Entries.Count == 0, "Wait for a complete line"); + File.AppendAllText(path, "\n"); + history.Apply(reader.Poll()); + Check(history.Entries.Count == 1, "Recover when the writer completes the line"); + var entry = history.Entries[0]; + Check(entry.Key == new DecisionKey("alpha", 1), "Keep session and step attribution"); + Check(entry.Summary().Contains("720 ms"), "Render model latency"); + Check(entry.Summary().Contains("execution is not recorded"), "Never infer action success"); + Check(entry.Evidence().Contains("Strike"), "Render recorded questions and answers"); + Check(reader.Poll().Lines.Count == 0, "Do not duplicate unchanged lines"); + + // Equal step numbers in separate sessions must never share action status. + File.AppendAllText(path, Row("beta", 1, "code") + + "{\"event\":\"action_rejected\",\"session\":\"alpha\",\"step\":1,\"action\":\"play_card\",\"message\":\"busy\"}\n" + + "{\"event\":\"session_end\",\"session\":\"alpha\",\"step\":1,\"reason\":\"step_limit\"}\n"); + history.Apply(reader.Poll()); + Check(history.Entries[0].Status.Contains("Proposal only"), "Other session remains a proposal"); + Check(history.Entries[1].Status.Contains("rejected: busy"), "Join rejection by session and step"); + Check(history.Entries[0].SessionEnd is null, "Do not end the other session"); + Check(history.Entries[1].SessionEnd == "Session ended: step_limit", "Show session end without claiming completion"); + Check(history.Entries[0].Evidence().Contains("No Jev response"), "Code-only decision needs no model"); + + File.AppendAllText(path, + "{\"event\":\"action_error\",\"session\":\"beta\",\"step\":1,\"action\":\"end_turn\",\"error\":\"timeout\"}\n" + + "{\"event\":\"model_error\",\"session\":\"beta\",\"step\":2,\"error\":\"offline\"}\n"); + history.Apply(reader.Poll()); + Check(history.Entries[0].Status.Contains("Proposal only"), "Ignore mismatched action name"); + Check(history.Notice!.Contains("model_error: offline"), "Show model failures before a decision exists"); + File.AppendAllText(path, + "{\"event\":\"action_error\",\"session\":\"beta\",\"step\":1,\"action\":\"play_card\",\"error\":\"timeout\"}\n"); + history.Apply(reader.Poll()); + Check(history.Entries[0].Status.Contains("completion unknown"), "Timeout is not proof that nothing executed"); + + File.AppendAllText(path, "not json\n[]\n" + + "{\"event\":\"decide\",\"session\":\"beta\",\"step\":\"bad\",\"action\":\"play_card\"}\n" + + "{\"event\":\"run_identity\",\"session\":\"beta\"}\n" + Row("beta", 3, "fallback")); + history.Apply(reader.Poll()); + Check(history.SkippedLines == 3, "Skip malformed lines, not valid unrelated events"); + Check(history.Entries[0].Source == "fallback", "Malformed rows do not block later decisions"); + + // Split a UTF-8 character across polls, followed by CRLF. + string unicodeRow = "{\"event\":\"decide\",\"session\":\"beta\",\"step\":4,\"action\":\"play_card\",\"reason\":\"雪\"}\r\n"; + byte[] unicode = Encoding.UTF8.GetBytes(unicodeRow); + int split = Array.IndexOf(unicode, (byte)0xe9) + 1; + using (var file = new FileStream(path, FileMode.Append)) file.Write(unicode, 0, split); + Check(reader.Poll().Lines.Count == 0, "Keep a partial UTF-8 sequence as bytes"); + using (var file = new FileStream(path, FileMode.Append)) file.Write(unicode, split, unicode.Length - split); + history.Apply(reader.Poll()); + Check(history.Entries[0].Summary().Contains("雪"), "Decode UTF-8 only after the line completes"); + + File.AppendAllText(path, new string('x', LogTail.MaxLineBytes + 1) + "\n" + Row("beta", 5)); + history.Apply(reader.Poll()); + Check(history.SkippedLines == 4, "Bound an oversized line and report the skip"); + Check(history.Entries[0].Key.Step == 5, "Resume at the next newline"); + + File.WriteAllText(path, Row("gamma", 1)); + var truncated = reader.Poll(); + history.Apply(truncated); + Check(truncated.Reset && history.Entries.Count == 1, "Reset history on truncation"); + Check(history.Entries[0].Key.Session == "gamma", "Read the truncated file's new session"); + string replacement = Path.Combine(directory, "replacement.jsonl"); + // Same-length replacement: file length alone cannot detect rotation. + File.WriteAllText(replacement, Row("delta", 1)); + File.Move(replacement, path, overwrite: true); + var rotated = reader.Poll(); + history.Apply(rotated); + Check(rotated.Reset && history.Entries[0].Key.Session == "delta", "Detect same-length replacement by content"); + + var many = new StringBuilder(); + for (int i = 2; i <= 150; i++) many.Append(Row("delta", i)); + File.AppendAllText(path, many.ToString()); + history.Apply(reader.Poll()); + Check(history.Entries.Count == DecisionHistory.Capacity, "Bound retained history"); + Check(history.Entries[0].Key.Step == 150 && history.Entries[^1].Key.Step == 51, "Retain newest decisions"); + + // Startup on a long existing log must read a bounded suffix, not the whole file. + many.Clear(); + for (int i = 0; i < 2000; i++) many.Append(Row("large", i)); + File.WriteAllText(path, many.ToString()); + var freshReader = new LogTail(path); + var startup = freshReader.Poll(); + history.Apply(startup); + Check(startup.Lines.Sum(line => Encoding.UTF8.GetByteCount(line) + 1) <= LogTail.MaxReadBytes, + "Bound bytes on startup"); + Check(history.Entries[0].Key.Step == 1999, "Startup reaches the newest complete row"); + File.AppendAllText(path, many.ToString()); + Check(freshReader.Poll().CatchingUp, "Bound each poll when a writer gets far ahead"); + + File.Delete(path); + Check(freshReader.Poll().Error is not null, "Deleted file remains a recoverable condition"); + File.WriteAllText(path, Row("restored", 1)); + var restored = freshReader.Poll(); + Check(restored.Reset && restored.Error is null, "Recover after file recreation"); + Console.WriteLine($"PASS: {checks} overlay integration checks (no game or model connection)."); +} +finally { Directory.Delete(directory, recursive: true); }