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,566 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class LegacyManualOperatorDataImporterTests
{
private static readonly string[] DestinationFiles =
[
"개인.dat",
"외국인.dat",
"기관.dat",
"VI발동.dat"
];
[Fact]
public async Task Fixed_source_reads_the_four_original_files_without_modifying_them()
{
using var sourceDirectory = new TemporaryDirectory();
WriteSourceFiles(sourceDirectory.Path);
var before = DestinationFiles.ToDictionary(
name => name,
name => File.ReadAllBytes(Path.Combine(sourceDirectory.Path, name)));
var source = new FixedLegacyManualOperatorDataSource(
sourceDirectory.Path,
TimeSpan.Zero);
var snapshot = await source.ReadAsync();
Assert.Equal(5, snapshot.IndividualRows.Count);
Assert.Equal(5, snapshot.ForeignRows.Count);
Assert.Equal(5, snapshot.InstitutionRows.Count);
Assert.Equal(3, snapshot.ViItems.Count);
Assert.Matches("^[a-f0-9]{64}$", snapshot.SourceSha256);
Assert.All(DestinationFiles, name =>
Assert.Equal(before[name], File.ReadAllBytes(Path.Combine(sourceDirectory.Path, name))));
}
[Fact]
public async Task Import_commits_all_four_files_and_the_versioned_marker_last()
{
using var sourceDirectory = new TemporaryDirectory();
using var destinationDirectory = new TemporaryDirectory();
WriteSourceFiles(sourceDirectory.Path);
PreparePrivate(destinationDirectory.Path);
var now = new DateTimeOffset(2026, 7, 12, 1, 2, 3, TimeSpan.Zero);
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
new FixedLegacyManualOperatorDataSource(sourceDirectory.Path, TimeSpan.Zero),
() => now);
var result = await importer.ImportAsync();
Assert.Equal(1, result.MarkerVersion);
Assert.Equal(15, result.NetSellRowCount);
Assert.Equal(3, result.ViItemCount);
Assert.Equal(now, result.ImportedAt);
Assert.All(DestinationFiles, name =>
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, name))));
var markerPath = Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.MarkerFileName);
Assert.True(File.Exists(markerPath));
using var marker = JsonDocument.Parse(await File.ReadAllBytesAsync(markerPath));
Assert.Equal(1, marker.RootElement.GetProperty("schemaVersion").GetInt32());
Assert.Equal(result.SourceSha256, marker.RootElement.GetProperty("sourceSha256").GetString());
Assert.Matches(
"^[a-f0-9]{64}$",
marker.RootElement.GetProperty("productionSha256").GetString()!);
Assert.Equal(15, marker.RootElement.GetProperty("netSellRowCount").GetInt32());
Assert.Equal(3, marker.RootElement.GetProperty("viItemCount").GetInt32());
Assert.False(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.IntentFileName)));
Assert.Equal(
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
using var netStore = new S5025TrustedManualFileStore(destinationDirectory.Path);
using var viStore = new ViTrustedManualFileStore(destinationDirectory.Path);
Assert.Equal(Rows(), await netStore.ReadAsync(S5025ManualAudience.Individual));
Assert.Equal(3, (await viStore.ReadAsync()).Count);
var serializedResult = JsonSerializer.Serialize(result);
var serializedMarker = await File.ReadAllTextAsync(markerPath);
Assert.DoesNotContain(sourceDirectory.Path, serializedResult, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(sourceDirectory.Path, serializedMarker, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("source\\repos", serializedMarker, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Empty_vi_file_keeps_its_distinct_zero_length_contract_after_import_and_restart()
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
var snapshot = new LegacyManualOperatorSourceSnapshot(
Rows(),
Rows(),
Rows(),
Array.Empty<LegacyManualViItem>(),
new string('c', 64));
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
new CountingSource(snapshot));
var result = await importer.ImportAsync();
Assert.Equal(0, result.ViItemCount);
Assert.Equal(
0,
new FileInfo(Path.Combine(destinationDirectory.Path, "VI발동.dat")).Length);
Assert.Equal(
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
using var viStore = new ViTrustedManualFileStore(destinationDirectory.Path);
Assert.Empty(await viStore.ReadAsync());
}
[Theory]
[InlineData("개인.dat")]
[InlineData("외국인.dat")]
[InlineData("기관.dat")]
[InlineData("VI발동.dat")]
public async Task Any_existing_private_data_file_blocks_the_whole_import_without_overwrite(
string existingFile)
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
var path = Path.Combine(destinationDirectory.Path, existingFile);
var original = new byte[] { 1, 2, 3, 4 };
await File.WriteAllBytesAsync(path, original);
var source = new CountingSource(Snapshot());
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
source);
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
() => importer.ImportAsync());
Assert.Equal(LegacyManualOperatorImportFailure.DestinationNotEmpty, error.Failure);
Assert.False(error.OutcomeUnknown);
Assert.Equal(0, source.ReadCount);
Assert.Equal(original, await File.ReadAllBytesAsync(path));
Assert.Single(Directory.EnumerateFileSystemEntries(destinationDirectory.Path));
}
[Fact]
public async Task Marker_blocks_a_second_import_before_the_source_is_read_again()
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
var source = new CountingSource(Snapshot());
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
source);
_ = await importer.ImportAsync();
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
() => importer.ImportAsync());
Assert.Equal(LegacyManualOperatorImportFailure.AlreadyImported, error.Failure);
Assert.False(error.OutcomeUnknown);
Assert.Equal(1, source.ReadCount);
}
[Fact]
public async Task Preexisting_intent_and_partial_data_fail_closed_before_source_or_store_use()
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
var intentPath = Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.IntentFileName);
await File.WriteAllTextAsync(intentPath, "{}");
var partialPath = Path.Combine(destinationDirectory.Path, DestinationFiles[0]);
var partialBytes = LegacyManualOperatorDataCodec.SerializeNetSell(Rows());
await File.WriteAllBytesAsync(partialPath, partialBytes);
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
destinationDirectory.Path);
var source = new CountingSource(Snapshot());
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
source);
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
() => importer.ImportAsync());
Assert.Equal(LegacyManualOperatorDataRuntimeState.InterruptedImport, inspection.State);
Assert.False(inspection.IsReady);
Assert.Equal(LegacyManualOperatorImportFailure.InterruptedImport, error.Failure);
Assert.True(error.OutcomeUnknown);
Assert.Equal(0, source.ReadCount);
Assert.Equal(partialBytes, await File.ReadAllBytesAsync(partialPath));
Assert.Equal("{}", await File.ReadAllTextAsync(intentPath));
}
[Fact]
public async Task Existing_legacy_files_without_marker_or_intent_remain_runtime_eligible()
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
await File.WriteAllBytesAsync(
Path.Combine(destinationDirectory.Path, DestinationFiles[0]),
LegacyManualOperatorDataCodec.SerializeNetSell(Rows()));
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
destinationDirectory.Path);
Assert.True(inspection.IsReady);
Assert.Equal(
LegacyManualOperatorDataRuntimeState.ReadyLegacyData,
inspection.State);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public async Task Failure_after_each_data_move_rolls_back_data_and_intent(
int failAfterMove)
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
new CountingSource(Snapshot()),
utcNow: null,
afterDataFileMoved: movedCount =>
{
if (movedCount == failAfterMove)
{
throw new IOException("injected move failure");
}
});
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
() => importer.ImportAsync());
Assert.Equal(LegacyManualOperatorImportFailure.ImportFailed, error.Failure);
Assert.False(error.OutcomeUnknown);
Assert.All(DestinationFiles, name =>
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, name))));
Assert.False(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.MarkerFileName)));
Assert.False(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.IntentFileName)));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public async Task Abrupt_termination_after_each_move_leaves_intent_and_blocks_all_reuse(
int crashAfterMove)
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
using (var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
new CountingSource(Snapshot()),
utcNow: null,
afterDataFileMoved: movedCount =>
{
if (movedCount == crashAfterMove)
{
throw new LegacyManualOperatorImportCrashSimulationException();
}
}))
{
await Assert.ThrowsAsync<LegacyManualOperatorImportCrashSimulationException>(
() => importer.ImportAsync());
}
Assert.True(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.IntentFileName)));
Assert.Equal(
crashAfterMove,
DestinationFiles.Count(name => File.Exists(Path.Combine(
destinationDirectory.Path,
name))));
Assert.Equal(
LegacyManualOperatorDataRuntimeState.InterruptedImport,
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
var secondSource = new CountingSource(Snapshot());
using var secondImporter = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
secondSource);
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
() => secondImporter.ImportAsync());
Assert.Equal(LegacyManualOperatorImportFailure.InterruptedImport, error.Failure);
Assert.True(error.OutcomeUnknown);
Assert.Equal(0, secondSource.ReadCount);
}
[Fact]
public async Task Abrupt_termination_after_completed_marker_keeps_stale_intent_fail_closed()
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
using (var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
new CountingSource(Snapshot()),
utcNow: null,
afterDataFileMoved: null,
afterMarkerCommitted: () =>
throw new LegacyManualOperatorImportCrashSimulationException()))
{
await Assert.ThrowsAsync<LegacyManualOperatorImportCrashSimulationException>(
() => importer.ImportAsync());
}
Assert.All(DestinationFiles, name =>
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, name))));
Assert.True(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.MarkerFileName)));
Assert.True(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.IntentFileName)));
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(
destinationDirectory.Path);
Assert.False(inspection.IsReady);
Assert.Equal(LegacyManualOperatorDataRuntimeState.InterruptedImport, inspection.State);
}
[Fact]
public async Task Completed_marker_with_missing_or_modified_data_is_rejected()
{
using var missingDirectory = new TemporaryDirectory();
PreparePrivate(missingDirectory.Path);
using (var importer = new LegacyManualOperatorDataImporter(
missingDirectory.Path,
new CountingSource(Snapshot())))
{
_ = await importer.ImportAsync();
}
File.Delete(Path.Combine(missingDirectory.Path, DestinationFiles[0]));
Assert.Equal(
LegacyManualOperatorDataRuntimeState.InvalidImportedState,
LegacyManualOperatorDataRuntimeGuard.Inspect(missingDirectory.Path).State);
using var modifiedDirectory = new TemporaryDirectory();
PreparePrivate(modifiedDirectory.Path);
using (var importer = new LegacyManualOperatorDataImporter(
modifiedDirectory.Path,
new CountingSource(Snapshot())))
{
_ = await importer.ImportAsync();
}
await File.AppendAllTextAsync(
Path.Combine(modifiedDirectory.Path, DestinationFiles[0]),
"tamper");
Assert.Equal(
LegacyManualOperatorDataRuntimeState.InvalidImportedState,
LegacyManualOperatorDataRuntimeGuard.Inspect(modifiedDirectory.Path).State);
}
[Fact]
public async Task Completed_marker_allows_later_verified_operator_edits()
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
using (var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
new CountingSource(Snapshot())))
{
_ = await importer.ImportAsync();
}
var editedRows = Rows().ToArray();
editedRows[0] = editedRows[0] with { LeftAmount = "9,999" };
using (var store = new S5025TrustedManualFileStore(destinationDirectory.Path))
{
await store.WriteAsync(S5025ManualAudience.Individual, editedRows);
}
Assert.Equal(
LegacyManualOperatorDataRuntimeState.ReadyImportedData,
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
}
[Fact]
public async Task Commit_failure_rolls_back_every_file_created_by_the_operation_without_retry()
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
var source = new CountingSource(Snapshot());
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
source,
utcNow: null,
afterDataFileMoved: movedCount =>
{
if (movedCount == 1)
{
Directory.CreateDirectory(Path.Combine(destinationDirectory.Path, "외국인.dat"));
}
});
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
() => importer.ImportAsync());
Assert.Equal(LegacyManualOperatorImportFailure.ImportFailed, error.Failure);
Assert.False(error.OutcomeUnknown);
Assert.Equal(1, source.ReadCount);
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "개인.dat")));
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "기관.dat")));
Assert.False(File.Exists(Path.Combine(destinationDirectory.Path, "VI발동.dat")));
Assert.False(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.MarkerFileName)));
Assert.Empty(Directory.EnumerateFiles(destinationDirectory.Path, "*.tmp"));
}
[Fact]
public async Task Unsafe_rollback_is_reported_as_outcome_unknown_and_is_never_retried()
{
using var destinationDirectory = new TemporaryDirectory();
PreparePrivate(destinationDirectory.Path);
var source = new CountingSource(Snapshot());
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
source,
utcNow: null,
afterDataFileMoved: movedCount =>
{
if (movedCount != 1)
{
return;
}
File.WriteAllText(Path.Combine(destinationDirectory.Path, "개인.dat"), "tampered");
Directory.CreateDirectory(Path.Combine(destinationDirectory.Path, "외국인.dat"));
});
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
() => importer.ImportAsync());
Assert.Equal(LegacyManualOperatorImportFailure.RollbackFailed, error.Failure);
Assert.True(error.OutcomeUnknown);
Assert.Equal(1, source.ReadCount);
Assert.True(File.Exists(Path.Combine(destinationDirectory.Path, "개인.dat")));
Assert.False(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.MarkerFileName)));
Assert.True(File.Exists(Path.Combine(
destinationDirectory.Path,
LegacyManualOperatorDataImporter.IntentFileName)));
Assert.Equal(
LegacyManualOperatorDataRuntimeState.InterruptedImport,
LegacyManualOperatorDataRuntimeGuard.Inspect(destinationDirectory.Path).State);
}
[Fact]
public async Task Invalid_source_format_creates_no_private_file_and_does_not_disclose_the_path()
{
using var sourceDirectory = new TemporaryDirectory();
using var destinationDirectory = new TemporaryDirectory();
WriteSourceFiles(sourceDirectory.Path);
await File.WriteAllTextAsync(Path.Combine(sourceDirectory.Path, "기관.dat"), "invalid");
PreparePrivate(destinationDirectory.Path);
using var importer = new LegacyManualOperatorDataImporter(
destinationDirectory.Path,
new FixedLegacyManualOperatorDataSource(sourceDirectory.Path, TimeSpan.Zero));
var error = await Assert.ThrowsAsync<LegacyManualOperatorImportException>(
() => importer.ImportAsync());
Assert.Equal(LegacyManualOperatorImportFailure.SourceInvalid, error.Failure);
Assert.DoesNotContain(sourceDirectory.Path, error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Empty(Directory.EnumerateFileSystemEntries(destinationDirectory.Path));
}
private static void WriteSourceFiles(string directory)
{
foreach (var fileName in DestinationFiles.Take(3))
{
File.WriteAllBytes(
Path.Combine(directory, fileName),
LegacyManualOperatorDataCodec.SerializeNetSell(Rows()));
}
File.WriteAllBytes(
Path.Combine(directory, "VI발동.dat"),
LegacyManualOperatorDataCodec.SerializeVi(ViItems()));
}
private static LegacyManualOperatorSourceSnapshot Snapshot() => new(
Rows(),
Rows(),
Rows(),
ViItems(),
new string('a', 64));
private static IReadOnlyList<S5025TrustedManualRow> Rows() =>
[
new("삼성전자", "1,000", "SK하이닉스", "-2,000"),
new("현대차", "2", "기아", "-2"),
new("NAVER", "3", "카카오", "-3"),
new("LG화학", "4", "포스코", "-4"),
new("한화", "5", "셀트리온", "-5")
];
private static IReadOnlyList<LegacyManualViItem> ViItems() =>
[
new("PKR7005930003", "삼성전자"),
new("PKR7016360000", "삼성증권"),
new("PKR7005930003", "삼성전자")
];
private static void PreparePrivate(string path) =>
TrustedManualDirectory.PreparePrivateWritableDirectory(path);
private sealed class CountingSource : ILegacyManualOperatorDataSource
{
private readonly LegacyManualOperatorSourceSnapshot _snapshot;
public CountingSource(LegacyManualOperatorSourceSnapshot snapshot)
{
_snapshot = snapshot;
}
public int ReadCount { get; private set; }
public Task<LegacyManualOperatorSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ReadCount++;
return Task.FromResult(_snapshot);
}
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Cleanup must not hide the test result.
}
}
}
}

