The remaining code-bearing findings from the round review, F4-F16 minus the doc-only items (batched separately): - F4: three client-wide UiButton corpus sweeps (LabelBox path — exactly the 4 Town buttons, confined to chargen; conflicting custom-selection- pair + standard Normal/Highlight media — zero found, no gate tightening needed; per-state label-color map — 209 matches beyond chargen, confirming AP-222's mechanism has always been broadly active since it shipped generically in DatWidgetFactory). - F5/F6: LayoutImporter's Batch C un-consumed-children carve-out now honors a child's own AuthoredInvisible flag (a narrow honor scoped to exactly that carve-out, not the general #408 client-wide one) — the chat transcript's new-text indicator (0x1000048C) was building as a visible phantom element retail never shows; verified both directions against the gold-frame pieces, which do not author Invisible. - F7: BoundedProcessOutputCapture.AppendLine combines the line text and its trailing newline into one buffer and one file open/write/close instead of two. - F9: corrected a stale comment in RuntimeSettingsTargets — #407 split DisplayModeCatalog's Resolutions/WindowedResolutions in two, so the fullscreen validator's own narrower list is now DELIBERATELY different from the Config dropdown's fuller offering, not the "must match" bug the comment described. - F10: documented (not changed) why the LabelBox path's default 3px inset and the face-relative +4px gap in DatWidgetFactory.BuildButton are deliberately different numbers — neither carries a retail citation, and moving either to match the other would be an unfounded guess on a button that currently works correctly. - F11: Heritage/Profession/Summary/Town description pages now compose DatRichText.Compose's result ONCE inside their already revision-gated Refresh, caching the built line list instead of re-wrapping on every draw call. - F14: documented (not changed) why PrivateEntityViewportRenderer's _animatedIds set carrying a reserved-but-never-drawn backdrop id is harmless — BuildDrawEntities already excludes a null/empty backdrop from the actual draw list, so the id is never looked up. - F16: the Summary preview now uses its own render-id pair (SummaryPreviewRenderId/SummaryPreviewBackdropRenderId, 0xDA11D035/ 0xDA11D036) instead of sharing the Appearance page's (0xDA11D032/0xDA11D034) — confirmed by tracing FixedEntityTextureOwnerLease through TextureCache to CompositeTextureArrayCache's shared owner tracker that both pages' previews share ONE process-wide TextureCache, so sharing render ids was a real cross-page texture-release collision (either page's own re-dress or disposal could release the OTHER page's still-active textures), not a theoretical one. F3's own register bookkeeping (AP-229 addendum) and F12's register/AD header-count corrections land in the docs-only commit alongside F15. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
8.1 KiB
C#
270 lines
8.1 KiB
C#
using System.Text;
|
|
using AcDream.Launcher.Core.Launching;
|
|
|
|
namespace AcDream.Launcher.Core.Tests.Launching;
|
|
|
|
/// <summary>Fix #406 sibling gap: the launcher previously discarded a
|
|
/// supervised child's stderr entirely, so diagnosing a crash (including
|
|
/// exactly the #406 crash) required re-running the identical binary by
|
|
/// hand. These tests cover <see cref="BoundedProcessOutputCapture"/> in
|
|
/// isolation — the real-child-process end-to-end capture tests live in
|
|
/// <c>LauncherProcessSupervisorTests</c> alongside the existing real-process
|
|
/// coverage.</summary>
|
|
public sealed class BoundedProcessOutputCaptureTests
|
|
{
|
|
[Fact]
|
|
public void AppendLineWritesEachLineWithATrailingNewline()
|
|
{
|
|
string path = TempPath();
|
|
try
|
|
{
|
|
using var capture = new BoundedProcessOutputCapture(path);
|
|
|
|
capture.AppendLine("first");
|
|
capture.AppendLine("second");
|
|
capture.Dispose();
|
|
|
|
Assert.Equal("first\nsecond\n", File.ReadAllText(path));
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ANullLineFromTheEndOfStreamSentinelIsANoOp()
|
|
{
|
|
string path = TempPath();
|
|
try
|
|
{
|
|
using var capture = new BoundedProcessOutputCapture(path);
|
|
|
|
capture.AppendLine("kept");
|
|
capture.AppendLine(null);
|
|
capture.Dispose();
|
|
|
|
Assert.Equal("kept\n", File.ReadAllText(path));
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void WritesBeyondTheCapAreDroppedAndAOneTimeTruncationMarkerIsAppended()
|
|
{
|
|
string path = TempPath();
|
|
try
|
|
{
|
|
using var capture = new BoundedProcessOutputCapture(path, maxBytes: 16);
|
|
|
|
capture.AppendLine("0123456789"); // 11 bytes incl. newline
|
|
capture.AppendLine("this line is dropped entirely");
|
|
capture.AppendLine("so is this one");
|
|
|
|
Assert.True(capture.IsDone);
|
|
string written = File.ReadAllText(path);
|
|
Assert.StartsWith("0123456789\n", written, StringComparison.Ordinal);
|
|
Assert.Contains("truncated at 16 bytes", written, StringComparison.Ordinal);
|
|
// The cap is a hard ceiling: nothing past it EVER lands on disk,
|
|
// even the marker's own text does not push the file arbitrarily
|
|
// far past the configured bound.
|
|
Assert.True(
|
|
written.Length < 200,
|
|
$"expected a small bounded file, got {written.Length} bytes");
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(path);
|
|
}
|
|
}
|
|
|
|
/// <summary>F7 (Campaign CC gate round 1 closeout): <c>AppendLine</c>
|
|
/// now combines the text and its trailing newline into ONE buffer
|
|
/// before writing, instead of two separate file open/write/close
|
|
/// round-trips. Pins the boundary case that change touches most
|
|
/// directly — a line whose TEXT ALONE exactly exhausts the remaining
|
|
/// cap, so the newline byte must be dropped by the SAME truncation
|
|
/// decision as the text, not a second one.</summary>
|
|
[Fact]
|
|
public void ALineWhoseTextExactlyExhaustsTheCap_DropsOnlyTheTrailingNewline()
|
|
{
|
|
string path = TempPath();
|
|
try
|
|
{
|
|
// "0123456789" is exactly 10 bytes; maxBytes=10 leaves no room
|
|
// for the newline the combined buffer also carries.
|
|
using var capture = new BoundedProcessOutputCapture(path, maxBytes: 10);
|
|
|
|
capture.AppendLine("0123456789");
|
|
|
|
Assert.True(capture.IsDone);
|
|
string written = File.ReadAllText(path);
|
|
Assert.StartsWith("0123456789", written, StringComparison.Ordinal);
|
|
Assert.Contains("truncated at 10 bytes", written, StringComparison.Ordinal);
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ALogSpammingChildCannotGrowTheFileUnboundedly()
|
|
{
|
|
string path = TempPath();
|
|
try
|
|
{
|
|
using var capture = new BoundedProcessOutputCapture(
|
|
path,
|
|
maxBytes: BoundedProcessOutputCapture.DefaultMaxBytes);
|
|
|
|
// Far more than the 2 MiB default cap.
|
|
string spamLine = new('x', 4096);
|
|
for (int i = 0; i < 4096; i++)
|
|
{
|
|
capture.AppendLine(spamLine);
|
|
if (capture.IsDone)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
Assert.True(capture.IsDone);
|
|
long fileLength = new FileInfo(path).Length;
|
|
Assert.True(
|
|
fileLength < BoundedProcessOutputCapture.DefaultMaxBytes + 256,
|
|
$"expected the file to stay near the {BoundedProcessOutputCapture.DefaultMaxBytes}-byte "
|
|
+ $"cap, got {fileLength} bytes");
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendCreatesTheSessionDirectoryOnFirstWrite()
|
|
{
|
|
string directory = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"acdream-406-capture-" + Guid.NewGuid().ToString("N"));
|
|
string path = Path.Combine(directory, "client.err.log");
|
|
Assert.False(Directory.Exists(directory));
|
|
|
|
try
|
|
{
|
|
using var capture = new BoundedProcessOutputCapture(path);
|
|
capture.AppendLine("hello");
|
|
capture.Dispose();
|
|
|
|
Assert.True(File.Exists(path));
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void RawByteAppendsAreConcatenatedWithoutAnImpliedLineBoundary()
|
|
{
|
|
string path = TempPath();
|
|
try
|
|
{
|
|
using var capture = new BoundedProcessOutputCapture(path);
|
|
|
|
capture.Append(Encoding.UTF8.GetBytes("abc"));
|
|
capture.Append(Encoding.UTF8.GetBytes("def"));
|
|
capture.Dispose();
|
|
|
|
Assert.Equal("abcdef", File.ReadAllText(path));
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyAppendsAreNoOps()
|
|
{
|
|
string path = TempPath();
|
|
try
|
|
{
|
|
using var capture = new BoundedProcessOutputCapture(path);
|
|
|
|
capture.Append(ReadOnlySpan<byte>.Empty);
|
|
capture.AppendLine(string.Empty);
|
|
capture.Dispose();
|
|
|
|
// An empty string line still gets its trailing newline —
|
|
// only a genuinely zero-length byte span (or a null line) is
|
|
// a true no-op.
|
|
Assert.Equal("\n", File.ReadAllText(path));
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendAfterDisposeIsASilentNoOp()
|
|
{
|
|
string path = TempPath();
|
|
try
|
|
{
|
|
var capture = new BoundedProcessOutputCapture(path);
|
|
capture.AppendLine("before");
|
|
capture.Dispose();
|
|
|
|
capture.AppendLine("after — must not throw or reopen the file");
|
|
|
|
Assert.Equal("before\n", File.ReadAllText(path));
|
|
}
|
|
finally
|
|
{
|
|
TryDelete(path);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorRejectsANonPositiveMaxBytes()
|
|
{
|
|
Assert.Throws<ArgumentOutOfRangeException>(
|
|
() => new BoundedProcessOutputCapture(TempPath(), maxBytes: 0));
|
|
Assert.Throws<ArgumentOutOfRangeException>(
|
|
() => new BoundedProcessOutputCapture(TempPath(), maxBytes: -1));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorRejectsANullOrBlankPath()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => new BoundedProcessOutputCapture(""));
|
|
Assert.Throws<ArgumentException>(() => new BoundedProcessOutputCapture(" "));
|
|
}
|
|
|
|
private static string TempPath() => Path.Combine(
|
|
Path.GetTempPath(),
|
|
"acdream-406-capture-" + Guid.NewGuid().ToString("N") + ".log");
|
|
|
|
private static void TryDelete(string path)
|
|
{
|
|
try
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
}
|
|
}
|
|
}
|