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.
188 lines
7.0 KiB
C#
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) { }
|
|
}
|
|
}
|