View File

@@ -0,0 +1,183 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Tests;
public sealed class NativeOperatorLogTransitionTrackerTests
{
[Fact]
public void DatabaseMapping_CoversEveryClosedHealthState()
{
var expected = new Dictionary<DatabaseHealthState, NativeOperatorDatabaseState>
{
[DatabaseHealthState.NotConfigured] = NativeOperatorDatabaseState.NotConfigured,
[DatabaseHealthState.Healthy] = NativeOperatorDatabaseState.Healthy,
[DatabaseHealthState.Unhealthy] = NativeOperatorDatabaseState.Unhealthy,
[DatabaseHealthState.TimedOut] = NativeOperatorDatabaseState.TimedOut
};
Assert.Equal(Enum.GetValues<DatabaseHealthState>().Length, expected.Count);
foreach (var pair in expected)
{
Assert.True(NativeOperatorLogTransitionTracker.TryMapDatabaseState(
pair.Key,
out var actual));
Assert.Equal(pair.Value, actual);
}
Assert.False(NativeOperatorLogTransitionTracker.TryMapDatabaseState(
(DatabaseHealthState)int.MaxValue,
out _));
}
[Fact]
public void TornadoMapping_CoversEveryClosedConnectionState()
{
var expected = new Dictionary<PlayoutConnectionState, NativeOperatorTornadoState>
{
[PlayoutConnectionState.Disabled] = NativeOperatorTornadoState.Disabled,
[PlayoutConnectionState.DryRunReady] = NativeOperatorTornadoState.DryRunReady,
[PlayoutConnectionState.Disconnected] = NativeOperatorTornadoState.Disconnected,
[PlayoutConnectionState.Connecting] = NativeOperatorTornadoState.Connecting,
[PlayoutConnectionState.Connected] = NativeOperatorTornadoState.Connected,
[PlayoutConnectionState.Reconnecting] = NativeOperatorTornadoState.Reconnecting,
[PlayoutConnectionState.Disconnecting] = NativeOperatorTornadoState.Disconnecting,
[PlayoutConnectionState.Faulted] = NativeOperatorTornadoState.Faulted,
[PlayoutConnectionState.OutcomeUnknown] = NativeOperatorTornadoState.OutcomeUnknown,
[PlayoutConnectionState.Disposed] = NativeOperatorTornadoState.Disposed
};
Assert.Equal(Enum.GetValues<PlayoutConnectionState>().Length, expected.Count);
foreach (var pair in expected)
{
Assert.True(NativeOperatorLogTransitionTracker.TryMapTornadoState(
pair.Key,
out var actual));
Assert.Equal(pair.Value, actual);
}
Assert.False(NativeOperatorLogTransitionTracker.TryMapTornadoState(
(PlayoutConnectionState)int.MaxValue,
out _));
}
[Fact]
public void DatabaseTransitions_SuppressDuplicatesIndependentlyPerProvider()
{
var tracker = new NativeOperatorLogTransitionTracker();
Assert.True(tracker.TryObserveDatabaseState(
DataSourceKind.Oracle,
DatabaseHealthState.Healthy,
out var oracleHealthy));
Assert.Equal(NativeOperatorLogEvent.OracleStateChanged, oracleHealthy.Event);
Assert.Equal(NativeOperatorDatabaseState.Healthy, oracleHealthy.DatabaseState);
Assert.False(tracker.TryObserveDatabaseState(
DataSourceKind.Oracle,
DatabaseHealthState.Healthy,
out _));
Assert.True(tracker.TryObserveDatabaseState(
DataSourceKind.MariaDb,
DatabaseHealthState.Healthy,
out var mariaHealthy));
Assert.Equal(NativeOperatorLogEvent.MariaDbStateChanged, mariaHealthy.Event);
Assert.False(tracker.TryObserveDatabaseState(
DataSourceKind.MariaDb,
DatabaseHealthState.Healthy,
out _));
Assert.True(tracker.TryObserveDatabaseState(
DataSourceKind.Oracle,
DatabaseHealthState.TimedOut,
out var oracleTimedOut));
Assert.Equal(NativeOperatorDatabaseState.TimedOut, oracleTimedOut.DatabaseState);
Assert.False(tracker.TryObserveDatabaseState(
(DataSourceKind)int.MaxValue,
DatabaseHealthState.Healthy,
out _));
}
[Fact]
public void TornadoTransitions_SuppressOnlyIdenticalConsecutiveStates()
{
var tracker = new NativeOperatorLogTransitionTracker();
Assert.True(tracker.TryObserveTornadoState(
PlayoutConnectionState.Disconnected,
out var disconnected));
Assert.Equal(NativeOperatorTornadoState.Disconnected, disconnected.TornadoState);
Assert.False(tracker.TryObserveTornadoState(
PlayoutConnectionState.Disconnected,
out _));
Assert.True(tracker.TryObserveTornadoState(
PlayoutConnectionState.Connecting,
out var connecting));
Assert.Equal(NativeOperatorTornadoState.Connecting, connecting.TornadoState);
Assert.True(tracker.TryObserveTornadoState(
PlayoutConnectionState.Disconnected,
out _));
}
[Fact]
public void CommandTracker_EmitsOneRequestAndExactlyOneMatchingTerminalRecord()
{
var tracker = new NativeOperatorCommandLogTracker();
Assert.True(tracker.TryBegin(
NativeOperatorPlayoutCommand.Prepare,
out var requested));
Assert.Equal(NativeOperatorLogEvent.PlayoutCommandRequested, requested.Event);
Assert.Equal(NativeOperatorPlayoutCommand.Prepare, requested.Command);
Assert.False(tracker.TryComplete(
NativeOperatorPlayoutCommand.Next,
NativeOperatorPlayoutCompletion.Succeeded,
out _));
Assert.True(tracker.TryComplete(
NativeOperatorPlayoutCommand.Prepare,
NativeOperatorPlayoutCompletion.Rejected,
out var completed));
Assert.Equal(NativeOperatorLogEvent.PlayoutCommandCompleted, completed.Event);
Assert.Equal(NativeOperatorPlayoutCompletion.Rejected, completed.Completion);
Assert.False(tracker.TryComplete(
NativeOperatorPlayoutCommand.Prepare,
NativeOperatorPlayoutCompletion.Succeeded,
out _));
Assert.False(tracker.TryMarkCurrentOutcomeUnknown(out _));
}
[Fact]
public void CommandTracker_TimeoutOutcomeWinsAndSuppressesLateCompletion()
{
var tracker = new NativeOperatorCommandLogTracker();
Assert.True(tracker.TryBegin(NativeOperatorPlayoutCommand.TakeIn, out _));
Assert.True(tracker.TryMarkCurrentOutcomeUnknown(out var unknown));
Assert.Equal(NativeOperatorLogEvent.OutcomeUnknown, unknown.Event);
Assert.Equal(NativeOperatorPlayoutCommand.TakeIn, unknown.Command);
Assert.False(tracker.TryComplete(
NativeOperatorPlayoutCommand.TakeIn,
NativeOperatorPlayoutCompletion.Succeeded,
out _));
Assert.True(tracker.TryBegin(NativeOperatorPlayoutCommand.Next, out _));
Assert.True(tracker.TryMarkOutcomeUnknown(
NativeOperatorPlayoutCommand.Next,
out var secondUnknown));
Assert.Equal(NativeOperatorLogEvent.OutcomeUnknown, secondUnknown.Event);
}
[Fact]
public void CommandTracker_UndefinedScalarsFailClosed()
{
var tracker = new NativeOperatorCommandLogTracker();
Assert.False(tracker.TryBegin((NativeOperatorPlayoutCommand)int.MaxValue, out _));
Assert.False(tracker.TryComplete(
NativeOperatorPlayoutCommand.Prepare,
(NativeOperatorPlayoutCompletion)int.MaxValue,
out _));
Assert.False(tracker.TryMarkOutcomeUnknown(
(NativeOperatorPlayoutCommand)int.MaxValue,
out _));
}
}

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.
}
}
}
}

