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); }