The real defect first. RefreshDatabaseMetadata was the one worker of six that never took the shared lock, and its flag was the one of six missing from the tab's busy state. It calls MessageCount, which holds the read lock, so a wipe could start while it was in there -- and the tab would not have known to grey the button, because it could not see the worker. Both halves fixed. The pattern is why: seven near-copies of one worker skeleton, and each copy decided something slightly different. The clear button failed silently when its thread could not start. The most destructive control in the plugin, pressed, and nothing happens, with no way to tell that from a wipe that worked -- while the three harmless workers beside it do report. Maintenance was the mirror: its comment promises refusals are said out loud, and then swallowed the actual failure. Three start-failure paths also bypassed the notify helper that carries the teardown check, three weeks after it was added for exactly that. The database numbers now wait for a real read, like the clear hint already did. Zero bytes and zero messages read as an empty database, not as a number nobody has fetched. SelectionAfterDelete is gone, with its three tests. The accordion has no selection, so its return value went into a discard -- a function answering a question the interface does not ask, with green tests guarding nothing. The project's own self-test README calls that the anti-pattern of record. Six new keys replaced by the translated orphans that already said the same thing. A commit earlier in this cycle is literally called "stop duplicating a key" and these went past it. The duplicate button also had the label "Add", which is the one string out of ninety-four that was never written. Tests: CleanupDeleteTypes had none, and with the failsafe on -- how a fresh config ships -- it is the path every cleanup takes. Four now, including the one that matters: an empty list deletes nothing rather than everything. And a self-test for the gate wiring, which is what would have caught the metadata worker. The unit tests prove the gate works; nothing proved the workers use it.
132 lines
4.5 KiB
C#
132 lines
4.5 KiB
C#
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Plugin.SelfTest;
|
|
using HellionChat.Ui.Components.Settings;
|
|
using HellionChat.Util;
|
|
|
|
namespace HellionChat.SelfTests;
|
|
|
|
// The gate has fourteen unit tests. None of them prove that the workers are
|
|
// wired to it, and that is where the mistake actually happened: the metadata
|
|
// refresh reached the store without taking the gate at all, and its flag was
|
|
// missing from the tab's shared busy state, so a wipe could start while it held
|
|
// the read lock.
|
|
//
|
|
// A unit test cannot see that. It needs the real singleton, the real flags and
|
|
// the real settings tab, so it lives here.
|
|
internal sealed class DbGateWiringStep : ISelfTestStep
|
|
{
|
|
private readonly Plugin _plugin;
|
|
|
|
public DbGateWiringStep(Plugin plugin) => _plugin = plugin;
|
|
|
|
public string Name => "Hellion Chat - DB gate wiring";
|
|
|
|
public SelfTestStepResult RunStep()
|
|
{
|
|
var failures = new List<string>();
|
|
|
|
// Nothing may be running when the probe starts, or the assertions below
|
|
// measure somebody else's operation.
|
|
if (_plugin.DbOperations.IsBusy)
|
|
failures.Add($"gate already held by {_plugin.DbOperations.Current} before the probe");
|
|
|
|
CheckEveryOperationRoundTrips(failures);
|
|
CheckRefusalLeavesTheOwnerAlone(failures);
|
|
CheckRevisionTracksMutations(failures);
|
|
CheckTabSeesTheGate(failures);
|
|
|
|
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 void CheckEveryOperationRoundTrips(List<string> failures)
|
|
{
|
|
foreach (var op in EnumValues<DbOperation>.All)
|
|
{
|
|
if (op == DbOperation.None)
|
|
continue;
|
|
|
|
if (!_plugin.DbOperations.TryBegin(op))
|
|
{
|
|
failures.Add($"{op}: TryBegin refused on a free gate");
|
|
continue;
|
|
}
|
|
|
|
if (_plugin.DbOperations.Current != op)
|
|
failures.Add($"{op}: gate reports {_plugin.DbOperations.Current} after TryBegin");
|
|
|
|
if (_plugin.DbOperations.TryBegin(op))
|
|
failures.Add($"{op}: a second TryBegin succeeded while it was held");
|
|
|
|
_plugin.DbOperations.End(op);
|
|
|
|
if (_plugin.DbOperations.IsBusy)
|
|
failures.Add($"{op}: still busy after End");
|
|
}
|
|
}
|
|
|
|
private void CheckRefusalLeavesTheOwnerAlone(List<string> failures)
|
|
{
|
|
if (!_plugin.DbOperations.TryBegin(DbOperation.Export))
|
|
{
|
|
failures.Add("could not take the gate for the refusal check");
|
|
return;
|
|
}
|
|
|
|
// A worker whose TryBegin was refused still runs its finally. Releasing
|
|
// there must not hand away somebody else's lock.
|
|
_plugin.DbOperations.End(DbOperation.Clear);
|
|
|
|
if (_plugin.DbOperations.Current != DbOperation.Export)
|
|
failures.Add("a foreign End released the gate");
|
|
|
|
_plugin.DbOperations.End(DbOperation.Export);
|
|
}
|
|
|
|
private void CheckRevisionTracksMutations(List<string> failures)
|
|
{
|
|
var before = _plugin.DbOperations.Revision;
|
|
|
|
_plugin.DbOperations.TryBegin(DbOperation.Export);
|
|
_plugin.DbOperations.End(DbOperation.Export);
|
|
if (_plugin.DbOperations.Revision != before)
|
|
failures.Add("export moved the revision; it cannot change a row");
|
|
|
|
_plugin.DbOperations.TryBegin(DbOperation.Cleanup);
|
|
_plugin.DbOperations.End(DbOperation.Cleanup);
|
|
if (_plugin.DbOperations.Revision == before)
|
|
failures.Add(
|
|
"cleanup did not move the revision; a stale preview would pass as current"
|
|
);
|
|
}
|
|
|
|
// The settings tab decides whether the destructive buttons are live. If it
|
|
// cannot see a held gate, two of them are clickable at once.
|
|
private void CheckTabSeesTheGate(List<string> failures)
|
|
{
|
|
if (!_plugin.DbOperations.TryBegin(DbOperation.Clear))
|
|
{
|
|
failures.Add("could not take the gate for the tab check");
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!_plugin.DataPrivacyTab.AnythingRunningForSelfTest)
|
|
failures.Add("the data and privacy tab does not see a held gate");
|
|
}
|
|
finally
|
|
{
|
|
_plugin.DbOperations.End(DbOperation.Clear);
|
|
}
|
|
|
|
if (_plugin.DataPrivacyTab.AnythingRunningForSelfTest)
|
|
failures.Add("the tab still reports busy after the gate was released");
|
|
}
|
|
|
|
public void CleanUp() { }
|
|
}
|