View File

@@ -79,6 +79,52 @@ public sealed class OperatorCatalogMutationExecutorTests
Assert.All(plan.Commands, command => Assert.Contains('@', command.Sql));
}
[Theory]
[InlineData("123", ThemeMarket.Krx)]
[InlineData("0123", ThemeMarket.Krx)]
[InlineData("001234", ThemeMarket.Krx)]
[InlineData("00001234", ThemeMarket.Krx)]
[InlineData("123", ThemeMarket.Nxt)]
[InlineData("0123", ThemeMarket.Nxt)]
[InlineData("001234", ThemeMarket.Nxt)]
[InlineData("00001234", ThemeMarket.Nxt)]
public async Task ExecuteTransactionAsync_ReplaceAndDeleteAcceptLegacyThemeIdentityLengths(
string code,
ThemeMarket market)
{
var source = market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb;
var session = market == ThemeMarket.Krx
? ThemeSession.NotApplicable
: ThemeSession.PreMarket;
var identity = new ThemeSelectionIdentity(market, session, code, "기존 테마");
var replacePlan = new CatalogMutationConnectionPlan();
replacePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
replacePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
var replaceFactory = new CatalogMutationConnectionFactory(replacePlan);
var replaceService = new LegacyThemeCatalogPersistenceService(
new CatalogNoQueryExecutor(),
CreateExecutor(replaceFactory));
await replaceService.ReplaceAsync(identity, "변경 테마", []);
Assert.Equal(source, replaceFactory.LastSource);
Assert.Equal(1, replacePlan.CommitCount);
var deletePlan = new CatalogMutationConnectionPlan();
deletePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(0));
deletePlan.NonQuerySteps.Enqueue(_ => Task.FromResult(1));
var deleteFactory = new CatalogMutationConnectionFactory(deletePlan);
var deleteService = new LegacyThemeCatalogPersistenceService(
new CatalogNoQueryExecutor(),
CreateExecutor(deleteFactory));
await deleteService.DeleteAsync(identity);
Assert.Equal(source, deleteFactory.LastSource);
Assert.Equal(1, deletePlan.CommitCount);
}
[Fact]
public async Task ExecuteTransactionAsync_AffectedRowConflictRollsBackBeforeNextCommand()
{