Migrate remaining legacy operator workflows

This commit is contained in:
2026-07-12 05:39:27 +09:00
parent ffb8f43c19
commit a01836a2d7
132 changed files with 20566 additions and 720 deletions

View File

@@ -0,0 +1,307 @@
using System.Reflection;
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class NativeOperatorLogWriterTests
{
private static readonly DateTimeOffset FixedUtcNow =
new(2026, 7, 12, 3, 4, 5, 678, TimeSpan.Zero);
[Fact]
public void TryWrite_WritesOnlyClosedEventsToTheFixedLocalApplicationDataPath()
{
using var scope = TemporaryDirectory.Create();
var writer = CreateWriter(scope.Path);
var records = new[]
{
NativeOperatorLogRecord.ForStartup(),
NativeOperatorLogRecord.ForShutdown(),
NativeOperatorLogRecord.ForOracleStateChanged(NativeOperatorDatabaseState.Healthy),
NativeOperatorLogRecord.ForMariaDbStateChanged(NativeOperatorDatabaseState.Unhealthy),
NativeOperatorLogRecord.ForTornadoStateChanged(NativeOperatorTornadoState.Connected),
NativeOperatorLogRecord.ForPlayoutCommandRequested(NativeOperatorPlayoutCommand.Prepare),
NativeOperatorLogRecord.ForPlayoutCommandCompleted(
NativeOperatorPlayoutCommand.Prepare,
NativeOperatorPlayoutCompletion.Succeeded),
NativeOperatorLogRecord.ForOutcomeUnknown(NativeOperatorPlayoutCommand.TakeIn)
};
Assert.All(records, record => Assert.True(writer.TryWrite(record)));
var expectedPath = Path.Combine(
scope.Path,
"MBN_STOCK_WEBVIEW",
"Logs",
"Log_0712.log");
Assert.True(File.Exists(expectedPath));
Assert.Equal(
new[]
{
"[2026-07-12T03:04:05.678+00:00] event=Startup",
"[2026-07-12T03:04:05.678+00:00] event=Shutdown",
"[2026-07-12T03:04:05.678+00:00] event=OracleStateChanged state=Healthy",
"[2026-07-12T03:04:05.678+00:00] event=MariaDbStateChanged state=Unhealthy",
"[2026-07-12T03:04:05.678+00:00] event=TornadoStateChanged state=Connected",
"[2026-07-12T03:04:05.678+00:00] event=PlayoutCommandRequested command=Prepare",
"[2026-07-12T03:04:05.678+00:00] event=PlayoutCommandCompleted command=Prepare result=Succeeded",
"[2026-07-12T03:04:05.678+00:00] event=OutcomeUnknown command=TakeIn"
},
File.ReadAllLines(expectedPath));
}
[Fact]
public void PublicApi_CannotAcceptFreeFormTextPathsSqlExceptionsOrSceneData()
{
Assert.Equal(
new[]
{
"Startup",
"Shutdown",
"OracleStateChanged",
"MariaDbStateChanged",
"TornadoStateChanged",
"PlayoutCommandRequested",
"PlayoutCommandCompleted",
"OutcomeUnknown"
},
Enum.GetNames<NativeOperatorLogEvent>());
var recordType = typeof(NativeOperatorLogRecord);
Assert.All(
recordType.GetProperties(BindingFlags.Instance | BindingFlags.Public),
property =>
{
var scalarType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType;
Assert.True(scalarType.IsEnum, $"{property.Name} must remain a closed enum scalar.");
});
Assert.DoesNotContain(
recordType.GetConstructors(BindingFlags.Instance | BindingFlags.Public),
constructor => constructor.GetParameters().Length > 0);
var factories = recordType.GetMethods(BindingFlags.Static | BindingFlags.Public)
.Where(method => method.Name.StartsWith("For", StringComparison.Ordinal))
.ToArray();
Assert.Equal(8, factories.Length);
Assert.All(
factories.SelectMany(factory => factory.GetParameters()),
parameter => Assert.True(parameter.ParameterType.IsEnum));
var publicWriterConstructors = typeof(NativeOperatorLogWriter)
.GetConstructors(BindingFlags.Instance | BindingFlags.Public);
var constructor = Assert.Single(publicWriterConstructors);
Assert.Empty(constructor.GetParameters());
var tryWrite = typeof(INativeOperatorLogWriter).GetMethod(nameof(INativeOperatorLogWriter.TryWrite));
Assert.NotNull(tryWrite);
Assert.Equal(
new[] { typeof(NativeOperatorLogRecord) },
tryWrite.GetParameters().Select(parameter => parameter.ParameterType));
}
[Fact]
public void TryWrite_InvalidDefaultAndUndefinedEnumsFailClosedWithoutCreatingALog()
{
using var scope = TemporaryDirectory.Create();
var writer = CreateWriter(scope.Path);
Assert.False(writer.TryWrite(default));
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForOracleStateChanged(
(NativeOperatorDatabaseState)int.MaxValue)));
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForPlayoutCommandCompleted(
NativeOperatorPlayoutCommand.Prepare,
(NativeOperatorPlayoutCompletion)int.MaxValue)));
Assert.False(Directory.Exists(Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW")));
}
[Fact]
public void TryWrite_AcceptsEveryDefinedClosedScalarAndNoUnmappedRuntimeState()
{
using var scope = TemporaryDirectory.Create();
var writer = CreateWriter(scope.Path);
foreach (var state in Enum.GetValues<NativeOperatorDatabaseState>())
{
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForOracleStateChanged(state)));
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForMariaDbStateChanged(state)));
}
foreach (var state in Enum.GetValues<NativeOperatorTornadoState>())
{
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForTornadoStateChanged(state)));
}
foreach (var command in Enum.GetValues<NativeOperatorPlayoutCommand>())
{
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForPlayoutCommandRequested(command)));
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForOutcomeUnknown(command)));
foreach (var completion in Enum.GetValues<NativeOperatorPlayoutCompletion>())
{
Assert.True(writer.TryWrite(
NativeOperatorLogRecord.ForPlayoutCommandCompleted(command, completion)));
}
}
}
[Fact]
public void TryWrite_StopsAtTheConfiguredFileSizeWithoutChangingExistingBytes()
{
using var scope = TemporaryDirectory.Create();
var initialWriter = CreateWriter(scope.Path);
Assert.True(initialWriter.TryWrite(NativeOperatorLogRecord.ForStartup()));
var path = LogPath(scope.Path);
var original = File.ReadAllBytes(path);
var boundedWriter = CreateWriter(scope.Path, maximumFileBytes: original.Length);
Assert.False(boundedWriter.TryWrite(NativeOperatorLogRecord.ForShutdown()));
Assert.Equal(original, File.ReadAllBytes(path));
}
[Fact]
public void TryWrite_DeletesOnlyExpiredClosedLogNamesAndKeepsRecentOrUnrelatedFiles()
{
using var scope = TemporaryDirectory.Create();
var logDirectory = Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW", "Logs");
Directory.CreateDirectory(logDirectory);
var expired = Path.Combine(logDirectory, "Log_0601.log");
var recent = Path.Combine(logDirectory, "Log_0711.log");
var unrelated = Path.Combine(logDirectory, "operator-notes.txt");
File.WriteAllText(expired, "expired");
File.WriteAllText(recent, "recent");
File.WriteAllText(unrelated, "unrelated");
File.SetLastWriteTimeUtc(expired, FixedUtcNow.UtcDateTime.AddDays(-12));
File.SetLastWriteTimeUtc(recent, FixedUtcNow.UtcDateTime.AddDays(-1));
File.SetLastWriteTimeUtc(unrelated, FixedUtcNow.UtcDateTime.AddDays(-100));
var writer = CreateWriter(scope.Path, retentionDays: 10);
Assert.True(writer.TryWrite(NativeOperatorLogRecord.ForStartup()));
Assert.False(File.Exists(expired));
Assert.True(File.Exists(recent));
Assert.True(File.Exists(unrelated));
Assert.True(File.Exists(LogPath(scope.Path)));
}
[Fact]
public async Task TryWrite_ConcurrentWritersProduceCompleteNonInterleavedLines()
{
using var scope = TemporaryDirectory.Create();
var writers = Enumerable.Range(0, 4)
.Select(_ => CreateWriter(scope.Path))
.ToArray();
const int taskCount = 8;
const int writesPerTask = 40;
var tasks = Enumerable.Range(0, taskCount)
.Select(taskIndex => Task.Run(() =>
{
var writer = writers[taskIndex % writers.Length];
for (var index = 0; index < writesPerTask; index++)
{
if (!writer.TryWrite(NativeOperatorLogRecord.ForTornadoStateChanged(
NativeOperatorTornadoState.Connected)))
{
return false;
}
}
return true;
}))
.ToArray();
var results = await Task.WhenAll(tasks);
Assert.All(results, Assert.True);
var lines = File.ReadAllLines(LogPath(scope.Path));
Assert.Equal(taskCount * writesPerTask, lines.Length);
Assert.All(
lines,
line => Assert.Equal(
"[2026-07-12T03:04:05.678+00:00] event=TornadoStateChanged state=Connected",
line));
}
[Fact]
public void TryWrite_UnsafeDirectoryLayoutsAndReparseAttributesFailWithoutThrowing()
{
using var scope = TemporaryDirectory.Create();
File.WriteAllText(Path.Combine(scope.Path, "MBN_STOCK_WEBVIEW"), "not a directory");
var writer = CreateWriter(scope.Path);
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForStartup()));
Assert.False(NativeOperatorLogWriter.IsSafeDirectoryAttributes(
FileAttributes.Directory | FileAttributes.ReparsePoint));
Assert.False(NativeOperatorLogWriter.IsSafeFileAttributes(
FileAttributes.Normal | FileAttributes.ReparsePoint));
Assert.False(NativeOperatorLogWriter.IsSafeFileAttributes(FileAttributes.Directory));
Assert.True(NativeOperatorLogWriter.IsSafeDirectoryAttributes(FileAttributes.Directory));
Assert.True(NativeOperatorLogWriter.IsSafeFileAttributes(FileAttributes.Normal));
}
[Fact]
public void TryWrite_MalformedInternalRootIsAlwaysBestEffort()
{
var writer = new NativeOperatorLogWriter(
"relative-root",
new FixedTimeProvider(FixedUtcNow),
NativeOperatorLogWriter.DefaultMaximumFileBytes,
NativeOperatorLogWriter.DefaultRetentionDays);
var exception = Record.Exception(() =>
Assert.False(writer.TryWrite(NativeOperatorLogRecord.ForStartup())));
Assert.Null(exception);
}
private static NativeOperatorLogWriter CreateWriter(
string localApplicationDataRoot,
int maximumFileBytes = NativeOperatorLogWriter.DefaultMaximumFileBytes,
int retentionDays = NativeOperatorLogWriter.DefaultRetentionDays) =>
new(
localApplicationDataRoot,
new FixedTimeProvider(FixedUtcNow),
maximumFileBytes,
retentionDays);
private static string LogPath(string localApplicationDataRoot) =>
Path.Combine(localApplicationDataRoot, "MBN_STOCK_WEBVIEW", "Logs", "Log_0712.log");
private sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
private sealed class TemporaryDirectory : IDisposable
{
private TemporaryDirectory(string path)
{
Path = path;
}
public string Path { get; }
public static TemporaryDirectory Create()
{
var path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"MBN_STOCK_WEBVIEW-log-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(path);
return new TemporaryDirectory(path);
}
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch
{
// Test cleanup is best effort.
}
}
}
}