Files
HellionChat/HellionChat/SelfTests/ExportRoundTripStep.cs
T
JonKazama-Hellion 5b738e6885 chore: comments say what the code does, not which task produced it
A comment that reads "MUST stay in lockstep with TryGetActiveCrossfade (K8)"
helps nobody outside the plan that used to have a K8 in it, and the plans are
not in this repo. Same for "Spec FR-4", "plan §B.2", "Sub-Task 4.4" and the
F/R/M/A/S round codes scattered through the style engine and the self-tests.

Personal names go too. "tester feedback from Jin (v1.4.7)" and "Flo decision
2026-06-15" carry the reason fine without naming anyone -- the version and the
reason are the parts a reader can act on, and a public repo should not need a
cast list to be read.

The rule applied throughout: keep the why, drop the reference. Version numbers
stay, since those resolve through the changelog. 77 files.

ChunkUtil also carried 281 lines of commented-out code -- an older ToChunks
variant and two helpers with no callers, inherited and never removed. Deleted;
git remembers them.
2026-08-19 21:50:31 +02:00

188 lines
7.0 KiB
C#

using Dalamud.Bindings.ImGui;
using Dalamud.Game.Text;
using Dalamud.Plugin.SelfTest;
using HellionChat.Code;
using HellionChat.Export;
using HellionChat.Util;
namespace HellionChat.SelfTests;
// v1.12.0: the exporter now reads text from the chunk lists instead of the
// raw SeStrings. That change is invisible to the build suite -- ExportToFile
// takes IEnumerable<Message>, Message needs SeString, and xUnit cannot load
// Dalamud.dll, so even an empty list fails before the body runs.
//
// So it is verified here, against real messages, with the three properties that
// actually matter:
//
// 1. Text survives the round trip. If the exporter ever reads SenderSource or
// ContentSource again, a message built without them comes out blank.
// 2. The format guard runs before the file is opened. It used to run after,
// so an unknown format left a zero-byte file where a previous export had
// been.
// 3. The write is atomic. A failure partway must not leave a file that opens
// cleanly and is quietly incomplete -- this is the path a GDPR access
// request goes out on.
internal sealed class ExportRoundTripStep : ISelfTestStep
{
public string Name => "Hellion Chat - Export round trip";
public SelfTestStepResult RunStep()
{
var dir = Path.Combine(Path.GetTempPath(), $"hellionchat-selftest-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
var failures = new List<string>();
try
{
CheckTextSurvives(dir, failures);
CheckUnknownFormatKeepsExistingFile(dir, failures);
CheckNoLeftoverPartFile(dir, failures);
}
catch (Exception e)
{
failures.Add($"threw: {e.GetType().Name}: {e.Message}");
}
finally
{
TryCleanup(dir);
}
foreach (var f in failures)
ImGui.Text(f);
SelfTestReport.Append(Name, failures.Count == 0 ? "PASS" : "FAIL", failures);
return failures.Count == 0 ? SelfTestStepResult.Pass : SelfTestStepResult.Fail;
}
private static void CheckTextSurvives(string dir, List<string> failures)
{
const string sender = "Selftest Sender";
const string content = "selftest content marker";
var path = Path.Combine(dir, "roundtrip.csv");
var written = MessageExporter.ExportToFile(
path,
ExportFormat.Csv,
[Probe(sender, content)],
new MessageExporter.FilterDescription(null, null, null, null)
);
if (written != 1)
failures.Add($"expected 1 message written, got {written}");
var text = File.ReadAllText(path);
if (!text.Contains(sender, StringComparison.Ordinal))
failures.Add("sender missing from export -- reading SeString again?");
if (!text.Contains(content, StringComparison.Ordinal))
failures.Add("content missing from export -- reading SeString again?");
// The sender filter runs inside the exporter and must see the same text.
var filtered = Path.Combine(dir, "filtered.csv");
var hits = MessageExporter.ExportToFile(
filtered,
ExportFormat.Csv,
[Probe(sender, content), Probe("Someone Else", "other")],
new MessageExporter.FilterDescription(null, null, null, "selftest sen")
);
if (hits != 1)
failures.Add($"sender filter matched {hits} messages, expected 1");
}
private static void CheckUnknownFormatKeepsExistingFile(string dir, List<string> failures)
{
var path = Path.Combine(dir, "previous.md");
File.WriteAllText(path, "an earlier export");
try
{
MessageExporter.ExportToFile(
path,
(ExportFormat)99,
[],
new MessageExporter.FilterDescription(null, null, null, null)
);
failures.Add("unknown format did not throw");
}
catch (ArgumentOutOfRangeException) { }
if (File.ReadAllText(path) != "an earlier export")
failures.Add("unknown format destroyed the existing file");
}
private static void CheckNoLeftoverPartFile(string dir, List<string> failures)
{
var path = Path.Combine(dir, "clean.json");
MessageExporter.ExportToFile(
path,
ExportFormat.Json,
[Probe("A", "b")],
new MessageExporter.FilterDescription(null, null, null, null)
);
if (!File.Exists(path))
failures.Add("export produced no file");
if (File.Exists(path + ".part"))
failures.Add("temporary file left behind after a successful export");
// Bytes, not text. File.ReadAllText strips a byte order mark while
// detecting the encoding, so a BOM that breaks every strict JSON parser
// is invisible to the check below -- and it shipped exactly that way.
var head = File.ReadAllBytes(path);
if (head.Length >= 3 && head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF)
failures.Add("export JSON starts with a byte order mark");
// Parsed, not merely counted. The writer builds JSON by hand, and it
// shipped a build where the chat relation kinds were interpolated as
// enum names -- "source_kind":LocalPlayer -- which every parser
// rejects. A test that only checks the file exists would have passed.
try
{
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path));
if (!doc.RootElement.TryGetProperty("messages", out var messages))
failures.Add("export JSON has no messages array");
else if (messages.GetArrayLength() != 1)
failures.Add($"export JSON holds {messages.GetArrayLength()} messages, expected 1");
}
catch (System.Text.Json.JsonException e)
{
failures.Add($"export JSON does not parse: {e.Message}");
}
}
// Built with empty SeStrings on purpose: the whole point is that the text
// comes from the chunks.
private static Message Probe(string sender, string content)
{
static List<Chunk> Text(string s) =>
[new TextChunk(ChunkSource.Content, null, null, null, null, false, s)];
return new Message(
0,
0,
0,
new ChatCode(XivChatType.Say, 0, 0),
Text(sender),
Text(content),
new Dalamud.Game.Text.SeStringHandling.SeString(),
new Dalamud.Game.Text.SeStringHandling.SeString()
);
}
// Nothing to undo between runs: every file lives in a fresh temp directory
// that RunStep deletes in its own finally.
public void CleanUp() { }
private static void TryCleanup(string dir)
{
try
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
}