sts2-bot/ui/JevOverlay/Tests/Program.cs
0xrsydn 82f2c98e7e 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.
2026-09-23 13:40:02 +07:00

140 lines
7.8 KiB
C#

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