Add 34 offline integration checks for partial writes, malformed records, session joins, bounded history, file rotation, and recovery without game or model connections.
140 lines
7.8 KiB
C#
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); }
|