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.
This commit is contained in:
parent
130273570b
commit
82f2c98e7e
2 changed files with 152 additions and 0 deletions
140
ui/JevOverlay/Tests/Program.cs
Normal file
140
ui/JevOverlay/Tests/Program.cs
Normal file
|
|
@ -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); }
|
||||
Loading…
Add table
Add a link
Reference in a new issue