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

8
.gitignore vendored
View File

@@ -20,6 +20,10 @@ BundleArtifacts/
*.pfx
*.p12
*.snk
*.cer
*.crt
*.key
*.pem
# Local settings and secrets
Config/appsettings.local.json
@@ -42,8 +46,12 @@ artifacts/K3DInterop/
**/K3DAsyncEngine.tlb
*.t2s
*.k3s
*.vrv
*.lic
K3D*.lic
Tornado*.lic
**/Cuts/
**/배경/
# Web tooling (if introduced later)
node_modules/

View File

@@ -1,3 +1,5 @@
using Microsoft.Windows.AppLifecycle;
namespace MBN_STOCK_WEBVIEW;
/// <summary>
@@ -5,16 +7,53 @@ namespace MBN_STOCK_WEBVIEW;
/// </summary>
public partial class App : Application
{
private const string MainInstanceKey = "Wickedness.MBNStockWebView.Main";
private Window? _window;
private AppInstance? _mainInstance;
public App()
{
InitializeComponent();
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
var registeredInstance = AppInstance.FindOrRegisterForKey(MainInstanceKey);
if (!registeredInstance.IsCurrent)
{
try
{
var activation = AppInstance.GetCurrent().GetActivatedEventArgs();
if (activation is not null)
{
await registeredInstance.RedirectActivationToAsync(activation);
}
}
finally
{
// The secondary process owns no DB, WebView, or playout runtime. End it only
// after the activation has been handed to the registered primary instance.
Exit();
}
return;
}
_mainInstance = registeredInstance;
_mainInstance.Activated += OnMainInstanceActivated;
_window = new MainWindow();
_window.Activate();
}
private void OnMainInstanceActivated(object? sender, AppActivationArguments args)
{
var window = _window;
if (window is null)
{
return;
}
window.DispatcherQueue.TryEnqueue(window.Activate);
}
}

View File

@@ -12,6 +12,7 @@
"legacySceneBackgroundAssetPath": null,
"legacySceneBackgroundVideoLoopCount": 2004,
"legacySceneBackgroundVideoLoopInfinite": true,
"legacyBackgroundDirectory": null,
"testProcessWindowTitlePattern": null,
"testSceneAllowlist": [],
"trustedLiveOutputEnabled": false,

View File

@@ -567,11 +567,13 @@ internal static class LegacySceneRuntimeFactory
ParseNxtSession(selection.GraphicType),
page),
"PAGED_THEME" => new ThemePagedQuoteLoadRequest(
RequireLookupValue(selection.DataCode, "theme code"),
RequireLookupValue(selection.Subject, "theme"),
ParseThemeSort(selection.GraphicType),
ParseThemeValueKind(selection.Subtype),
page),
"PAGED_NXT_THEME" => new NxtThemePagedQuoteLoadRequest(
RequireLookupValue(selection.DataCode, "NXT theme code"),
RequireLookupValue(selection.Subject, "NXT theme"),
ParseThemeSort(selection.Subtype),
ParseNxtSession(selection.GraphicType),

View File

@@ -25,9 +25,9 @@
<PublishProfile>Properties\PublishProfiles\win-x64.pubxml</PublishProfile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<ApplicationDisplayVersion>1.0.2</ApplicationDisplayVersion>
<ApplicationVersion>3</ApplicationVersion>
<Version>1.0.2</Version>
<ApplicationDisplayVersion>1.0.3</ApplicationDisplayVersion>
<ApplicationVersion>4</ApplicationVersion>
<Version>1.0.3</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -9,9 +9,6 @@ namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private static readonly IReadOnlySet<string> OperatorBackgroundExtensions =
new HashSet<string>([".vrv", ".jpg", ".jpeg", ".png"],
StringComparer.OrdinalIgnoreCase);
private int _backgroundSelectionInFlight;
private void HandleBackgroundStatusRequest(JsonElement payload)
@@ -50,9 +47,25 @@ public sealed partial class MainWindow
return;
}
var source = _legacyCompositionOptionsSource ??
throw new InvalidOperationException("Background composition is unavailable.");
// Original btnback_Click assigns 배경\기본.vrv before opening the dialog.
// Keep that behavior only when the default passes all trusted-file checks.
try
{
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
_playoutOptions!,
source.Current.FadeDuration));
}
catch (PlayoutConfigurationException)
{
// F2 may still select another valid file from the same trusted root.
}
var picker = new FileOpenPicker
{
SuggestedStartLocation = PickerLocationId.DocumentsLibrary,
SuggestedStartLocation = PickerLocationId.PicturesLibrary,
ViewMode = PickerViewMode.List
};
picker.FileTypeFilter.Add(".vrv");
@@ -74,14 +87,14 @@ public sealed partial class MainWindow
}
var composition = CreateOperatorBackgroundComposition(selected.Path);
_legacyCompositionOptionsSource!.Update(composition);
_lastEnabledLegacyComposition = composition;
source.Update(composition);
message = "선택한 배경을 다음 PREPARE부터 사용합니다.";
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException)
exception is ArgumentException or IOException or UnauthorizedAccessException or
PlayoutConfigurationException)
{
error = "배경 파일은 설정된 컷 루트 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다.";
error = "배경은 신뢰 배경 폴더 안의 일반 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다.";
}
catch
{
@@ -133,18 +146,21 @@ public sealed partial class MainWindow
var current = source.Current;
if (current.BackgroundKind == LegacySceneBackgroundKind.None)
{
if (_lastEnabledLegacyComposition is null)
try
{
error = "켜 둘 배경 파일이 없습니다. 먼저 F2로 배경 파일을 선택하세요.";
source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault(
_playoutOptions!,
current.FadeDuration));
message = "기본 배경을 켰습니다. 다음 PREPARE부터 반영됩니다.";
}
catch (PlayoutConfigurationException)
{
error = "신뢰 배경 폴더의 기본.vrv를 확인할 수 없습니다. F2로 유효한 배경을 선택하세요.";
return;
}
source.Update(_lastEnabledLegacyComposition);
message = "배경 사용을 켰습니다. 다음 PREPARE부터 반영됩니다.";
}
else
{
_lastEnabledLegacyComposition = current;
source.Update(new LegacySceneCueCompositionOptions(
current.FadeDuration,
LegacySceneBackgroundKind.None));
@@ -178,42 +194,18 @@ public sealed partial class MainWindow
throw new InvalidOperationException("Playout options are unavailable.");
var source = _legacyCompositionOptionsSource ??
throw new InvalidOperationException("Background composition is unavailable.");
var rootText = options.SceneDirectory?.Trim();
if (string.IsNullOrWhiteSpace(rootText) || !Path.IsPathFullyQualified(rootText) ||
string.IsNullOrWhiteSpace(selectedPath) || !Path.IsPathFullyQualified(selectedPath))
{
throw new ArgumentException("A trusted scene root and background are required.");
}
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText));
var candidate = Path.GetFullPath(selectedPath);
var rootPrefix = root + Path.DirectorySeparatorChar;
var extension = Path.GetExtension(candidate);
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!OperatorBackgroundExtensions.Contains(extension))
{
throw new ArgumentException("The selected background is outside the trusted scene root.");
}
var kind = string.Equals(extension, ".vrv", StringComparison.OrdinalIgnoreCase)
? LegacySceneBackgroundKind.Video
: LegacySceneBackgroundKind.Texture;
var validationOptions = new PlayoutOptions
{
SceneDirectory = root,
LegacySceneFadeDuration = source.Current.FadeDuration,
LegacySceneBackgroundKind = kind,
LegacySceneBackgroundAssetPath = Path.GetRelativePath(root, candidate),
LegacySceneBackgroundVideoLoopCount = options.LegacySceneBackgroundVideoLoopCount,
LegacySceneBackgroundVideoLoopInfinite = options.LegacySceneBackgroundVideoLoopInfinite
};
return PlayoutSceneCompositionFactory.Create(validationOptions);
return PlayoutSceneCompositionFactory.CreateOperatorSelection(
options,
source.Current.FadeDuration,
selectedPath);
}
private bool CanChangeLegacyBackground(bool allowBackgroundOperation = false)
{
if (_playoutOptions is null || _legacyCompositionOptionsSource is null ||
string.IsNullOrWhiteSpace(_playoutOptions.SceneDirectory) ||
!PlayoutSceneCompositionFactory.TryGetTrustedBackgroundDirectory(
_playoutOptions,
out _) ||
IsBrowserCorrelationQuarantined() ||
(!allowBackgroundOperation &&
Volatile.Read(ref _backgroundSelectionInFlight) != 0) ||
@@ -251,6 +243,26 @@ public sealed partial class MainWindow
var current = _legacyCompositionOptionsSource?.Current ??
LegacySceneCueCompositionOptions.Default;
var enabled = current.BackgroundKind != LegacySceneBackgroundKind.None;
var rootAvailable = _playoutOptions is not null &&
PlayoutSceneCompositionFactory.TryGetTrustedBackgroundDirectory(
_playoutOptions,
out _);
var defaultAvailable = false;
if (rootAvailable && _playoutOptions is not null)
{
try
{
_ = PlayoutSceneCompositionFactory.CreateLegacyDefault(
_playoutOptions,
current.FadeDuration);
defaultAvailable = true;
}
catch (PlayoutConfigurationException)
{
// F2 remains available for another valid file in the trusted root.
}
}
PostMessage("background-status", new
{
requestId,
@@ -263,9 +275,13 @@ public sealed partial class MainWindow
},
fileName = enabled ? Path.GetFileName(current.BackgroundAssetPath) : string.Empty,
canChange = CanChangeLegacyBackground(),
message = message ?? (enabled
message = message ?? (!rootAvailable
? "신뢰 배경 폴더를 찾을 수 없어 F2/F3 배경 기능을 잠갔습니다."
: enabled
? "선택한 배경은 다음 PREPARE부터 적용됩니다."
: "배경 사용 안 함")
: defaultAvailable
? "배경 사용 안 함"
: "기본.vrv가 없습니다. F2로 신뢰 배경 폴더의 파일을 선택하세요.")
});
}

View File

@@ -0,0 +1,124 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using Microsoft.UI.Windowing;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private AppWindow? _closeConfirmationAppWindow;
private int _closeConfirmationPending;
private int _closeConfirmed;
private void EnsureCloseConfirmationAttached()
{
if (_closeConfirmationAppWindow is not null)
{
return;
}
try
{
var windowHandle = WindowNative.GetWindowHandle(this);
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Closing += OnAppWindowClosing;
_closeConfirmationAppWindow = appWindow;
}
catch
{
// Root.Loaded retries the attachment after the native window has been created.
}
}
private async void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args)
{
if (Volatile.Read(ref _closeConfirmed) != 0)
{
return;
}
// AppWindow.Closing cannot be deferred. Cancel this close synchronously and issue a
// second, explicitly confirmed close only after the modal result is known.
args.Cancel = true;
if (Interlocked.CompareExchange(ref _closeConfirmationPending, 1, 0) != 0)
{
return;
}
try
{
if (Root.XamlRoot is null)
{
return;
}
var dialog = new ContentDialog
{
XamlRoot = Root.XamlRoot,
Title = "프로그램 종료",
Content = BuildCloseConfirmationMessage(),
PrimaryButtonText = "종료",
CloseButtonText = "취소",
DefaultButton = ContentDialogButton.Close
};
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary)
{
return;
}
Interlocked.Exchange(ref _closeConfirmed, 1);
Close();
}
catch
{
// If the confirmation surface cannot be shown, fail closed and keep the app open.
}
finally
{
Interlocked.Exchange(ref _closeConfirmationPending, 0);
}
}
private string BuildCloseConfirmationMessage()
{
var status = _playoutEngine?.Status;
var hasPreparedScene = !string.IsNullOrWhiteSpace(status?.PreparedSceneName);
var hasOnAirScene = !string.IsNullOrWhiteSpace(status?.OnAirSceneName);
var commandPending = Volatile.Read(ref _playoutCommandInFlight) != 0 ||
status?.IsPlayCompletionPending == true;
var outcomeUnknown = Volatile.Read(ref _browserCorrelationQuarantined) != 0 ||
status?.State == PlayoutConnectionState.OutcomeUnknown;
if (outcomeUnknown)
{
return "송출 결과를 확정할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 종료하세요. " +
"종료 과정에서 TAKE OUT 또는 다른 송출 명령을 자동으로 반복하지 않습니다. 그래도 종료하시겠습니까?";
}
if (commandPending)
{
return "송출 명령 또는 callback 처리가 진행 중입니다. 결과를 확인한 뒤 종료하는 것이 안전합니다. " +
"그래도 종료하시겠습니까?";
}
if (hasOnAirScene || hasPreparedScene)
{
return "PREPARED 또는 PROGRAM 장면이 남아 있습니다. 필요한 경우 먼저 TAKE OUT을 실행하고 " +
"PGM을 확인하세요. 종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?";
}
return "프로그램을 종료하시겠습니까?";
}
private void DetachCloseConfirmation()
{
var appWindow = Interlocked.Exchange(ref _closeConfirmationAppWindow, null);
if (appWindow is not null)
{
appWindow.Closing -= OnAppWindowClosing;
}
}
}

View File

@@ -0,0 +1,307 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private LegacyComparisonPairImportService? _legacyComparisonImportService;
private CancellationTokenSource? _legacyComparisonImportCancellation;
private bool TryHandleLegacyComparisonImportRequest(
string? requestType,
JsonElement payload)
{
if (!string.Equals(
requestType,
"import-legacy-comparison-pairs",
StringComparison.Ordinal))
{
return false;
}
var requestId = string.Empty;
var hasSafeRequestId = payload.ValueKind == JsonValueKind.Object &&
TryGetSafeToken(
payload,
"requestId",
MaximumPlayoutRequestIdLength,
out requestId);
if (!hasSafeRequestId || !HasOnlyProperties(payload, "requestId"))
{
PostLegacyComparisonImportError(
hasSafeRequestId ? requestId : string.Empty,
"INVALID_REQUEST",
"원본 비교쌍 가져오기 요청 형식이 올바르지 않습니다.");
return true;
}
_ = HandleLegacyComparisonImportAsync(requestId);
return true;
}
private async Task HandleLegacyComparisonImportAsync(string requestId)
{
var runtime = _databaseRuntime;
if (runtime is null)
{
PostLegacyComparisonImportError(
requestId,
"DATABASE_UNAVAILABLE",
"현재 종목 마스터 DB를 사용할 수 없어 원본 비교쌍을 검증할 수 없습니다.");
return;
}
LegacyComparisonPairImportService service;
try
{
service = _legacyComparisonImportService ??=
new LegacyComparisonPairImportService(
runtime.Executor,
TrustedLegacyComparisonFileSource.CreateDefault());
}
catch (LegacyComparisonImportException exception)
{
PostLegacyComparisonImportFailure(requestId, exception.Failure);
return;
}
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
_lifetimeCancellation.Token);
var previous = Interlocked.Exchange(
ref _legacyComparisonImportCancellation,
cancellation);
CancelRequest(previous);
var gateEntered = false;
try
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
PostLegacyComparisonImportErrorIfCurrent(
requestId,
cancellation,
"PLAYOUT_PRIORITY",
"송출 명령이 우선 처리 중이므로 가져오기를 실행하지 않았습니다.");
return;
}
await _databaseActivityGate.WaitAsync(cancellation.Token);
gateEntered = true;
cancellation.Token.ThrowIfCancellationRequested();
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
PostLegacyComparisonImportErrorIfCurrent(
requestId,
cancellation,
"PLAYOUT_PRIORITY",
"송출 명령이 우선 처리 중이므로 가져오기를 실행하지 않았습니다.");
return;
}
var result = await service.ImportAsync(cancellation.Token);
cancellation.Token.ThrowIfCancellationRequested();
if (!IsCurrentLegacyComparisonImport(cancellation))
{
return;
}
PostMessage("legacy-comparison-import-results", new
{
requestId,
result.SourceSha256,
result.SourceRowCount,
result.RetrievedAt,
pairs = result.Pairs.Select(pair => new
{
pair.RowNumber,
first = ToLegacyComparisonImportWireTarget(pair.First),
second = ToLegacyComparisonImportWireTarget(pair.Second)
})
});
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
// A newer explicit import, navigation, playout priority, or shutdown
// owns the DB slot. Never retry or publish a stale result.
PostPlayoutPriorityLegacyComparisonImportCancellationIfCurrent(
requestId,
cancellation);
}
catch (DatabaseOperationException) when (cancellation.IsCancellationRequested)
{
// Providers may surface a native exception after cancellation.
PostPlayoutPriorityLegacyComparisonImportCancellationIfCurrent(
requestId,
cancellation);
}
catch (Exception) when (cancellation.IsCancellationRequested)
{
// Preserve correlation loss and shutdown semantics.
PostPlayoutPriorityLegacyComparisonImportCancellationIfCurrent(
requestId,
cancellation);
}
catch (LegacyComparisonImportException exception)
{
if (IsCurrentLegacyComparisonImport(cancellation))
{
PostLegacyComparisonImportFailure(requestId, exception.Failure);
}
}
catch (DatabaseOperationException)
{
if (IsCurrentLegacyComparisonImport(cancellation))
{
PostLegacyComparisonImportError(
requestId,
"DATABASE_UNAVAILABLE",
"현재 종목 마스터 DB에서 원본 비교쌍을 검증하지 못했습니다.");
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
}
catch
{
if (IsCurrentLegacyComparisonImport(cancellation))
{
PostLegacyComparisonImportError(
requestId,
"IMPORT_FAILED",
"원본 비교쌍 가져오기를 안전하게 완료하지 못했습니다.");
}
}
finally
{
if (gateEntered)
{
_databaseActivityGate.Release();
}
Interlocked.CompareExchange(
ref _legacyComparisonImportCancellation,
null,
cancellation);
cancellation.Dispose();
}
}
private bool IsCurrentLegacyComparisonImport(CancellationTokenSource cancellation) =>
!_lifetimeCancellation.IsCancellationRequested &&
ReferenceEquals(
Volatile.Read(ref _legacyComparisonImportCancellation),
cancellation);
private void PostLegacyComparisonImportErrorIfCurrent(
string requestId,
CancellationTokenSource cancellation,
string code,
string message)
{
if (IsCurrentLegacyComparisonImport(cancellation))
{
PostLegacyComparisonImportError(requestId, code, message);
}
}
private void PostPlayoutPriorityLegacyComparisonImportCancellationIfCurrent(
string requestId,
CancellationTokenSource cancellation)
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0 &&
IsCurrentLegacyComparisonImport(cancellation))
{
PostLegacyComparisonImportError(
requestId,
"PLAYOUT_PRIORITY",
"송출 명령을 우선 처리하기 위해 원본 비교쌍 가져오기를 취소했습니다. 자동 재시도하지 않습니다.");
}
}
private void PostLegacyComparisonImportFailure(
string requestId,
LegacyComparisonImportFailure failure)
{
var (code, message) = failure switch
{
LegacyComparisonImportFailure.SourceUnavailable => (
"SOURCE_UNAVAILABLE",
"고정된 원본 위치의 종목비교.dat를 안전하게 읽을 수 없습니다."),
LegacyComparisonImportFailure.SourceInvalid => (
"SOURCE_INVALID",
"원본 종목비교.dat가 정확한 CP949 9필드 형식과 일치하지 않습니다."),
LegacyComparisonImportFailure.IdentityNotFound => (
"IDENTITY_NOT_FOUND",
"원본 비교쌍의 종목 또는 시장 식별자를 현재 DB에서 정확히 확인하지 못했습니다."),
LegacyComparisonImportFailure.IdentityAmbiguous => (
"IDENTITY_AMBIGUOUS",
"원본 비교쌍의 종목 식별자가 현재 DB에서 둘 이상 확인되어 가져오기를 중단했습니다."),
LegacyComparisonImportFailure.DatabaseDataInvalid => (
"DATABASE_DATA_INVALID",
"현재 종목 마스터 조회 결과가 엄격한 식별자 형식과 일치하지 않습니다."),
_ => (
"IMPORT_FAILED",
"원본 비교쌍 가져오기를 안전하게 완료하지 못했습니다.")
};
PostLegacyComparisonImportError(requestId, code, message);
}
private void PostLegacyComparisonImportError(
string requestId,
string code,
string message)
{
PostMessage("legacy-comparison-import-error", new
{
requestId,
code,
message
});
}
private static object ToLegacyComparisonImportWireTarget(
LegacyComparisonImportTarget target) => target.Kind switch
{
LegacyComparisonImportTargetKind.MarketTarget => new
{
kind = "market-target",
target = target.MarketTarget
},
LegacyComparisonImportTargetKind.KrxStock => new
{
kind = "krx-stock",
market = target.Market,
stockName = target.StockName,
stockCode = target.StockCode
},
LegacyComparisonImportTargetKind.NxtStock => new
{
kind = "nxt-stock",
market = target.Market,
stockName = target.StockName,
stockCode = target.StockCode
},
LegacyComparisonImportTargetKind.WorldStock => new
{
kind = "world-stock",
inputName = target.InputName,
symbol = target.Symbol,
nation = target.Nation
},
_ => throw new InvalidOperationException(
"The comparison import target kind is invalid.")
};
private void InvalidateLegacyComparisonImportRequest()
{
CancelRequest(Interlocked.Exchange(
ref _legacyComparisonImportCancellation,
null));
}
private void ShutdownLegacyComparisonImportRuntime()
{
InvalidateLegacyComparisonImportRequest();
_legacyComparisonImportService = null;
}
}

View File

@@ -15,7 +15,9 @@ public sealed partial class MainWindow
private readonly SemaphoreSlim _manualOperatorDataGate = new(1, 1);
private S5025TrustedManualFileStore? _manualNetSellStore;
private ViTrustedManualFileStore? _viManualListStore;
private LegacyManualOperatorDataImporter? _legacyManualOperatorDataImporter;
private string? _manualOperatorDataInitializationError;
private string? _manualOperatorDataInitializationCode;
private int _manualOperatorWriteInFlight;
private int _manualOperatorWriteQuarantined;
private int _manualOperatorBrowserGeneration;
@@ -30,6 +32,7 @@ public sealed partial class MainWindow
OperatorDataDirectoryEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(configuredDirectory))
{
_manualOperatorDataInitializationCode = "UNSAFE_CONFIGURATION";
_manualOperatorDataInitializationError =
"외부 수동 데이터 경로는 TOCTOU 안전성을 보장할 수 없어 비활성화되었습니다. 앱 전용 로컬 운영 데이터 폴더를 사용하세요.";
return;
@@ -39,15 +42,37 @@ public sealed partial class MainWindow
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"OperatorData"));
var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(directory);
if (!inspection.IsReady)
{
(_manualOperatorDataInitializationCode,
_manualOperatorDataInitializationError) = inspection.State switch
{
LegacyManualOperatorDataRuntimeState.InterruptedImport => (
"IMPORT_INTERRUPTED",
"이전 원본 수동 데이터 가져오기가 중단되었습니다. FSell/VI 파일은 열지 않았습니다. 다시 시작하기 전에 진행 표식, 완료 표식, 데이터 파일 4개를 확인하세요."),
_ => (
"IMPORTED_STATE_INVALID",
"원본 수동 데이터 완료 표식과 파일 4개가 검증된 한 세트를 이루지 않습니다. FSell/VI 파일은 열지 않았습니다. 다시 시작하기 전에 앱 전용 운영 데이터 폴더를 확인하세요.")
};
return;
}
_manualNetSellStore = new S5025TrustedManualFileStore(directory);
_viManualListStore = new ViTrustedManualFileStore(directory);
_legacyManualOperatorDataImporter = new LegacyManualOperatorDataImporter(
directory,
new FixedLegacyManualOperatorDataSource());
}
catch
{
_legacyManualOperatorDataImporter?.Dispose();
_viManualListStore?.Dispose();
_manualNetSellStore?.Dispose();
_legacyManualOperatorDataImporter = null;
_viManualListStore = null;
_manualNetSellStore = null;
_manualOperatorDataInitializationCode = "INITIALIZATION_FAILED";
_manualOperatorDataInitializationError =
"수동 송출 데이터 저장소를 초기화하지 못했습니다. 로컬 운영 데이터 설정을 확인하세요.";
}
@@ -75,6 +100,7 @@ public sealed partial class MainWindow
{
requestId,
available = _manualNetSellStore is not null && _viManualListStore is not null,
code = _manualOperatorDataInitializationCode ?? "READY",
writeQuarantined = IsManualOperatorWriteQuarantined(),
message = IsManualOperatorWriteQuarantined()
? GetOperatorMutationQuarantineMessage(
@@ -84,6 +110,120 @@ public sealed partial class MainWindow
});
}
private async Task HandleLegacyManualOperatorImportAsync(JsonElement payload)
{
const string operation = "import-legacy-manual-data";
if (payload.ValueKind != JsonValueKind.Object ||
!HasOnlyProperties(payload, "requestId") ||
!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId))
{
PostManualOperatorError(
SafeManualOperatorRequestId(payload),
operation,
"INVALID_REQUEST",
"올바르지 않은 원본 수동 데이터 가져오기 요청입니다.",
retryable: false,
outcomeUnknown: false);
return;
}
var importer = _legacyManualOperatorDataImporter;
if (importer is null || _manualNetSellStore is null || _viManualListStore is null)
{
PostManualOperatorUnavailable(requestId, operation);
return;
}
if (IsManualOperatorWriteQuarantined())
{
PostManualOperatorQuarantine(requestId, operation);
return;
}
if (!await TryEnterManualMutationGatesAsync(requestId, operation))
{
return;
}
var browserGeneration = Volatile.Read(ref _manualOperatorBrowserGeneration);
Interlocked.Exchange(ref _manualOperatorWriteInFlight, 1);
try
{
var result = await importer.ImportAsync(_lifetimeCancellation.Token);
if (browserGeneration != Volatile.Read(ref _manualOperatorBrowserGeneration))
{
QuarantineManualOperatorWrites(
"원본 수동 데이터 가져오기 중 화면 상관관계가 사라졌습니다. 가져오기 marker와 4개 파일을 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요.");
return;
}
PostMessage("legacy-manual-operator-import-result", new
{
requestId,
markerVersion = result.MarkerVersion,
sourceSha256 = result.SourceSha256,
importedAt = result.ImportedAt,
netSellRowCount = result.NetSellRowCount,
viItemCount = result.ViItemCount
});
}
catch (OperationCanceledException) when (_lifetimeCancellation.IsCancellationRequested)
{
// A pre-commit cancellation creates nothing. Once commit begins the
// importer completes or rolls back without observing cancellation.
}
catch (LegacyManualOperatorImportException exception)
{
if (exception.OutcomeUnknown)
{
QuarantineManualOperatorWrites(
"원본 수동 데이터 가져오기를 안전하게 되돌렸는지 확인할 수 없습니다. 4개 파일과 marker를 직접 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요.");
PostManualOperatorQuarantine(requestId, operation);
return;
}
var (code, message) = exception.Failure switch
{
LegacyManualOperatorImportFailure.SourceUnavailable => (
"SOURCE_UNAVAILABLE",
"원본 프로젝트의 FSell/VI 데이터 4개 파일을 찾을 수 없습니다."),
LegacyManualOperatorImportFailure.SourceInvalid => (
"SOURCE_INVALID",
"원본 FSell/VI 데이터가 CP949 원본 형식 또는 크기 제한을 충족하지 않습니다."),
LegacyManualOperatorImportFailure.AlreadyImported => (
"ALREADY_IMPORTED",
"이 앱 전용 폴더에는 이미 완료 marker가 있어 다시 가져오지 않습니다."),
LegacyManualOperatorImportFailure.DestinationNotEmpty => (
"DESTINATION_NOT_EMPTY",
"앱 전용 폴더에 기존 FSell/VI 데이터가 있어 아무 파일도 덮어쓰지 않았습니다."),
LegacyManualOperatorImportFailure.InterruptedImport => (
"IMPORT_INTERRUPTED",
"이전 가져오기의 진행 표식이 남아 있습니다. 완료 표식과 파일 4개를 확인하기 전에는 다시 시작하거나 반복하지 마세요."),
_ => (
"IMPORT_FAILED",
"원본 수동 데이터 가져오기에 실패했고 생성된 파일은 되돌렸습니다. 자동으로 재시도하지 않습니다.")
};
PostManualOperatorError(
requestId,
operation,
code,
message,
retryable: false,
outcomeUnknown: false);
}
catch
{
QuarantineManualOperatorWrites(
"원본 수동 데이터 가져오기 결과를 확인할 수 없습니다. 4개 파일과 marker를 직접 확인하고 앱을 다시 시작하기 전까지 작업을 반복하지 마세요.");
PostManualOperatorQuarantine(requestId, operation);
}
finally
{
Interlocked.Exchange(ref _manualOperatorWriteInFlight, 0);
ExitManualMutationGates();
}
}
private async Task HandleManualNetSellReadAsync(JsonElement payload)
{
if (!TryParseAudienceRequest(payload, out var requestId, out var audience))
@@ -701,7 +841,7 @@ public sealed partial class MainWindow
PostManualOperatorError(
requestId,
operation,
"DATA_UNAVAILABLE",
_manualOperatorDataInitializationCode ?? "DATA_UNAVAILABLE",
_manualOperatorDataInitializationError ??
"수동 송출 데이터 저장소를 사용할 수 없습니다.",
retryable: false,
@@ -750,21 +890,24 @@ public sealed partial class MainWindow
InvalidateManualOperatorBrowserRequests();
var netSellStore = Interlocked.Exchange(ref _manualNetSellStore, null);
var viStore = Interlocked.Exchange(ref _viManualListStore, null);
if (netSellStore is null && viStore is null)
var importer = Interlocked.Exchange(ref _legacyManualOperatorDataImporter, null);
if (netSellStore is null && viStore is null && importer is null)
{
return;
}
_ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore);
_ = DisposeManualOperatorStoresWhenIdleAsync(netSellStore, viStore, importer);
}
private async Task DisposeManualOperatorStoresWhenIdleAsync(
S5025TrustedManualFileStore? netSellStore,
ViTrustedManualFileStore? viStore)
ViTrustedManualFileStore? viStore,
LegacyManualOperatorDataImporter? importer)
{
await _manualOperatorDataGate.WaitAsync().ConfigureAwait(false);
try
{
importer?.Dispose();
viStore?.Dispose();
netSellStore?.Dispose();
}

View File

@@ -0,0 +1,133 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private LegacyNamedComparisonRestoreService? _namedComparisonRestoreService;
private async Task PostNamedComparisonRestoreAsync(
string requestId,
IReadOnlyList<NamedPlaylistStoredItem> items,
CancellationToken cancellationToken)
{
var candidates = items
.Select(static item => new LegacyNamedComparisonRestoreRow(
item.ItemIndex,
item.IsEnabled,
item.Selection.GroupCode,
item.Selection.Subject,
item.Selection.GraphicType,
item.Selection.Subtype,
item.Page?.ToString() ?? string.Empty,
item.Selection.DataCode))
.Where(LegacyNamedComparisonRestoreService.IsCandidate)
.ToArray();
if (candidates.Length == 0)
{
return;
}
try
{
var runtime = _databaseRuntime;
if (runtime is null)
{
PostNamedComparisonRestoreError(
requestId,
"DATABASE_UNAVAILABLE",
"현재 종목 마스터 DB를 사용할 수 없어 비교 행을 재검증하지 못했습니다.");
return;
}
var service = _namedComparisonRestoreService ??=
new LegacyNamedComparisonRestoreService(runtime.Executor);
var result = await service.ResolveAsync(candidates, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
PostMessage("named-comparison-restore-results", new
{
requestId,
result.RetrievedAt,
totalRowCount = result.Rows.Count,
rows = result.Rows.Select(ToNamedComparisonRestoreWireRow)
});
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0 &&
!_lifetimeCancellation.IsCancellationRequested)
{
PostNamedComparisonRestoreError(
requestId,
"PLAYOUT_PRIORITY",
"송출 명령 우선 처리로 비교 행 재검증을 취소했습니다. 자동 재시도하지 않습니다.");
}
throw;
}
catch (DatabaseOperationException)
{
PostNamedComparisonRestoreError(
requestId,
"DATABASE_UNAVAILABLE",
"현재 종목 마스터 DB에서 비교 행을 재검증하지 못했습니다.");
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
catch
{
PostNamedComparisonRestoreError(
requestId,
"RESTORE_FAILED",
"저장된 비교 행을 현재 명시적 선택으로 안전하게 복원하지 못했습니다.");
}
}
private static object ToNamedComparisonRestoreWireRow(
LegacyNamedComparisonRestoreOutcome outcome)
{
if (outcome.IsResolved)
{
return new
{
outcome.ItemIndex,
status = "resolved",
outcome.ActionId,
first = ToLegacyComparisonImportWireTarget(outcome.First!),
second = ToLegacyComparisonImportWireTarget(outcome.Second!)
};
}
return new
{
outcome.ItemIndex,
status = "failed",
failure = outcome.Failure switch
{
LegacyNamedComparisonRestoreFailure.InvalidRow => "INVALID_ROW",
LegacyNamedComparisonRestoreFailure.MissingMarketIdentity =>
"MISSING_MARKET_IDENTITY",
LegacyNamedComparisonRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
LegacyNamedComparisonRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
LegacyNamedComparisonRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION",
LegacyNamedComparisonRestoreFailure.DatabaseDataInvalid =>
"DATABASE_DATA_INVALID",
_ => "RESTORE_FAILED"
}
};
}
private void PostNamedComparisonRestoreError(
string requestId,
string code,
string message)
{
PostMessage("named-comparison-restore-error", new
{
requestId,
code,
message,
retryable = false
});
}
}

View File

@@ -0,0 +1,137 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private LegacyForeignIndexCandleRestoreService? _namedForeignIndexCandleRestoreService;
private async Task PostNamedForeignIndexCandleRestoreAsync(
string requestId,
IReadOnlyList<NamedPlaylistStoredItem> items,
CancellationToken cancellationToken)
{
var candidates = items
.Select(static item => new LegacyForeignIndexCandleRestoreRow(
item.ItemIndex,
item.IsEnabled,
item.Selection.GroupCode,
item.Selection.Subject,
item.Selection.GraphicType,
item.Selection.Subtype,
item.Page?.ToString() ?? string.Empty,
item.Selection.DataCode))
.Where(LegacyForeignIndexCandleRestoreService.IsCandidate)
.ToArray();
if (candidates.Length == 0)
{
return;
}
try
{
var runtime = _databaseRuntime;
if (runtime is null)
{
PostNamedForeignIndexCandleRestoreError(
requestId,
"DATABASE_UNAVAILABLE",
"현재 해외지수 master DB를 사용할 수 없어 캔들 행을 재검증하지 못했습니다.");
return;
}
var service = _namedForeignIndexCandleRestoreService ??=
new LegacyForeignIndexCandleRestoreService(runtime.Executor);
var result = await service.ResolveAsync(candidates, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
PostMessage("named-foreign-index-candle-restore-results", new
{
requestId,
result.RetrievedAt,
totalRowCount = result.Rows.Count,
rows = result.Rows.Select(ToNamedForeignIndexCandleRestoreWireRow)
});
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0 &&
!_lifetimeCancellation.IsCancellationRequested)
{
PostNamedForeignIndexCandleRestoreError(
requestId,
"PLAYOUT_PRIORITY",
"송출 명령 우선 처리로 해외지수 캔들 재검증을 취소했습니다. 자동 재시도하지 않습니다.");
}
throw;
}
catch (DatabaseOperationException)
{
PostNamedForeignIndexCandleRestoreError(
requestId,
"DATABASE_UNAVAILABLE",
"현재 해외지수 master DB에서 캔들 행을 재검증하지 못했습니다.");
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
catch
{
PostNamedForeignIndexCandleRestoreError(
requestId,
"RESTORE_FAILED",
"저장된 해외지수 캔들 행을 안전한 s8010 선택으로 복원하지 못했습니다.");
}
}
private static object ToNamedForeignIndexCandleRestoreWireRow(
LegacyForeignIndexCandleRestoreOutcome outcome)
{
if (outcome.IsResolved)
{
var identity = outcome.Identity!;
return new
{
outcome.ItemIndex,
status = "resolved",
identity.TargetKey,
identity.InputName,
identity.Symbol,
identity.NationCode,
identity.ForeignDomesticCode,
identity.SceneCode,
identity.ValueType,
identity.PeriodDays,
identity.CutCode
};
}
return new
{
outcome.ItemIndex,
status = "failed",
failure = outcome.Failure switch
{
LegacyForeignIndexCandleRestoreFailure.InvalidRow => "INVALID_ROW",
LegacyForeignIndexCandleRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
LegacyForeignIndexCandleRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid =>
"DATABASE_DATA_INVALID",
_ => "RESTORE_FAILED"
}
};
}
private void PostNamedForeignIndexCandleRestoreError(
string requestId,
string code,
string message)
{
PostMessage("named-foreign-index-candle-restore-error", new
{
requestId,
code,
message,
retryable = false
});
}
}

View File

@@ -0,0 +1,149 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private LegacyNamedNxtThemeRestoreService? _namedNxtThemeRestoreService;
private async Task PostNamedNxtThemeRestoreAsync(
string requestId,
IReadOnlyList<NamedPlaylistStoredItem> items,
CancellationToken cancellationToken)
{
var candidates = items
.Select(static item => new LegacyNamedNxtThemeRestoreRow(
item.ItemIndex,
item.IsEnabled,
item.Selection.GroupCode,
item.Selection.Subject,
item.Selection.GraphicType,
item.Selection.Subtype,
item.Page?.ToString() ?? string.Empty,
item.Selection.DataCode))
.Where(LegacyNamedNxtThemeRestoreService.IsCandidate)
.ToArray();
if (candidates.Length == 0)
{
return;
}
try
{
var runtime = _databaseRuntime;
if (runtime is null)
{
PostNamedNxtThemeRestoreError(
requestId,
"DATABASE_UNAVAILABLE",
"현재 MariaDB를 사용할 수 없어 저장된 NXT 테마를 재검증하지 못했습니다.");
return;
}
var service = _namedNxtThemeRestoreService ??=
new LegacyNamedNxtThemeRestoreService(runtime.Executor);
var result = await service.ResolveAsync(candidates, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
PostMessage("named-nxt-theme-restore-results", new
{
requestId,
result.RetrievedAt,
totalRowCount = result.Rows.Count,
rows = result.Rows.Select(ToNamedNxtThemeRestoreWireRow)
});
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
if (Volatile.Read(ref _playoutCommandInFlight) != 0 &&
!_lifetimeCancellation.IsCancellationRequested)
{
PostNamedNxtThemeRestoreError(
requestId,
"PLAYOUT_PRIORITY",
"송출 명령 우선 처리로 NXT 테마 재검증을 취소했습니다. 자동 재시도하지 않습니다.");
}
throw;
}
catch (DatabaseOperationException)
{
PostNamedNxtThemeRestoreError(
requestId,
"DATABASE_UNAVAILABLE",
"현재 MariaDB에서 NXT 테마를 재검증하지 못했습니다.");
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
catch
{
PostNamedNxtThemeRestoreError(
requestId,
"RESTORE_FAILED",
"저장된 NXT 테마 행을 안전한 현재 선택으로 복원하지 못했습니다.");
}
}
private static object ToNamedNxtThemeRestoreWireRow(
LegacyNamedNxtThemeRestoreOutcome outcome)
{
if (outcome.IsResolved)
{
var identity = outcome.Identity!;
return new
{
outcome.ItemIndex,
status = "resolved",
identity.ActionId,
identity.BuilderKey,
identity.CutCode,
identity.PageSize,
identity.Sort,
session = identity.Session switch
{
ThemeSession.PreMarket => "PRE_MARKET",
ThemeSession.AfterMarket => "AFTER_MARKET",
_ => throw new InvalidOperationException("NXT theme session is invalid.")
},
identity.ThemeTitle,
identity.StoredThemeCode,
identity.CurrentThemeCode,
identity.PreviewItemCount,
identity.LiveItemCount,
identity.PreviewIsTruncated,
identity.CodeWasRemapped
};
}
return new
{
outcome.ItemIndex,
status = "failed",
failure = outcome.Failure switch
{
LegacyNamedNxtThemeRestoreFailure.InvalidRow => "INVALID_ROW",
LegacyNamedNxtThemeRestoreFailure.UnsupportedAction => "UNSUPPORTED_ACTION",
LegacyNamedNxtThemeRestoreFailure.IdentityNotFound => "IDENTITY_NOT_FOUND",
LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous => "IDENTITY_AMBIGUOUS",
LegacyNamedNxtThemeRestoreFailure.PreviewEmpty => "PREVIEW_EMPTY",
LegacyNamedNxtThemeRestoreFailure.LiveItemsEmpty => "LIVE_ITEMS_EMPTY",
LegacyNamedNxtThemeRestoreFailure.DatabaseDataInvalid =>
"DATABASE_DATA_INVALID",
_ => "RESTORE_FAILED"
}
};
}
private void PostNamedNxtThemeRestoreError(
string requestId,
string code,
string message)
{
PostMessage("named-nxt-theme-restore-error", new
{
requestId,
code,
message,
retryable = false
});
}
}

View File

@@ -122,6 +122,21 @@ public sealed partial class MainWindow
item.Selection.DataCode
})
});
await PostNamedComparisonRestoreAsync(
requestId,
result.Items,
cancellationToken);
await PostNamedForeignIndexCandleRestoreAsync(
requestId,
result.Items,
cancellationToken);
// Keep all named restore batches correlated to this load. NXT
// theme verification runs last because it can perform both an
// exact identity lookup and a bounded item preview per title.
await PostNamedNxtThemeRestoreAsync(
requestId,
result.Items,
cancellationToken);
});
}
@@ -779,7 +794,7 @@ public sealed partial class MainWindow
!TryGetNamedPlaylistText(
itemElement,
"subject",
256,
4000,
allowEmpty: true,
allowCaret: false,
out var subject) ||

View File

@@ -0,0 +1,160 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW;
public sealed partial class MainWindow
{
private readonly INativeOperatorLogWriter _nativeOperatorLogWriter =
new NativeOperatorLogWriter();
private readonly NativeOperatorLogTransitionTracker _nativeOperatorTransitionTracker =
new();
private readonly NativeOperatorCommandLogTracker _nativeOperatorCommandTracker =
new();
private int _nativeStartupRecorded;
private int _nativeShutdownRecorded;
private void LogNativeOperatorStartup()
{
if (Interlocked.Exchange(ref _nativeStartupRecorded, 1) == 0)
{
TryWriteNativeOperatorRecord(NativeOperatorLogRecord.ForStartup());
}
}
private void LogNativeOperatorShutdown()
{
if (Interlocked.Exchange(ref _nativeShutdownRecorded, 1) == 0)
{
TryWriteNativeOperatorRecord(NativeOperatorLogRecord.ForShutdown());
}
}
private void ObserveNativeDatabaseState(
DataSourceKind source,
DatabaseHealthState state)
{
if (_nativeOperatorTransitionTracker.TryObserveDatabaseState(
source,
state,
out var record))
{
TryWriteNativeOperatorRecord(record);
}
}
private void ObserveNativeTornadoState(PlayoutConnectionState state)
{
if (_nativeOperatorTransitionTracker.TryObserveTornadoState(state, out var record))
{
TryWriteNativeOperatorRecord(record);
}
}
private void LogNativePlayoutCommandRequested(string command)
{
if (TryMapNativePlayoutCommand(command, out var mapped) &&
_nativeOperatorCommandTracker.TryBegin(mapped, out var record))
{
TryWriteNativeOperatorRecord(record);
}
}
private void LogNativePlayoutCommandCompleted(
string command,
PlayoutResultCode result)
{
if (!TryMapNativePlayoutCommand(command, out var mapped))
{
return;
}
if (result is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut ||
!TryMapNativePlayoutCompletion(result, out var completion))
{
if (_nativeOperatorCommandTracker.TryMarkOutcomeUnknown(mapped, out var unknownRecord))
{
TryWriteNativeOperatorRecord(unknownRecord);
}
return;
}
if (_nativeOperatorCommandTracker.TryComplete(mapped, completion, out var record))
{
TryWriteNativeOperatorRecord(record);
}
}
private void LogNativePlayoutCommandCompleted(
string command,
NativeOperatorPlayoutCompletion completion)
{
if (TryMapNativePlayoutCommand(command, out var mapped) &&
_nativeOperatorCommandTracker.TryComplete(mapped, completion, out var record))
{
TryWriteNativeOperatorRecord(record);
}
}
private void LogNativePlayoutOutcomeUnknown(string command)
{
if (TryMapNativePlayoutCommand(command, out var mapped) &&
_nativeOperatorCommandTracker.TryMarkOutcomeUnknown(mapped, out var record))
{
TryWriteNativeOperatorRecord(record);
}
}
private void LogCurrentNativePlayoutOutcomeUnknown()
{
if (_nativeOperatorCommandTracker.TryMarkCurrentOutcomeUnknown(out var record))
{
TryWriteNativeOperatorRecord(record);
}
}
private void TryWriteNativeOperatorRecord(NativeOperatorLogRecord record)
{
try
{
_ = _nativeOperatorLogWriter.TryWrite(record);
}
catch
{
// Logging is strictly best effort and cannot affect operator commands.
}
}
private static bool TryMapNativePlayoutCommand(
string command,
out NativeOperatorPlayoutCommand mapped)
{
mapped = command switch
{
"prepare" => NativeOperatorPlayoutCommand.Prepare,
"take-in" => NativeOperatorPlayoutCommand.TakeIn,
"next" => NativeOperatorPlayoutCommand.Next,
"take-out" => NativeOperatorPlayoutCommand.TakeOut,
_ => default
};
return mapped != default;
}
private static bool TryMapNativePlayoutCompletion(
PlayoutResultCode result,
out NativeOperatorPlayoutCompletion mapped)
{
mapped = result switch
{
PlayoutResultCode.Success => NativeOperatorPlayoutCompletion.Succeeded,
PlayoutResultCode.Rejected => NativeOperatorPlayoutCompletion.Rejected,
PlayoutResultCode.Cancelled => NativeOperatorPlayoutCompletion.Cancelled,
PlayoutResultCode.Unavailable => NativeOperatorPlayoutCompletion.Unavailable,
PlayoutResultCode.Failed => NativeOperatorPlayoutCompletion.Failed,
_ => default
};
return mapped != default;
}
}

View File

@@ -17,6 +17,7 @@ public sealed partial class MainWindow
new(StringComparer.Ordinal);
private IThemeCatalogPersistenceService? _themeCatalogPersistenceService;
private IExpertCatalogPersistenceService? _expertCatalogPersistenceService;
private IStockMasterIdentityValidationService? _stockMasterIdentityValidationService;
private IOperatorCatalogSchemaValidationService? _operatorCatalogSchemaValidationService;
private CancellationTokenSource? _operatorCatalogWriteCancellation;
private string? _operatorCatalogInitializationError;
@@ -56,6 +57,8 @@ public sealed partial class MainWindow
_expertCatalogPersistenceService = new LegacyExpertCatalogPersistenceService(
runtime.Executor,
mutationExecutor);
_stockMasterIdentityValidationService =
new LegacyStockMasterIdentityValidationService(runtime.Executor);
_operatorCatalogSchemaValidationService =
new LegacyOperatorCatalogSchemaValidationService(runtime.Executor);
}
@@ -63,6 +66,7 @@ public sealed partial class MainWindow
{
_themeCatalogPersistenceService = null;
_expertCatalogPersistenceService = null;
_stockMasterIdentityValidationService = null;
_operatorCatalogSchemaValidationService = null;
_operatorCatalogInitializationError =
"The theme/expert database boundary could not be initialized.";
@@ -376,12 +380,19 @@ public sealed partial class MainWindow
InitializeOperatorCatalogRuntime();
var service = _themeCatalogPersistenceService;
var identityValidation = _stockMasterIdentityValidationService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"theme",
"create",
definition.ThemeCode,
service?.CanMutate == true,
service?.CanMutate == true && identityValidation is not null,
async cancellationToken =>
{
_ = await identityValidation!.ValidateThemeItemsAsync(
definition.Items,
cancellationToken);
},
cancellationToken => service!.CreateAsync(definition, cancellationToken));
}
@@ -403,12 +414,19 @@ public sealed partial class MainWindow
InitializeOperatorCatalogRuntime();
var service = _themeCatalogPersistenceService;
var identityValidation = _stockMasterIdentityValidationService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"theme",
"replace",
expectedIdentity.ThemeCode,
service?.CanMutate == true,
service?.CanMutate == true && identityValidation is not null,
async cancellationToken =>
{
_ = await identityValidation!.ValidateThemeItemsAsync(
items,
cancellationToken);
},
cancellationToken => service!.ReplaceAsync(
expectedIdentity,
newTitle,
@@ -438,6 +456,7 @@ public sealed partial class MainWindow
"delete",
identity.ThemeCode,
service?.CanMutate == true,
validationBody: null,
cancellationToken => service!.DeleteAsync(identity, cancellationToken));
}
@@ -457,12 +476,19 @@ public sealed partial class MainWindow
InitializeOperatorCatalogRuntime();
var service = _expertCatalogPersistenceService;
var identityValidation = _stockMasterIdentityValidationService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"expert",
"create",
definition.Identity.ExpertCode,
service?.CanMutate == true,
service?.CanMutate == true && identityValidation is not null,
async cancellationToken =>
{
_ = await identityValidation!.ValidateExpertRecommendationsAsync(
definition.Recommendations,
cancellationToken);
},
cancellationToken => service!.CreateAsync(definition, cancellationToken));
}
@@ -484,12 +510,19 @@ public sealed partial class MainWindow
InitializeOperatorCatalogRuntime();
var service = _expertCatalogPersistenceService;
var identityValidation = _stockMasterIdentityValidationService;
await ExecuteOperatorCatalogWriteAsync(
requestId,
"expert",
"replace",
expectedIdentity.ExpertCode,
service?.CanMutate == true,
service?.CanMutate == true && identityValidation is not null,
async cancellationToken =>
{
_ = await identityValidation!.ValidateExpertRecommendationsAsync(
recommendations,
cancellationToken);
},
cancellationToken => service!.ReplaceAsync(
expectedIdentity,
newName,
@@ -519,6 +552,7 @@ public sealed partial class MainWindow
"delete",
identity.ExpertCode,
service?.CanMutate == true,
validationBody: null,
cancellationToken => service!.DeleteAsync(identity, cancellationToken));
}
@@ -672,6 +706,7 @@ public sealed partial class MainWindow
string operation,
string identityCode,
bool canMutate,
Func<CancellationToken, Task>? validationBody,
Func<CancellationToken, Task<OperatorCatalogMutationReceipt>> operationBody)
{
if (!canMutate)
@@ -764,6 +799,30 @@ public sealed partial class MainWindow
return;
}
if (validationBody is not null)
{
await validationBody(requestToken);
requestToken.ThrowIfCancellationRequested();
if (!CanStartOperatorCatalogWrite())
{
PostOperatorCatalogPlayoutActive(
requestId,
entity,
operation,
identityCode);
return;
}
if (browserGeneration != Volatile.Read(ref _operatorCatalogBrowserGeneration) ||
!ReferenceEquals(
Volatile.Read(ref _operatorCatalogWriteCancellation),
requestCancellation))
{
requestCancellation.Cancel();
return;
}
}
mutationDispatched = true;
Interlocked.Exchange(ref _operatorCatalogWriteInFlight, 1);
var receipt = await operationBody(requestToken);
@@ -806,6 +865,31 @@ public sealed partial class MainWindow
outcomeUnknown: false,
requestCancellation);
}
catch (StockMasterIdentityDataException) when (!mutationDispatched)
{
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
"INVALID_DATA",
"Every catalog stock must be selected from one current, unambiguous stock-master identity.",
outcomeUnknown: false,
requestCancellation);
}
catch (DatabaseOperationException) when (!mutationDispatched)
{
PostOperatorCatalogWriteErrorIfCurrent(
requestId,
entity,
operation,
identityCode,
"DATABASE_ERROR",
"The stock-master identity verification failed before the database transaction began.",
outcomeUnknown: false,
requestCancellation);
_ = PostDatabaseStatusAsync(_lifetimeCancellation.Token);
}
catch (OperatorCatalogMutationException exception)
{
if (exception.OutcomeUnknown)
@@ -1152,10 +1236,12 @@ public sealed partial class MainWindow
private bool CanMutateThemeCatalog() =>
_themeCatalogPersistenceService?.CanMutate == true &&
_stockMasterIdentityValidationService is not null &&
!IsOperatorCatalogWriteQuarantined();
private bool CanMutateExpertCatalog() =>
_expertCatalogPersistenceService?.CanMutate == true &&
_stockMasterIdentityValidationService is not null &&
!IsOperatorCatalogWriteQuarantined();
private bool IsOperatorCatalogWriteQuarantined() =>
@@ -1224,6 +1310,7 @@ public sealed partial class MainWindow
CancelRequest(Interlocked.Exchange(ref _operatorCatalogWriteCancellation, null));
_themeCatalogPersistenceService = null;
_expertCatalogPersistenceService = null;
_stockMasterIdentityValidationService = null;
_operatorCatalogSchemaValidationService = null;
}
@@ -1378,7 +1465,7 @@ public sealed partial class MainWindow
market == ThemeMarket.Krx && session != ThemeSession.NotApplicable ||
market == ThemeMarket.Nxt &&
session is not (ThemeSession.PreMarket or ThemeSession.AfterMarket) ||
!TryGetOperatorCatalogCode(element, "themeCode", 8, out var code) ||
!TryGetExistingThemeCatalogCode(element, "themeCode", out var code) ||
!TryGetOperatorCatalogText(element, "themeTitle", 128, out var title) ||
market == ThemeMarket.Nxt && title.Contains("(NXT)", StringComparison.OrdinalIgnoreCase))
{
@@ -1612,6 +1699,15 @@ public sealed partial class MainWindow
return value.Length == length && value.All(char.IsAsciiDigit);
}
private static bool TryGetExistingThemeCatalogCode(
JsonElement payload,
string propertyName,
out string value)
{
value = GetString(payload, propertyName) ?? string.Empty;
return value.Length is >= 3 and <= 8 && value.All(char.IsAsciiDigit);
}
private static bool TryGetOperatorCatalogText(
JsonElement payload,
string propertyName,

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
@@ -18,7 +19,6 @@ public sealed partial class MainWindow
private LegacyPlayoutWorkflow? _playoutWorkflow;
private PlayoutOptions? _playoutOptions;
private MutableLegacySceneCueCompositionOptionsSource? _legacyCompositionOptionsSource;
private LegacySceneCueCompositionOptions? _lastEnabledLegacyComposition;
private string? _playoutInitializationError;
private int _playoutCommandInFlight;
private int _playoutShutdownStarted;
@@ -38,12 +38,9 @@ public sealed partial class MainWindow
_playoutOptions = options;
_legacyCompositionOptionsSource =
new MutableLegacySceneCueCompositionOptionsSource(compositionOptions);
_lastEnabledLegacyComposition = compositionOptions.BackgroundKind ==
LegacySceneBackgroundKind.None
? null
: compositionOptions;
_playoutEngine = PlayoutEngineFactory.Create(options);
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
ObserveNativeTornadoState(_playoutEngine.Status.State);
if (_databaseRuntime is not null)
{
_playoutWorkflow = LegacySceneRuntimeFactory.Create(
@@ -56,6 +53,7 @@ public sealed partial class MainWindow
}
catch (Exception exception)
{
ObserveNativeTornadoState(PlayoutConnectionState.Faulted);
_playoutInitializationError = exception is PlayoutConfigurationException
? exception.Message
: "Tornado 송출 어댑터를 초기화하지 못했습니다.";
@@ -89,6 +87,7 @@ public sealed partial class MainWindow
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
{
ObserveNativeTornadoState(args.Current.State);
_playoutWorkflow?.ObserveEngineStatus(args.Current);
if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
@@ -186,9 +185,11 @@ public sealed partial class MainWindow
var databaseActivityEntered = false;
try
{
LogNativePlayoutCommandRequested(request.Command);
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
if (IsBrowserCorrelationQuarantined())
{
LogNativePlayoutOutcomeUnknown(request.Command);
PostPlayoutCommandError(
request.RequestId,
request.Command,
@@ -202,6 +203,9 @@ public sealed partial class MainWindow
var refreshBeforeCommand = GetLegacyRefreshStatus();
if (refreshBeforeCommand.IsFaulted && request.Command != "take-out")
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Rejected);
PostPlayoutCommandError(
request.RequestId,
request.Command,
@@ -222,6 +226,9 @@ public sealed partial class MainWindow
// gates are released, leaving an emergency TAKE OUT actionable.
if (request.Command == "next" && engine.Status.IsPlayCompletionPending)
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Rejected);
PostPlayoutCommandError(
request.RequestId,
request.Command,
@@ -238,6 +245,7 @@ public sealed partial class MainWindow
databaseActivityEntered = true;
if (IsBrowserCorrelationQuarantined())
{
LogNativePlayoutOutcomeUnknown(request.Command);
PostPlayoutCommandError(
request.RequestId,
request.Command,
@@ -252,6 +260,7 @@ public sealed partial class MainWindow
workflow,
request,
_lifetimeCancellation.Token);
LogNativePlayoutCommandCompleted(request.Command, result.Code);
var status = engine.Status;
var workflowState = workflow.State;
@@ -312,6 +321,9 @@ public sealed partial class MainWindow
}
catch (OperationCanceledException)
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Cancelled);
PostPlayoutCommandError(
request.RequestId,
request.Command,
@@ -322,6 +334,9 @@ public sealed partial class MainWindow
}
catch (DatabaseOperationException exception)
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Unavailable);
PostPlayoutCommandError(
request.RequestId,
request.Command,
@@ -333,6 +348,9 @@ public sealed partial class MainWindow
catch (Exception exception) when (
exception is LegacySceneDataException or ArgumentException)
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Rejected);
PostPlayoutCommandError(
request.RequestId,
request.Command,
@@ -343,6 +361,7 @@ public sealed partial class MainWindow
}
catch
{
LogNativePlayoutOutcomeUnknown(request.Command);
PostPlayoutCommandError(
request.RequestId,
request.Command,
@@ -389,6 +408,7 @@ public sealed partial class MainWindow
// Latch before the first await so a reload or a second WebView message can
// never race ahead of native quarantine. The latch is process-lifetime only.
LogCurrentNativePlayoutOutcomeUnknown();
StopLegacyRefreshLoop();
QueuePlayoutStatus();

View File

@@ -54,6 +54,8 @@ public sealed partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();
LogNativeOperatorStartup();
EnsureCloseConfirmationAttached();
InitializeManualOperatorDataRuntime();
InitializeDatabaseRuntime();
InitializePlayoutRuntime();
@@ -100,6 +102,7 @@ public sealed partial class MainWindow : Window
private async void OnRootLoaded(object sender, RoutedEventArgs e)
{
Root.Loaded -= OnRootLoaded;
EnsureCloseConfirmationAttached();
ResizeWindow();
await InitializeWebViewAsync();
@@ -230,6 +233,11 @@ public sealed partial class MainWindow : Window
{
return;
}
if (request is not null &&
TryHandleLegacyComparisonImportRequest(request.Type, request.Payload))
{
return;
}
switch (request?.Type)
{
@@ -371,6 +379,9 @@ public sealed partial class MainWindow : Window
case "request-manual-operator-data-status":
HandleManualOperatorStatusRequest(request.Payload);
break;
case "import-legacy-manual-operator-data":
_ = HandleLegacyManualOperatorImportAsync(request.Payload);
break;
case "request-manual-net-sell-data":
_ = HandleManualNetSellReadAsync(request.Payload);
break;
@@ -416,16 +427,6 @@ public sealed partial class MainWindow : Window
retryable: false,
outcomeUnknown: false);
break;
case "open-external" when request.Payload.ValueKind == JsonValueKind.Object:
if (request.Payload.TryGetProperty("url", out var value) &&
Uri.TryCreate(value.GetString(), UriKind.Absolute, out var uri))
{
_ = OpenExternalUriAsync(uri);
}
break;
case "reload":
ReloadBrowserSafely();
break;
}
}
catch (JsonException exception)
@@ -2262,6 +2263,8 @@ public sealed partial class MainWindow : Window
if (_databaseRuntime is null)
{
var message = _databaseInitializationError ?? "데이터베이스가 설정되지 않았습니다.";
ObserveNativeDatabaseState(DataSourceKind.Oracle, DatabaseHealthState.NotConfigured);
ObserveNativeDatabaseState(DataSourceKind.MariaDb, DatabaseHealthState.NotConfigured);
PostDatabaseStatus(new[]
{
CreateUnavailableStatus("oracle", message),
@@ -2274,6 +2277,10 @@ public sealed partial class MainWindow : Window
{
var statuses = await _databaseRuntime.HealthService
.CheckAllAsync(cancellationToken);
foreach (var status in statuses)
{
ObserveNativeDatabaseState(status.Source, status.State);
}
PostDatabaseStatus(statuses.Select(status => new
{
@@ -2292,6 +2299,8 @@ public sealed partial class MainWindow : Window
}
catch
{
ObserveNativeDatabaseState(DataSourceKind.Oracle, DatabaseHealthState.Unhealthy);
ObserveNativeDatabaseState(DataSourceKind.MariaDb, DatabaseHealthState.Unhealthy);
PostDatabaseStatus(new[]
{
CreateUnavailableStatus("oracle", "Oracle 상태 확인에 실패했습니다."),
@@ -2335,6 +2344,7 @@ public sealed partial class MainWindow : Window
CancelRequest(Volatile.Read(ref _namedPlaylistReadCancellation));
CancelRequest(Volatile.Read(ref _namedPlaylistWriteCancellation));
CancelRequest(Volatile.Read(ref _playlistPagePlanCancellation));
CancelRequest(Volatile.Read(ref _legacyComparisonImportCancellation));
CancelOperatorCatalogReadsForPlayout();
CancelManualFinancialReadsForPlayout();
}
@@ -2579,6 +2589,7 @@ public sealed partial class MainWindow : Window
InvalidateOperatorCatalogBrowserRequests();
InvalidateManualOperatorBrowserRequests();
InvalidateManualFinancialBrowserRequests();
InvalidateLegacyComparisonImportRequest();
if (Volatile.Read(ref _playoutCommandInFlight) != 0)
{
// Navigation or a renderer failure destroys the JavaScript request
@@ -2589,6 +2600,8 @@ public sealed partial class MainWindow : Window
private void OnClosed(object sender, WindowEventArgs args)
{
DetachCloseConfirmation();
LogNativeOperatorShutdown();
var wasWebViewReady = _webViewReady;
_webViewReady = false;
_lifetimeCancellation.Cancel();
@@ -2637,6 +2650,7 @@ public sealed partial class MainWindow : Window
ShutdownOperatorCatalogRuntime();
ShutdownManualFinancialRuntime();
ShutdownManualOperatorDataRuntime();
ShutdownLegacyComparisonImportRuntime();
DataQueryExecutor.Reset();
if (!wasWebViewReady)

View File

@@ -10,7 +10,7 @@
<Identity
Name="Wickedness.MBNStockWebView"
Publisher="CN=Comtrophy"
Version="1.0.2.0" />
Version="1.0.3.0" />
<mp:PhoneIdentity PhoneProductId="b4129399-858c-4b31-90d7-28a0f6c677d0" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>

View File

@@ -11,7 +11,7 @@
- Windows x64 전용
- 단일 프로젝트 MSIX
## 현재 구현 범위
## 현재 화면/계약 구현 범위
- WinUI 3 네이티브 창과 WebView2 간 JSON 메시지 브리지
- FarPoint 플레이리스트를 대체하는 Web UI
@@ -30,14 +30,14 @@
- 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트
- MSIX 패키지 매니페스트와 x64 게시 프로필
Oracle/MariaDB 연결 계층과 Tornado/K3D 어댑터는 구현·검증을 완료했습니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요.
Oracle/MariaDB 조회 계층과 Tornado/K3D 어댑터의 코드·계약·자동 검증은 구현했습니다. 이는 운영 Oracle 쓰기나 35개 장면 전체의 실제 PGM 동등성이 끝났다는 뜻이 아닙니다. 앱과 패키지의 기본 모드는 계속 `DryRun`이며 Live allowlist, 회차별 승인, 명령 예산, callback/`OutcomeUnknown` 게이트를 완화하지 않습니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정·실제 검증 증거·롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 장면별 현황은 [35개 Scene 동등성 매트릭스](docs/SCENE_EQUIVALENCE.md), 전체 412개 실행 action과 전용 편집 화면은 [운영자 UI 동등성 인벤토리](docs/OPERATOR_UI_PARITY.md)를 참고하세요. 재감사에서 확인한 미적용·적용 중·외부자산 필요 항목은 [원본 기능 재감사 기준표](docs/LEGACY_FEATURE_AUDIT.md)가 최종 상태 기준입니다.
35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 운영 화면은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 운영 DB write와 각 화면의 실제 PGM 확인은 별도 승인 회차 전까지 완료 증거로 간주하지 않습니다.
35개 Scene/PageN 런타임과 원본 MainForm·UC1~UC7·GraphE·FSell·VIList·PList·AList·ThemeA·EList의 화면·bridge 계약은 WinUI 3/WebView2에 연결했습니다. 412개 고정 실행 action은 폐쇄형 매트릭스로 고정하며, 수동 재무·순매도·VI·테마·전문가·이름 있는 플레이리스트는 저장 후 재조회와 신뢰 검증이 끝나기 전에는 PREPARE할 수 없습니다. 다만 PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원, GraphE·FSell·VI를 포함한 수동 항목의 named save→fresh restore, ThemeA/EList의 실제 운영 identity, 구현된 `종목비교.dat` importer의 운영 DB 연결 1회 실행, FSell/VI 원본 파일의 one-time import는 아직 운영 동등성 완료로 판정하지 않습니다. 운영 DB write와 승인된 `5001`/`5074` 밖의 실제 PGM 확인도 미검증입니다.
## 최종 검증 기준선 (2026-07-12)
- 현재 마이그레이션 소스에서 Core 1,116개, Infrastructure 125개, Playout 374개가 Debug/Release x64에서 각각 모두 통과했다. 구성별 1,615개, 전체 3,230개이며 실패·skip·경고는 0건이다. Web 구문 19개와 테스트 235/235, 원본 scene 기준선 35/35도 통과했다.
- 원본 `Cuts` 기준으로 도달 가능한 builder 34개와 active alias 45개의 컷 커버리지를 다시 검사했고 missing/unsafe 0건이다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색 63건, 필수 scene 33/33과 전체 query 55/55를 통과했다. `s5025`는 승인된 외부 수동 파일이 필요한 전제조건으로 분리된다.
- 원본 `Cuts` 기준 active alias 45개의 `.t2s` 존재성은 missing/unsafe 0건이었다. 이 결과는 종속 자산 준비 상태를 뜻하지 않는다. 실제 Oracle 21c/MariaDB read-only 스모크는 `삼성` 검색 63건, 필수 loader 33/33과 전체 query 55/55를 통과했지만 DB→DTO→mutation 검증 범위다. `s5025`는 승인된 외부 수동 파일이 필요하고, `s5006``Video\큐브배경.vrv``s6001` 해외지수용 13개 영상은 현재 승인 Cuts에 없어 외부자산 필요 상태다.
- Visual Studio 2026 MSBuild `18.7.8.30822`로 Debug x64와 signed Release x64 MSIX 빌드를 완료했다. `1.0.2.0` 패키지 SHA-256은 `4A4ED867D16B75C1C82CB55DA5111E0502573F486B8B897889069843D9DF5072`이며 서명, 281개 패키지 항목, Web 24개 byte-for-byte 일치, 금지 자산·비밀값 0건을 확인했다.
- 설치본 `Wickedness.MBNStockWebView_1.0.2.0_x64__qbv3jkvsn3aj0`은 실제 package context에서 WebView2 프로세스 6개와 함께 실행된다. 전체 운영 UI, Oracle/MariaDB 연결, `삼성` 63건 검색, 기본 `DryRun`, 응답 정상과 패키지 생성 이후 오류 이벤트 0건을 확인했다.
- 실제 Tornado2 검증은 고정 계획 `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19``MBNWEB-20260711-H` 한 회차로 제한했다. PGM에서 `5001``5074` page 1/page 2를 확인한 뒤 TAKE OUT의 검은 화면을 확인했다. Network Monitoring의 정확한 합계는 `SCENE_PREPARE 5001 = 3`, `SCENE_PREPARE 5074 = 2`, `PLAY = 4`, `SCENE_PLAYED = 4`, `FAILURE = 0`, `ERROR = 0`, retry `0`이며 최종 `OutcomeUnknown = false`다.

1218
Web/app.js

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
(function (root, factory) {
"use strict";
const comparison = typeof module === "object" && module.exports
? require("./comparison-workflow.js")
: root?.MbnComparisonWorkflow;
const api = Object.freeze(factory(comparison));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnComparisonImportWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (comparison) {
"use strict";
if (!comparison) throw new Error("Comparison workflow dependency is unavailable.");
const IMPORT_SCHEMA_VERSION = 1;
const STORAGE_ENVELOPE_SCHEMA_VERSION = 1;
const MAXIMUM_PAIRS = 500;
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
const digestPattern = /^[A-F0-9]{64}$/;
const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/u;
const importedRecordIdPattern = /^legacy-compare-[a-f0-9]{12}-[1-9][0-9]{0,2}$/u;
function hasOnlyKeys(value, expected) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const keys = Object.keys(value).sort();
const wanted = [...expected].sort();
return keys.length === wanted.length && keys.every((key, index) => key === wanted[index]);
}
function safeText(value, maximumLength) {
return typeof value === "string" && value.length > 0 && value.length <= maximumLength &&
value === value.trim() && !controlCharacterPattern.test(value)
? value
: null;
}
function normalizeWireTarget(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
typeof value.kind !== "string") return null;
let candidate;
switch (value.kind) {
case "market-target":
if (!hasOnlyKeys(value, ["kind", "target"]) || !safeText(value.target, 64)) return null;
candidate = { kind: value.kind, target: value.target };
break;
case "krx-stock":
case "nxt-stock":
if (!hasOnlyKeys(value, ["kind", "market", "stockName", "stockCode"]) ||
!safeText(value.market, 8) || !safeText(value.stockName, 200) ||
!safeText(value.stockCode, 32)) return null;
candidate = {
kind: value.kind,
market: value.market,
stockName: value.stockName,
stockCode: value.stockCode
};
break;
case "world-stock":
if (!hasOnlyKeys(value, ["kind", "inputName", "symbol", "nation"]) ||
!safeText(value.inputName, 200) || !safeText(value.symbol, 64) ||
!safeText(value.nation, 2)) return null;
candidate = {
kind: value.kind,
inputName: value.inputName,
displayName: value.inputName,
symbol: value.symbol,
nation: value.nation
};
break;
default:
return null;
}
return comparison.normalizeTarget(candidate);
}
function normalizeImportResponse(value, expectedRequestId) {
if (!requestIdPattern.test(String(expectedRequestId || "")) ||
!hasOnlyKeys(value, ["requestId", "sourceSha256", "sourceRowCount", "retrievedAt", "pairs"]) ||
value.requestId !== expectedRequestId ||
typeof value.sourceSha256 !== "string" || !digestPattern.test(value.sourceSha256) ||
!Number.isInteger(value.sourceRowCount) || value.sourceRowCount < 1 ||
value.sourceRowCount > MAXIMUM_PAIRS ||
typeof value.retrievedAt !== "string" || !isoDatePattern.test(value.retrievedAt) ||
!Number.isFinite(Date.parse(value.retrievedAt)) ||
!Array.isArray(value.pairs) || value.pairs.length !== value.sourceRowCount) return null;
const pairs = [];
const signatures = new Set();
for (let index = 0; index < value.pairs.length; index += 1) {
const raw = value.pairs[index];
if (!hasOnlyKeys(raw, ["rowNumber", "first", "second"]) ||
raw.rowNumber !== index + 1) return null;
const first = normalizeWireTarget(raw.first);
const second = normalizeWireTarget(raw.second);
const pair = comparison.normalizePair([first, second]);
if (!pair) return null;
const signature = pair.map(comparison.targetKey).join("\u001f");
if (!signature || signatures.has(signature)) return null;
signatures.add(signature);
pairs.push(Object.freeze({ rowNumber: index + 1, pair }));
}
return Object.freeze({
requestId: value.requestId,
sourceSha256: value.sourceSha256,
sourceRowCount: value.sourceRowCount,
retrievedAt: value.retrievedAt,
pairs: Object.freeze(pairs)
});
}
function pairSignature(value) {
const pair = comparison.normalizePair(value);
return pair ? pair.map(comparison.targetKey).join("\u001f") : null;
}
function mergeImport(currentValue, importValue) {
const current = comparison.normalizePairList(currentValue);
if (!importValue || !Array.isArray(importValue.pairs) ||
!digestPattern.test(String(importValue.sourceSha256 || "")) ||
!Number.isInteger(importValue.sourceRowCount) ||
importValue.sourceRowCount !== importValue.pairs.length ||
importValue.sourceRowCount < 1 || importValue.sourceRowCount > MAXIMUM_PAIRS ||
current.pairs.length + importValue.pairs.length > MAXIMUM_PAIRS) {
throw new RangeError("The legacy comparison import cannot fit the saved-pair schema.");
}
const ids = new Set(current.pairs.map(record => record.id));
const signatures = new Set(current.pairs.map(record => pairSignature(record.pair)));
let merged = current;
for (let index = 0; index < importValue.pairs.length; index += 1) {
const imported = importValue.pairs[index];
if (imported?.rowNumber !== index + 1) {
throw new TypeError("The legacy comparison import row order is invalid.");
}
const signature = pairSignature(imported?.pair);
if (!signature || signatures.has(signature)) {
throw new RangeError("The legacy comparison import contains an existing pair.");
}
const id = `legacy-compare-${importValue.sourceSha256.slice(0, 12).toLocaleLowerCase("en-US")}-${imported.rowNumber}`;
if (ids.has(id)) {
throw new RangeError("The legacy comparison import id already exists.");
}
const record = comparison.createPairRecord(id, imported.pair);
if (!record) throw new TypeError("The legacy comparison import pair is invalid.");
merged = comparison.appendPair(merged, record);
ids.add(id);
signatures.add(signature);
}
return merged;
}
function createMarker(importValue, importedAt = new Date().toISOString()) {
if (!importValue || !digestPattern.test(String(importValue.sourceSha256 || "")) ||
!Number.isInteger(importValue.sourceRowCount) || importValue.sourceRowCount < 1 ||
importValue.sourceRowCount > MAXIMUM_PAIRS ||
typeof importedAt !== "string" || !isoDatePattern.test(importedAt) ||
!Number.isFinite(Date.parse(importedAt))) return null;
return Object.freeze({
schemaVersion: IMPORT_SCHEMA_VERSION,
sourceSha256: importValue.sourceSha256,
importedPairCount: importValue.sourceRowCount,
importedAt
});
}
function normalizeMarker(value) {
if (!hasOnlyKeys(value, ["schemaVersion", "sourceSha256", "importedPairCount", "importedAt"]) ||
value.schemaVersion !== IMPORT_SCHEMA_VERSION ||
!digestPattern.test(String(value.sourceSha256 || "")) ||
!Number.isInteger(value.importedPairCount) || value.importedPairCount < 1 ||
value.importedPairCount > MAXIMUM_PAIRS ||
typeof value.importedAt !== "string" || !isoDatePattern.test(value.importedAt) ||
!Number.isFinite(Date.parse(value.importedAt))) return null;
return Object.freeze({
schemaVersion: IMPORT_SCHEMA_VERSION,
sourceSha256: value.sourceSha256,
importedPairCount: value.importedPairCount,
importedAt: value.importedAt
});
}
function normalizeExactPairList(value) {
if (!hasOnlyKeys(value, ["schemaVersion", "pairs"]) || !Array.isArray(value.pairs) ||
value.pairs.length > MAXIMUM_PAIRS || value.pairs.some(record =>
!hasOnlyKeys(record, ["id", "pair"]))) return null;
const normalized = comparison.normalizePairList(value);
if (normalized.pairs.length !== value.pairs.length ||
normalized.pairs.some((record, index) => record.id !== value.pairs[index].id)) return null;
return normalized;
}
function normalizeStorageEnvelope(value) {
if (!hasOnlyKeys(value, ["schemaVersion", "pairList", "legacyImportMarker"]) ||
value.schemaVersion !== STORAGE_ENVELOPE_SCHEMA_VERSION) return null;
const pairList = normalizeExactPairList(value.pairList);
const marker = value.legacyImportMarker === null
? null
: normalizeMarker(value.legacyImportMarker);
if (!pairList || (value.legacyImportMarker !== null && !marker) ||
(!marker && pairList.pairs.some(record => importedRecordIdPattern.test(record.id)))) {
return null;
}
return Object.freeze({
schemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
pairList,
legacyImportMarker: marker
});
}
function createStorageEnvelope(pairList, marker = null) {
return normalizeStorageEnvelope({
schemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
pairList,
legacyImportMarker: marker
});
}
return {
schemaVersion: IMPORT_SCHEMA_VERSION,
storageEnvelopeSchemaVersion: STORAGE_ENVELOPE_SCHEMA_VERSION,
maximumPairs: MAXIMUM_PAIRS,
normalizeImportResponse,
mergeImport,
createMarker,
normalizeMarker,
createStorageEnvelope,
normalizeStorageEnvelope
};
});

View File

@@ -171,6 +171,14 @@
return [null, null];
}
function krxIdentityEnvelope(value) {
const pair = normalizePair(value);
if (!pair || pair.some(target => target.kind !== "krx-stock")) return "";
return pair
.map(target => `${target.market === "kospi" ? "KOSPI" : "KOSDAQ"}:${target.stockCode}`)
.join("|");
}
function normalizePair(value) {
const [firstValue, secondValue] = pairParts(value);
const first = normalizeTarget(firstValue);
@@ -288,6 +296,10 @@
const builderKey = selectedAction.kind === "return" ? "s5029" : selectedAction.builderKey;
const code = selectedAction.kind === "return" ? "5029" : selectedAction.code;
const graphicType = selectedAction.kind === "return" ? "RETURN_COMPARISON" : selectedAction.graphicType;
const dataCode = krxIdentityEnvelope(pair);
if (!dataCode) {
throw new RangeError("Comparison graphs require two exact KRX stock identities.");
}
return Object.freeze({
builderKey,
code,
@@ -297,7 +309,7 @@
subject,
graphicType,
subtype: selectedAction.period,
dataCode: ""
dataCode
}),
effectiveMode: compatibility.effectiveMode,
warning: compatibility.warning
@@ -482,6 +494,7 @@
getCompatibility,
isActionAllowed,
buildComparisonSelection,
krxIdentityEnvelope,
createComparisonPlaylistEntry,
createYieldBatchEntries,
restoreComparisonPlaylistEntry,

View File

@@ -22,8 +22,8 @@
</div>
<nav id="marketNav" class="market-nav">
<button class="nav-item active" data-market="dashboard"><span></span>대시보드</button>
<button class="nav-item" data-market="overseas"><span></span>해외</button>
<button class="nav-item" data-market="dashboard"><span></span>대시보드</button>
<button class="nav-item active" data-market="overseas"><span></span>해외</button>
<button class="nav-item" data-market="exchange"><span></span>환율</button>
<button class="nav-item" data-market="index"><span></span>지수</button>
<button class="nav-item" data-market="kospi"><span>K</span>코스피</button>
@@ -46,7 +46,7 @@
<header class="workspace-header">
<div>
<p class="eyebrow">OPERATOR WORKSPACE</p>
<h1 id="workspaceTitle">마이그레이션 대시보드</h1>
<h1 id="workspaceTitle">해외 그래픽</h1>
</div>
<div class="header-meta">
<div><span>현재 시각</span><strong id="clock">--:--:--</strong></div>
@@ -63,10 +63,10 @@
</section>
<div class="console-grid">
<section class="panel catalog-panel">
<section class="panel stock-panel">
<div class="panel-header">
<div><p class="panel-kicker">OPERATOR INPUT</p><h2>종목·그래픽 선택</h2></div>
<span id="catalogModeBadge" class="badge">Web UI</span>
<div><p class="panel-kicker">LEGACY STOCK INPUT</p><h2>종목 검색 · 31개 컷</h2></div>
<span class="badge">항상 표시</span>
</div>
<section id="stockWorkflow" class="stock-workflow" aria-labelledby="stockWorkflowTitle">
<div class="stock-workflow-header">
@@ -96,9 +96,22 @@
<label><input id="stockMa5" type="checkbox" checked>5일선</label>
<label><input id="stockMa20" type="checkbox" checked>20일선</label>
</div>
<div id="stockCutList" class="stock-cut-list" aria-label="종목용 컷 목록"></div>
<div class="stock-cut-selection-tools">
<span id="stockCutSelectionCount" role="status" aria-live="polite">0개 선택</span>
<button id="stockCutBatchAdd" type="button">선택 컷 추가</button>
<button id="stockCutSelectionClear" type="button">선택 해제</button>
</div>
<div id="stockCutList" class="stock-cut-list" role="listbox"
aria-label="종목용 컷 목록" aria-multiselectable="true"></div>
<div id="manualStockActions" class="manual-stock-actions" aria-label="수동 재무 데이터"></div>
</section>
</section>
<section class="panel catalog-panel">
<div class="panel-header">
<div><p class="panel-kicker">OPERATOR INPUT</p><h2>업무별 그래픽 선택</h2></div>
<span id="catalogModeBadge" class="badge">Web UI</span>
</div>
<section id="legacyFixedWorkflow" class="legacy-fixed-workflow" aria-labelledby="legacyFixedWorkflowTitle" hidden>
<div class="legacy-fixed-header">
<div>
@@ -149,8 +162,11 @@
<p class="panel-kicker">LEGACY COMPARISON WORKFLOW</p>
<h3 id="comparisonWorkflowTitle">종목 비교</h3>
</div>
<div class="comparison-import-actions">
<button id="comparisonImportLegacy" type="button">원본 비교쌍 가져오기</button>
<span id="comparisonPairCount" class="badge neutral">0 PAIRS</span>
</div>
</div>
<div id="comparisonSourceTabs" class="comparison-source-tabs" role="tablist" aria-label="비교 대상 종류">
<button type="button" class="active" data-comparison-source="domestic">국내·NXT</button>
<button type="button" data-comparison-source="fixed">지수·환율</button>
@@ -316,6 +332,11 @@
<button id="tradingHaltSearchButton" type="submit">조회</button>
</form>
<div id="tradingHaltSearchState" class="trading-halt-search-state" role="status" aria-live="polite"></div>
<div class="trading-halt-results-header" role="group" aria-label="거래 정지 검색 결과 정렬">
<button id="tradingHaltNameSort" type="button" aria-controls="tradingHaltResults"
aria-label="종목명 정렬: 서버 기본 순서" data-sort-direction="none">종목명 ↕</button>
<span aria-hidden="true">시장</span>
</div>
<div id="tradingHaltResults" class="trading-halt-results" role="listbox" aria-label="거래 정지 종목 검색 결과"></div>
<div id="selectedTradingHalt" class="selected-trading-halt" hidden>
<div>
@@ -533,10 +554,13 @@
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="playout-safety.js"></script>
<script src="market-nav-reorder.js"></script>
<script src="operator-workflow.js"></script>
<script src="legacy-batch-selection-workflow.js"></script>
<script src="legacy-fixed-workflow.js"></script>
<script src="industry-workflow.js"></script>
<script src="comparison-workflow.js"></script>
<script src="comparison-import-workflow.js"></script>
<script src="theme-workflow.js"></script>
<script src="overseas-workflow.js"></script>
<script src="expert-workflow.js"></script>
@@ -545,6 +569,10 @@
<script src="manual-lists-workflow.js"></script>
<script src="manual-financial-workflow.js"></script>
<script src="named-manual-restore-workflow.js"></script>
<script src="named-comparison-restore-workflow.js"></script>
<script src="legacy-foreign-index-candle-workflow.js"></script>
<script src="named-nxt-theme-restore-workflow.js"></script>
<script src="legacy-named-row-workflow.js"></script>
<script src="operator-catalog-workflow.js"></script>
<script src="manual-lists-ui.js"></script>
<script src="manual-financial-ui.js"></script>

View File

@@ -0,0 +1,142 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnLegacyBatchSelectionWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const MAX_ITEMS = 2048;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
function requireItemCount(value) {
const count = Number(value);
if (!Number.isInteger(count) || count < 0 || count > MAX_ITEMS) {
throw new RangeError(`The selectable item count must be from 0 through ${MAX_ITEMS}.`);
}
return count;
}
function requireIndex(value, count, name) {
const index = Number(value);
if (!Number.isInteger(index) || index < 0 || index >= count) {
throw new RangeError(`${name} is outside the selectable item range.`);
}
return index;
}
function normalizeSelectedIndices(values, count) {
if (!Array.isArray(values)) throw new TypeError("Selected indices must be an array.");
const unique = new Set();
for (const value of values) unique.add(requireIndex(value, count, "A selected index"));
return [...unique].sort((left, right) => left - right);
}
function normalizeAnchor(value, count) {
if (value === undefined || value === null || value === -1) return -1;
return requireIndex(value, count, "The selection anchor");
}
function createSelection(itemCount, selectedIndices = [], anchorIndex = -1) {
const count = requireItemCount(itemCount);
const selected = normalizeSelectedIndices(selectedIndices, count);
const anchor = normalizeAnchor(anchorIndex, count);
return Object.freeze({
itemCount: count,
selectedIndices: Object.freeze(selected),
anchorIndex: anchor
});
}
function inclusiveRange(left, right) {
const from = Math.min(left, right);
const to = Math.max(left, right);
return Array.from({ length: to - from + 1 }, (_, offset) => from + offset);
}
// Mirrors MainForm.listView1_MouseDown: plain click replaces, Ctrl toggles,
// Shift replaces with an anchor range, and Ctrl+Shift unions that range.
// The clicked row becomes the next anchor after every successful gesture.
function applyPointerSelection(selection, targetIndex, modifiers = {}) {
if (!selection || typeof selection !== "object" || Array.isArray(selection)) {
throw new TypeError("A legacy selection snapshot is required.");
}
const current = createSelection(
selection.itemCount,
selection.selectedIndices,
selection.anchorIndex);
const target = requireIndex(targetIndex, current.itemCount, "The target index");
if (!modifiers || typeof modifiers !== "object" || Array.isArray(modifiers)) {
throw new TypeError("Selection modifiers must be an object.");
}
const control = modifiers.ctrlKey === true;
const shift = modifiers.shiftKey === true;
const anchor = current.anchorIndex >= 0 ? current.anchorIndex : target;
let selected;
if (control && shift) {
selected = [...new Set([...current.selectedIndices, ...inclusiveRange(anchor, target)])]
.sort((left, right) => left - right);
} else if (control) {
const values = new Set(current.selectedIndices);
if (values.has(target)) values.delete(target);
else values.add(target);
selected = [...values].sort((left, right) => left - right);
} else if (shift) {
selected = inclusiveRange(anchor, target);
} else {
selected = [target];
}
return createSelection(current.itemCount, selected, target);
}
function canActivateSingle(selection, targetIndex) {
if (!selection || typeof selection !== "object" || Array.isArray(selection)) return false;
try {
const current = createSelection(
selection.itemCount,
selection.selectedIndices,
selection.anchorIndex);
const target = requireIndex(targetIndex, current.itemCount, "The target index");
return current.selectedIndices.length === 1 && current.selectedIndices[0] === target;
} catch {
return false;
}
}
// Materialize the complete batch before the caller mutates its playlist. A
// validation error therefore cannot leave an operator-visible partial batch.
function materializeAtomicBatch(items, materialize) {
if (!Array.isArray(items) || items.length < 1 || items.length > MAX_ITEMS) {
throw new RangeError(`A batch must contain from 1 through ${MAX_ITEMS} items.`);
}
if (typeof materialize !== "function") {
throw new TypeError("A batch materializer is required.");
}
const entries = [];
const ids = new Set();
for (let index = 0; index < items.length; index += 1) {
const entry = materialize(items[index], index);
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new TypeError("Every materialized batch item must be a playlist entry object.");
}
const id = typeof entry.id === "string" ? entry.id.trim() : "";
if (!identifierPattern.test(id) || ids.has(id)) {
throw new TypeError("Every materialized batch item must have a unique safe id.");
}
ids.add(id);
entries.push(entry);
}
return Object.freeze(entries);
}
return Object.freeze({
MAX_ITEMS,
createSelection,
applyPointerSelection,
canActivateSingle,
materializeAtomicBatch
});
});

View File

@@ -0,0 +1,341 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnLegacyForeignIndexCandleWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const SCHEMA_VERSION = 1;
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const pendingValues = new WeakSet();
const resolvedValues = new WeakSet();
const materializedEntries = new WeakSet();
const failureCodes = new Set([
"INVALID_ROW",
"IDENTITY_NOT_FOUND",
"IDENTITY_AMBIGUOUS",
"DATABASE_DATA_INVALID",
"RESTORE_FAILED"
]);
const targetProfiles = Object.freeze([
Object.freeze({
key: "Dow",
inputName: "다우존스",
symbol: "DJI@DJI",
nationCode: "US",
foreignDomesticCode: "0"
}),
Object.freeze({
key: "Nasdaq",
inputName: "나스닥",
symbol: "NAS@IXIC",
nationCode: "US",
foreignDomesticCode: "0"
}),
Object.freeze({
key: "Sp500",
inputName: "S&P500",
symbol: "SPI@SPX",
nationCode: "US",
foreignDomesticCode: "0"
})
]);
const targetsByInputName = new Map(targetProfiles.map(value => [value.inputName, value]));
const targetsByKey = new Map(targetProfiles.map(value => [value.key, value]));
const periodProfiles = Object.freeze([
Object.freeze({ legacy: "5일", days: 5, cutCode: "8061" }),
Object.freeze({ legacy: "20일", days: 20, cutCode: "8040" }),
Object.freeze({ legacy: "60일", days: 60, cutCode: "8046" }),
Object.freeze({ legacy: "120일", days: 120, cutCode: "8051" }),
Object.freeze({ legacy: "240일", days: 240, cutCode: "8056" })
]);
const periodsByLegacy = new Map(periodProfiles.map(value => [value.legacy, value]));
const periodsByDays = new Map(periodProfiles.map(value => [value.days, value]));
const bridgeContract = Object.freeze({
resultType: "named-foreign-index-candle-restore-results",
errorType: "named-foreign-index-candle-restore-error"
});
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasExactKeys(value, keys) {
if (!isPlainObject(value)) return false;
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function safeText(value, maximumLength, allowEmpty = false) {
return typeof value === "string" && value.length <= maximumLength &&
(allowEmpty || value.length > 0) && value === value.trim() &&
!value.includes("^") && !unsafeTextPattern.test(value);
}
function requireOptions(options) {
if (!hasExactKeys(options, ["id", "fadeDuration"]) ||
!requestIdPattern.test(options.id) ||
!Number.isInteger(options.fadeDuration) ||
options.fadeDuration < 0 || options.fadeDuration > 60) {
return null;
}
return Object.freeze({ id: options.id, fadeDuration: options.fadeDuration });
}
function rawSelection(raw) {
return Object.freeze({
groupCode: raw.groupCode,
subject: raw.subject,
graphicType: raw.graphicType,
subtype: raw.subtype,
dataCode: raw.dataCode
});
}
function classify(raw, options) {
const normalizedOptions = requireOptions(options);
if (!normalizedOptions || !isPlainObject(raw) ||
!Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 || raw.itemIndex >= 1000 ||
typeof raw.enabled !== "boolean" || raw.groupCode !== "해외지수" ||
raw.graphicType !== "캔들그래프" || raw.pageText !== "1/1" || raw.dataCode !== "" ||
!safeText(raw.subject, 200) || !safeText(raw.subtype, 256) ||
!safeText(raw.pageText, 9) || !safeText(raw.dataCode, 1024, true)) return null;
const target = targetsByInputName.get(raw.subject);
const period = periodsByLegacy.get(raw.subtype);
if (!target || !period) return null;
const pending = Object.freeze({
itemIndex: raw.itemIndex,
enabled: raw.enabled,
targetKey: target.key,
inputName: target.inputName,
symbol: target.symbol,
nationCode: target.nationCode,
foreignDomesticCode: target.foreignDomesticCode,
periodDays: period.days,
cutCode: period.cutCode,
entry: Object.freeze({ id: normalizedOptions.id }),
fadeDuration: normalizedOptions.fadeDuration,
rawSelection: rawSelection(raw)
});
pendingValues.add(pending);
return pending;
}
function normalizeOutcome(value, pending, verifiedAt) {
if (!pendingValues.has(pending) || !isPlainObject(value) ||
value.itemIndex !== pending.itemIndex) return null;
if (value.status === "failed") {
if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) ||
!failureCodes.has(value.failure)) return null;
return Object.freeze({
itemIndex: value.itemIndex,
status: "failed",
failure: value.failure
});
}
if (value.status !== "resolved" || !hasExactKeys(value, [
"itemIndex", "status", "targetKey", "inputName", "symbol", "nationCode",
"foreignDomesticCode", "sceneCode", "valueType", "periodDays", "cutCode"
])) return null;
if (value.targetKey !== pending.targetKey || value.inputName !== pending.inputName ||
value.symbol !== pending.symbol || value.nationCode !== pending.nationCode ||
value.foreignDomesticCode !== pending.foreignDomesticCode ||
value.sceneCode !== "s8010" || value.valueType !== "PRICE" ||
value.periodDays !== pending.periodDays || value.cutCode !== pending.cutCode) return null;
const resolved = Object.freeze({
itemIndex: value.itemIndex,
status: "resolved",
targetKey: value.targetKey,
inputName: value.inputName,
symbol: value.symbol,
nationCode: value.nationCode,
foreignDomesticCode: value.foreignDomesticCode,
sceneCode: value.sceneCode,
valueType: value.valueType,
periodDays: value.periodDays,
cutCode: value.cutCode,
verifiedAt
});
resolvedValues.add(resolved);
return resolved;
}
function normalizeResponse(value, expectedRequestId, pendingRows) {
if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) ||
pendingRows.length < 1 || pendingRows.length > 1000 ||
pendingRows.some(pending => !pendingValues.has(pending)) ||
!hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) ||
value.requestId !== expectedRequestId ||
typeof value.retrievedAt !== "string" || !isoTimestampPattern.test(value.retrievedAt) ||
!Number.isFinite(Date.parse(value.retrievedAt)) ||
value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) ||
value.rows.length !== pendingRows.length) return null;
const rows = [];
for (let index = 0; index < pendingRows.length; index += 1) {
const outcome = normalizeOutcome(value.rows[index], pendingRows[index], value.retrievedAt);
if (!outcome) return null;
rows.push(outcome);
}
return Object.freeze({
requestId: value.requestId,
retrievedAt: value.retrievedAt,
rows: Object.freeze(rows)
});
}
function normalizeError(value, expectedRequestId) {
if (!requestIdPattern.test(expectedRequestId) ||
!hasExactKeys(value, ["requestId", "code", "message", "retryable"]) ||
value.requestId !== expectedRequestId ||
!["PLAYOUT_PRIORITY", "DATABASE_UNAVAILABLE", "RESTORE_FAILED"].includes(value.code) ||
!safeText(value.message, 1024) || value.retryable !== false) return null;
return Object.freeze({ ...value });
}
function buildEntry(pending, outcome) {
const code = outcome.cutCode;
const selection = Object.freeze({
groupCode: "FOREIGN_INDEX",
subject: outcome.inputName,
graphicType: "",
subtype: "PRICE",
dataCode: `${outcome.symbol}|${outcome.nationCode}|${outcome.foreignDomesticCode}`
});
return Object.freeze({
id: pending.entry.id,
builderKey: "s8010",
code,
aliases: Object.freeze([code]),
title: `${outcome.inputName} 캔들 ${outcome.periodDays}`,
detail: `해외지수 · ${outcome.inputName} · ${outcome.periodDays}`,
category: "chart",
market: "overseas",
selection,
enabled: pending.enabled,
fadeDuration: pending.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
operator: Object.freeze({
source: "legacy-foreign-index-candle-workflow",
schemaVersion: SCHEMA_VERSION,
targetKey: outcome.targetKey,
inputName: outcome.inputName,
symbol: outcome.symbol,
nationCode: outcome.nationCode,
foreignDomesticCode: outcome.foreignDomesticCode,
periodDays: outcome.periodDays,
cutCode: outcome.cutCode,
verifiedAt: outcome.verifiedAt
})
});
}
function materialize(pending, outcome) {
if (!pendingValues.has(pending) || !resolvedValues.has(outcome) ||
outcome.itemIndex !== pending.itemIndex || outcome.targetKey !== pending.targetKey ||
outcome.periodDays !== pending.periodDays || outcome.cutCode !== pending.cutCode) return null;
const entry = buildEntry(pending, outcome);
materializedEntries.add(entry);
return entry;
}
function restorePlaylistEntry(value) {
if (!isPlainObject(value) || !hasExactKeys(value, [
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
"selection", "enabled", "fadeDuration", "graphicType", "subtype", "operator"
]) || !requestIdPattern.test(value.id) || value.builderKey !== "s8010" ||
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== value.code ||
typeof value.enabled !== "boolean" || !Number.isInteger(value.fadeDuration) ||
value.fadeDuration < 0 || value.fadeDuration > 60 || !isPlainObject(value.operator) ||
!hasExactKeys(value.operator, [
"source", "schemaVersion", "targetKey", "inputName", "symbol", "nationCode",
"foreignDomesticCode", "periodDays", "cutCode", "verifiedAt"
]) || value.operator.source !== "legacy-foreign-index-candle-workflow" ||
value.operator.schemaVersion !== SCHEMA_VERSION ||
typeof value.operator.verifiedAt !== "string" ||
!isoTimestampPattern.test(value.operator.verifiedAt) ||
!Number.isFinite(Date.parse(value.operator.verifiedAt))) return null;
const target = targetsByKey.get(value.operator.targetKey);
const period = periodsByDays.get(value.operator.periodDays);
if (!target || !period || value.operator.inputName !== target.inputName ||
value.operator.symbol !== target.symbol || value.operator.nationCode !== target.nationCode ||
value.operator.foreignDomesticCode !== target.foreignDomesticCode ||
value.operator.cutCode !== period.cutCode || value.code !== period.cutCode) return null;
const pending = {
entry: { id: value.id },
enabled: value.enabled,
fadeDuration: value.fadeDuration
};
const outcome = {
cutCode: period.cutCode,
inputName: target.inputName,
symbol: target.symbol,
nationCode: target.nationCode,
foreignDomesticCode: target.foreignDomesticCode,
periodDays: period.days,
targetKey: target.key,
verifiedAt: value.operator.verifiedAt
};
const rebuilt = buildEntry(pending, outcome);
const scalarKeys = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"enabled", "fadeDuration", "graphicType", "subtype"
];
if (!scalarKeys.every(key => value[key] === rebuilt[key]) ||
!hasExactKeys(value.selection, ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) ||
!Object.keys(rebuilt.selection).every(key => value.selection[key] === rebuilt.selection[key])) return null;
return rebuilt;
}
function legacySelectionForEntry(entry) {
const restored = restorePlaylistEntry(entry);
if (!restored) return null;
const period = periodsByDays.get(restored.operator.periodDays);
return period ? Object.freeze({
groupCode: "해외지수",
subject: restored.operator.inputName,
graphicType: "캔들그래프",
subtype: period.legacy,
dataCode: ""
}) : null;
}
function matchesRaw(entry, raw) {
const signature = legacySelectionForEntry(entry);
return Boolean(signature && raw && typeof raw.enabled === "boolean" &&
entry.enabled === raw.enabled && raw.pageText === "1/1" &&
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
.every(key => signature[key] === raw[key]));
}
function isPending(value) {
return pendingValues.has(value);
}
function isMaterializedEntry(value) {
return materializedEntries.has(value);
}
return {
bridgeContract,
targetProfiles,
periodProfiles,
classify,
normalizeResponse,
normalizeError,
materialize,
restorePlaylistEntry,
legacySelectionForEntry,
matchesRaw,
isPending,
isMaterializedEntry
};
});

View File

@@ -0,0 +1,489 @@
(function (root, factory) {
"use strict";
const stockWorkflow = typeof module === "object" && module.exports
? require("./operator-workflow.js")
: root.MbnOperatorWorkflow;
const fixedWorkflow = typeof module === "object" && module.exports
? require("./legacy-fixed-workflow.js")
: root.MbnLegacyFixedWorkflow;
const themeWorkflow = typeof module === "object" && module.exports
? require("./theme-workflow.js")
: root.MbnThemeWorkflow;
const expertWorkflow = typeof module === "object" && module.exports
? require("./expert-workflow.js")
: root.MbnExpertWorkflow;
const comparisonWorkflow = typeof module === "object" && module.exports
? require("./comparison-workflow.js")
: root.MbnComparisonWorkflow;
const industryWorkflow = typeof module === "object" && module.exports
? require("./industry-workflow.js")
: root.MbnIndustryWorkflow;
const foreignIndexCandleWorkflow = typeof module === "object" && module.exports
? require("./legacy-foreign-index-candle-workflow.js")
: root.MbnLegacyForeignIndexCandleWorkflow;
const api = Object.freeze(factory(
stockWorkflow,
fixedWorkflow,
themeWorkflow,
expertWorkflow,
comparisonWorkflow,
industryWorkflow,
foreignIndexCandleWorkflow));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnLegacyNamedRowWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (
stockWorkflow,
fixedWorkflow,
themeWorkflow,
expertWorkflow,
comparisonWorkflow,
industryWorkflow,
foreignIndexCandleWorkflow) {
"use strict";
if (!stockWorkflow || !fixedWorkflow || !themeWorkflow || !expertWorkflow ||
!comparisonWorkflow || !industryWorkflow || !foreignIndexCandleWorkflow) {
throw new Error("Legacy named-row dependencies are unavailable.");
}
const selectionKeys = Object.freeze([
"groupCode", "subject", "graphicType", "subtype", "dataCode"
]);
const unsafeTextPattern = /[\u0000-\u001f\u007f\uFFFD]/;
const themeCodePattern = /^[0-9]{3,8}$/;
const expertCodePattern = /^[0-9]{4}$/;
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isSafeField(value) {
return typeof value === "string" && value.length <= 256 &&
!unsafeTextPattern.test(value) && !value.includes("^");
}
function isSafeRawRow(value) {
return isPlainObject(value) && selectionKeys.every(key => isSafeField(value[key]));
}
function sameSelection(left, right) {
return isPlainObject(left) && isSafeRawRow(right) &&
selectionKeys.every(key => isSafeField(left[key]) && left[key] === right[key]);
}
function splitLegacyLabel(value) {
if (!isSafeField(value) || !value) return null;
const separator = value.indexOf("_");
return Object.freeze(separator < 0
? { first: value, remainder: "" }
: { first: value.slice(0, separator), remainder: value.slice(separator + 1) });
}
function stockLegacySignature(operator) {
const stock = operator?.stock;
const label = splitLegacyLabel(operator?.legacyLabel);
if (operator?.source !== "legacy-stock-workflow" || !isPlainObject(stock) || !label ||
!["kospi", "kosdaq"].includes(stock.market) || typeof stock.isNxt !== "boolean" ||
!isSafeField(stock.displayName) || !stock.displayName ||
!isSafeField(stock.stockCode) || !stock.stockCode) return null;
const baseGroup = stock.market === "kospi" ? "코스피" : "코스닥";
return Object.freeze({
groupCode: stock.isNxt ? `${baseGroup}_NXT` : baseGroup,
subject: stock.displayName,
graphicType: label.first,
subtype: label.remainder,
dataCode: stock.stockCode
});
}
function fixedIndexGroup(label) {
if (label.includes("코스피_NXT")) return "코스피_NXT";
if (label.includes("코스닥_NXT")) return "코스닥_NXT";
if (label.includes("코스피")) return "코스피";
if (label.includes("코스닥")) return "코스닥";
return "전체";
}
function fixedLegacySignature(value) {
const tab = value?.legacyTab ?? value?.market;
const section = value?.legacySection ?? value?.section;
const labelText = value?.legacyLabel ?? value?.label;
const label = splitLegacyLabel(labelText);
if (!label || !isSafeField(section) || !section ||
!["overseas", "exchange", "index"].includes(tab)) return null;
if (tab === "overseas" || tab === "exchange") {
return Object.freeze({
groupCode: tab === "overseas" ? "해외지수" : "환율",
subject: label.first,
graphicType: section,
subtype: label.remainder,
dataCode: ""
});
}
if (["5단 표그래프", "6종목 현재가", "12종목 현재가"].includes(section)) {
return Object.freeze({
groupCode: fixedIndexGroup(labelText),
subject: "지수",
graphicType: section,
subtype: labelText,
dataCode: ""
});
}
if (section === "매매동향") {
return Object.freeze({
groupCode: fixedIndexGroup(labelText),
subject: "지수",
graphicType: label.first,
subtype: label.remainder,
dataCode: ""
});
}
return Object.freeze({
groupCode: section,
subject: "지수",
graphicType: label.first,
subtype: label.remainder,
dataCode: ""
});
}
function themeLegacyDescriptor(rawRow, options = {}) {
const isNxt = rawRow?.groupCode === "테마_NXT";
if (!isSafeRawRow(rawRow) || (rawRow.groupCode !== "테마" && !isNxt) ||
!rawRow.subject || !themeCodePattern.test(rawRow.dataCode)) return null;
const match = /^테마-(현재가|예상체결가)(?:\((입력순|상승률순|하락률순)\))?$/.exec(rawRow.subtype);
if (!match) return null;
const valueKind = match[1] === "현재가" ? "CURRENT" : "EXPECTED";
if (isNxt && valueKind !== "CURRENT") return null;
// The original s5074/s5077/s5088 code falls through to its final
// "하락률순" branch when the persisted subtype has no parenthesized sort.
const sort = match[2]
? ({ 입력순: "INPUT_ORDER", 상승률순: "GAIN_DESC", 하락률순: "GAIN_ASC" })[match[2]]
: "GAIN_ASC";
const actionId = ({
"5단 표그래프|CURRENT": "five-row-current",
"5단 표그래프|EXPECTED": "five-row-expected",
"6종목 현재가|CURRENT": "six-row-current",
"12종목 현재가|CURRENT": "twelve-row-current"
})[`${rawRow.graphicType}|${valueKind}`];
if (!actionId) return null;
let title = rawRow.subject;
let session = null;
if (isNxt) {
const suffix = "(NXT)";
if (!title.endsWith(suffix) || title.length === suffix.length) return null;
title = title.slice(0, -suffix.length);
if (!isSafeField(title) || !title || title.includes(suffix)) return null;
const suppliedNow = options?.now;
const now = suppliedNow instanceof Date && Number.isFinite(suppliedNow.getTime())
? suppliedNow
: new Date();
// Original s5074/s5077/s5088 selects the NXT mark at scene construction:
// local hour 00..12 is pre-market and 13..23 is after-market.
session = now.getHours() <= 12 ? "PRE_MARKET" : "AFTER_MARKET";
}
return Object.freeze({
actionId,
sort,
theme: Object.freeze({
market: isNxt ? "nxt" : "krx",
themeCode: rawRow.dataCode,
title,
session,
itemCount: 1
})
});
}
function themeLegacySignature(operator) {
const theme = operator?.theme;
if (operator?.source !== "legacy-theme-workflow" || !isPlainObject(theme) ||
theme.market !== "krx" || !themeCodePattern.test(theme.themeCode) ||
!isSafeField(theme.title) || !theme.title) return null;
const action = ({
"five-row-current": ["5단 표그래프", "현재가"],
"five-row-expected": ["5단 표그래프", "예상체결가"],
"six-row-current": ["6종목 현재가", "현재가"],
"twelve-row-current": ["12종목 현재가", "현재가"]
})[operator.actionId];
const sortLabel = ({
INPUT_ORDER: "입력순",
GAIN_DESC: "상승률순",
GAIN_ASC: "하락률순"
})[operator.sort];
if (!action || !sortLabel) return null;
return Object.freeze({
groupCode: "테마",
subject: theme.title,
graphicType: action[0],
subtype: `테마-${action[1]}(${sortLabel})`,
dataCode: theme.themeCode
});
}
function expertLegacyDescriptor(rawRow) {
if (!isSafeRawRow(rawRow) || rawRow.groupCode !== "전문가 추천" ||
!rawRow.subject || rawRow.graphicType !== "5단 표그래프" ||
rawRow.subtype !== "현재가" || !expertCodePattern.test(rawRow.dataCode)) return null;
return Object.freeze({
expertCode: rawRow.dataCode,
expertName: rawRow.subject,
source: "oracle",
itemCount: 1,
previewTruncated: false
});
}
function expertLegacySignature(operator) {
const expert = operator?.expert;
if (operator?.source !== "legacy-expert-workflow" || !isPlainObject(expert) ||
!expertCodePattern.test(expert.expertCode) ||
!isSafeField(expert.expertName) || !expert.expertName) return null;
return Object.freeze({
groupCode: "전문가 추천",
subject: expert.expertName,
graphicType: "5단 표그래프",
subtype: "현재가",
dataCode: expert.expertCode
});
}
function comparisonLegacySignature(operator) {
if (operator?.source !== "legacy-comparison-workflow" ||
!Array.isArray(operator.pair) || operator.pair.length !== 2) return null;
const pair = comparisonWorkflow.normalizePair(operator.pair);
if (!pair) return null;
const subjects = pair.map(target => {
if (target.kind === "market-target") return target.displayName;
if (target.kind === "krx-stock") return target.stockName;
return null;
});
if (subjects.some(value => !isSafeField(value) || !value || value.includes(","))) return null;
if (["two-column-current", "two-column-expected"].includes(operator.actionId)) {
// The original "종목" row discarded each target's market. Only a pair of
// closed fixed selectors can be projected without creating a lossy row.
if (pair.some(target => target.kind !== "market-target")) return null;
const subject = subjects.join(",");
// UC1 persisted the generic "종목" group when neither label contained
// "지수" (for example an exchange-only pair). That row cannot prove it
// was not a same-name world stock, so do not create that lossy signature.
if (!subject.includes("지수")) return null;
return Object.freeze({
groupCode: "지수",
subject,
graphicType: "2열판",
subtype: operator.actionId === "two-column-expected" ? "예상체결" : "",
dataCode: ""
});
}
if (pair.some(target => target.kind !== "krx-stock")) return null;
const action = ({
"comparison-candle": ["종목별 비교분석_캔들 그래프", ""],
"comparison-line": ["종목별 비교분석_라인 그래프", ""],
"return-5d": ["종목별 수익률 비교", "5일"],
"return-1m": ["종목별 수익률 비교", "1개월"],
"return-3m": ["종목별 수익률 비교", "3개월"],
"return-6m": ["종목별 수익률 비교", "6개월"],
"return-12m": ["종목별 수익률 비교", "12개월"]
})[operator.actionId];
if (!action) return null;
return Object.freeze({
groupCode: pair.map(target => target.market === "kospi" ? "코스피" : "코스닥").join(","),
subject: subjects.join(","),
graphicType: action[0],
subtype: action[1],
dataCode: ""
});
}
function industryFixedDescriptor(rawRow) {
if (!isSafeRawRow(rawRow) || rawRow.subtype !== "" || rawRow.dataCode !== "") return null;
const market = ({
"업종_코스피": "kospi",
"업종_코스닥": "kosdaq"
})[rawRow.groupCode];
if (!market || rawRow.subject !== (market === "kospi" ? "코스피" : "코스닥")) return null;
const cutId = ({
"5단 표그래프": "five-row",
"네모그래프": "square",
"섹터지수": "sector"
})[rawRow.graphicType];
return cutId ? Object.freeze({ market, cutId }) : null;
}
function industryFixedLegacySignature(operator) {
if (operator?.source !== "legacy-industry-workflow" ||
!["kospi", "kosdaq"].includes(operator.market)) return null;
const graphicType = ({
"five-row": "5단 표그래프",
square: "네모그래프",
sector: "섹터지수"
})[operator.cutId];
if (!graphicType) return null;
return Object.freeze({
groupCode: operator.market === "kospi" ? "업종_코스피" : "업종_코스닥",
subject: operator.market === "kospi" ? "코스피" : "코스닥",
graphicType,
subtype: "",
dataCode: ""
});
}
function matchesFixedActionRaw(rawRow, action) {
return Boolean(action?.available && sameSelection(fixedLegacySignature(action), rawRow));
}
function matchesCanonicalEntry(entry, rawRow, options = {}) {
if (!isPlainObject(entry) || !isSafeRawRow(rawRow)) return false;
if (sameSelection(entry.selection, rawRow)) return true;
let signature = null;
switch (entry.operator?.source) {
case "legacy-stock-workflow":
signature = stockLegacySignature(entry.operator);
break;
case "legacy-fixed-workflow":
signature = fixedLegacySignature(entry.operator);
break;
case "legacy-theme-workflow":
if (entry.operator?.theme?.market === "nxt") {
const descriptor = themeLegacyDescriptor(rawRow, options);
return Boolean(descriptor && descriptor.theme.market === "nxt" &&
descriptor.actionId === entry.operator.actionId &&
descriptor.sort === entry.operator.sort &&
descriptor.theme.themeCode === entry.operator.theme.themeCode &&
descriptor.theme.title === entry.operator.theme.title &&
descriptor.theme.session === entry.operator.theme.session);
}
signature = themeLegacySignature(entry.operator);
if (sameSelection(signature, rawRow)) return true;
if (entry.operator.sort === "GAIN_ASC" && signature) {
const originalFallback = {
...signature,
subtype: signature.subtype.replace(/\(하락률순\)$/, "")
};
return sameSelection(originalFallback, rawRow);
}
break;
case "legacy-expert-workflow":
signature = expertLegacySignature(entry.operator);
break;
case "legacy-comparison-workflow":
signature = comparisonLegacySignature(entry.operator);
break;
case "legacy-industry-workflow":
signature = industryFixedLegacySignature(entry.operator);
break;
case "legacy-foreign-index-candle-workflow":
return foreignIndexCandleWorkflow.matchesRaw(entry, rawRow);
default:
return false;
}
return sameSelection(signature, rawRow);
}
function fixedRestoreNow(entry) {
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(entry?.selection?.dataCode || "");
const now = dateMatch
? new Date(Number(dateMatch[1]), Number(dateMatch[2]) - 1, Number(dateMatch[3]), 9, 0, 0, 0)
: new Date();
if (entry?.selection?.graphicType === "PRE_MARKET") now.setHours(9, 0, 0, 0);
if (entry?.selection?.graphicType === "AFTER_MARKET") now.setHours(14, 0, 0, 0);
return now;
}
function restoreTrustedEntry(entry) {
try {
switch (entry?.operator?.source) {
case "legacy-stock-workflow":
return stockWorkflow.restoreStockPlaylistEntry(entry);
case "legacy-fixed-workflow":
return fixedWorkflow.restoreFixedPlaylistEntry(entry, { now: fixedRestoreNow(entry) });
case "legacy-theme-workflow":
return themeWorkflow.restoreThemePlaylistEntry(entry);
case "legacy-expert-workflow":
return expertWorkflow.restoreExpertPlaylistEntry(entry);
case "legacy-comparison-workflow":
return comparisonWorkflow.restoreComparisonPlaylistEntry(entry);
case "legacy-industry-workflow":
return industryWorkflow.restoreIndustryPlaylistEntry(entry);
case "legacy-foreign-index-candle-workflow":
return foreignIndexCandleWorkflow.restorePlaylistEntry(entry);
default:
return null;
}
} catch {
return null;
}
}
function legacySelectionForEntry(entry) {
if (!isPlainObject(entry) || !isPlainObject(entry.selection) ||
!selectionKeys.every(key => isSafeField(entry.selection[key]))) return null;
const trusted = restoreTrustedEntry(entry);
if (!trusted || trusted.enabled !== entry.enabled) return null;
switch (trusted.operator?.source) {
case "legacy-stock-workflow":
return stockLegacySignature(trusted.operator);
case "legacy-fixed-workflow":
return fixedLegacySignature(trusted.operator);
case "legacy-theme-workflow":
// The original NXT row did not persist PRE/AFTER session. Do not create
// a new lossy row that this closed restore contract must later block.
return themeLegacySignature(trusted.operator);
case "legacy-expert-workflow":
return expertLegacySignature(trusted.operator);
case "legacy-comparison-workflow":
return comparisonLegacySignature(trusted.operator);
case "legacy-industry-workflow":
return industryFixedLegacySignature(trusted.operator);
case "legacy-foreign-index-candle-workflow":
return foreignIndexCandleWorkflow.legacySelectionForEntry(trusted);
default:
return null;
}
}
function semanticKey(entry) {
const operator = entry?.operator;
return JSON.stringify({
builderKey: entry?.builderKey,
code: entry?.code,
selection: entry?.selection,
source: operator?.source ?? "",
actionId: operator?.actionId ?? "",
cutId: operator?.cutId ?? ""
});
}
function selectUniqueCanonicalEntry(candidates, rawRow, options = {}) {
if (!Array.isArray(candidates) || !isSafeRawRow(rawRow)) return null;
const matches = new Map();
for (const candidate of candidates) {
if (!matchesCanonicalEntry(candidate, rawRow, options)) continue;
const key = semanticKey(candidate);
if (!matches.has(key)) matches.set(key, candidate);
}
return matches.size === 1 ? [...matches.values()][0] : null;
}
return {
selectionKeys,
fixedLegacySignature,
comparisonLegacySignature,
industryFixedDescriptor,
industryFixedLegacySignature,
matchesFixedActionRaw,
themeLegacyDescriptor,
expertLegacyDescriptor,
matchesCanonicalEntry,
legacySelectionForEntry,
selectUniqueCanonicalEntry
};
});

View File

@@ -96,7 +96,7 @@
.mfui-list-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
grid-template-rows: auto auto auto minmax(0, 1fr);
gap: 12px;
border-right: 1px solid var(--mfui-border);
background: rgba(8, 19, 31, 0.92);
@@ -118,7 +118,7 @@
.mfui-search {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(76px, 1fr) auto auto auto;
gap: 8px;
}
@@ -164,6 +164,31 @@
background: rgba(2, 10, 19, 0.48);
}
.mfui-list-sort {
width: 100%;
min-height: 30px;
padding: 0 11px;
border: 1px solid rgba(135, 158, 186, 0.24);
border-radius: 7px;
background: rgba(19, 37, 56, 0.88);
color: #b8c9da;
font-size: 11px;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.mfui-list-sort:hover,
.mfui-list-sort:focus-visible {
border-color: #55a9ed;
color: #fff;
outline: none;
}
.mfui-list-sort[data-direction="descending"] {
color: #73b8ee;
}
.mfui-list-row {
width: 100%;
padding: 10px 12px;
@@ -234,6 +259,7 @@
.mfui-button,
.mfui-search-button,
.mfui-find-button,
.mfui-close {
min-height: 36px;
padding: 0 13px;
@@ -247,6 +273,7 @@
.mfui-button:hover:not(:disabled),
.mfui-search-button:hover:not(:disabled),
.mfui-find-button:hover:not(:disabled),
.mfui-close:hover:not(:disabled) {
border-color: #78b9eb;
background: rgba(43, 75, 105, 0.96);
@@ -268,11 +295,18 @@
}
.mfui-button:disabled,
.mfui-search-button:disabled {
.mfui-search-button:disabled,
.mfui-find-button:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.mfui-search-button,
.mfui-find-button {
padding-inline: 9px;
white-space: nowrap;
}
.mfui-status {
color: #c6d4e2;
font-size: 12px;
@@ -320,6 +354,14 @@
grid-template-columns: 1fr;
}
.mfui-search {
grid-template-columns: minmax(0, 1fr) auto;
}
.mfui-find-button {
width: 100%;
}
.mfui-quarantine {
max-width: none;
}

View File

@@ -19,6 +19,8 @@
const STOCK_SEARCH_RESULT = "stock-search-results";
const STOCK_SEARCH_ERROR = "stock-search-error";
const STOCK_SEARCH_MAXIMUM_QUERY_LENGTH = 64;
const DISPLAY_SORT_ASCENDING = "ascending";
const DISPLAY_SORT_DESCENDING = "descending";
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const UNSAFE_TEXT_PATTERN = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const LEGACY_ACTION_SCREENS = Object.freeze({
@@ -61,6 +63,32 @@
return value === undefined || value === null ? "" : String(value).trim();
}
function compareDisplayStockNames(left, right) {
const foldedLeft = left.toUpperCase();
const foldedRight = right.toUpperCase();
if (foldedLeft < foldedRight) return -1;
if (foldedLeft > foldedRight) return 1;
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
function sortDisplaySnapshots(items, direction = DISPLAY_SORT_ASCENDING) {
if (!Array.isArray(items) ||
![DISPLAY_SORT_ASCENDING, DISPLAY_SORT_DESCENDING].includes(direction) ||
items.some(item => !item || typeof item.stockName !== "string")) {
throw new TypeError("Closed GraphE display rows and sort direction are required.");
}
const sign = direction === DISPLAY_SORT_ASCENDING ? 1 : -1;
return Object.freeze(items
.map((item, sourceIndex) => ({ item, sourceIndex }))
.sort((left, right) => {
const byName = compareDisplayStockNames(left.item.stockName, right.item.stockName);
return byName === 0 ? left.sourceIndex - right.sourceIndex : byName * sign;
})
.map(value => value.item));
}
function freezeObject(value) {
return Object.freeze({ ...value });
}
@@ -326,6 +354,10 @@
screen: null,
profile: null,
query: "",
findQuery: "",
hasLoadedList: false,
sortDirection: DISPLAY_SORT_ASCENDING,
sourceItems: Object.freeze([]),
status: "idle",
message: "",
items: [],
@@ -393,6 +425,9 @@
status: state.status,
message: state.message,
query: state.query,
findQuery: state.findQuery,
hasLoadedList: state.hasLoadedList,
sortDirection: state.sortDirection,
itemCount: state.items.length,
selectedStockName: state.snapshot?.stockName || state.form?.stockName || "",
hasSnapshot: !!state.snapshot,
@@ -423,6 +458,8 @@
const request = workflow.createListRequest(
nextRequestId(), state.screen, textValue(query), contract.defaultMaximumResults);
state.query = request.query;
state.findQuery = request.query;
state.hasLoadedList = false;
pending.set("list", request);
setStatus("loading", "수동 재무 목록을 조회하고 있습니다.");
post(contract.listRequestType, request);
@@ -455,6 +492,58 @@
}
}
function toggleDisplaySort() {
state.sortDirection = state.sortDirection === DISPLAY_SORT_ASCENDING
? DISPLAY_SORT_DESCENDING
: DISPLAY_SORT_ASCENDING;
state.items = sortDisplaySnapshots(state.sourceItems, state.sortDirection);
render();
return true;
}
function findBlocked() {
return !state.visible || !state.screen || !state.hasLoadedList ||
externallyLocked() || !!pending.get("list") || !!pending.get("load") ||
!!pending.get("write") || !!pending.get("stock") || !!pending.get("selection");
}
function requestNextMatchingLoad(query) {
if (findBlocked()) return false;
state.findQuery = textValue(query);
const needle = state.findQuery.toUpperCase();
if (!needle) return false;
const selectedName = state.snapshot?.stockName || null;
const selectedIndex = selectedName === null
? -1
: state.items.findIndex(item => item.stockName === selectedName);
for (let index = selectedIndex + 1; index < state.items.length; index += 1) {
const candidate = state.items[index];
if (candidate.stockName.toUpperCase().includes(needle)) {
return requestLoad(candidate.stockName);
}
}
return false;
}
function requestPreviousMatchingLoad(query) {
if (findBlocked() || !state.snapshot) return false;
state.findQuery = textValue(query);
const needle = state.findQuery.toUpperCase();
if (!needle) return false;
const selectedIndex = state.items.findIndex(
item => item.stockName === state.snapshot.stockName);
if (selectedIndex < 0) return false;
for (let index = selectedIndex - 1; index >= 0; index -= 1) {
const candidate = state.items[index];
if (candidate.stockName.toUpperCase().includes(needle)) {
return requestLoad(candidate.stockName);
}
}
return false;
}
function syncFormFromDom() {
if (!state.form || !refs.formInputs) return;
const next = { ...state.form };
@@ -637,7 +726,10 @@
function handleListResult(payload) {
return consumeRead("list", payload, workflow.normalizeListResponse, normalized => {
state.items = [...normalized.items];
state.sourceItems = normalized.items;
state.sortDirection = DISPLAY_SORT_ASCENDING;
state.items = sortDisplaySnapshots(state.sourceItems, state.sortDirection);
state.hasLoadedList = true;
setStatus("ready", `${normalized.totalRowCount}개 행을 조회했습니다.`);
});
}
@@ -870,17 +962,43 @@
query.maxLength = contract.maximumQueryLength;
query.placeholder = "저장 종목명 검색";
query.setAttribute("aria-label", "수동 재무 저장 종목 검색");
query.addEventListener("input", () => {
state.findQuery = query.value;
});
query.addEventListener("keydown", event => {
if (event.key !== "Enter" || event.isComposing || !state.hasLoadedList) return;
event.preventDefault();
requestNextMatchingLoad(query.value);
});
const searchButton = makeElement(documentObject, "button", "mfui-search-button", "조회");
searchButton.type = "submit";
searchForm.append(query, searchButton);
const findPrevious = makeButton(
documentObject,
"위로 찾기",
"mfui-find-button mfui-find-previous",
() => requestPreviousMatchingLoad(query.value));
findPrevious.setAttribute("aria-label", "현재 선택 이전의 일치 종목 찾기");
const findNext = makeButton(
documentObject,
"아래로 찾기",
"mfui-find-button mfui-find-next",
() => requestNextMatchingLoad(query.value));
findNext.setAttribute("aria-label", "현재 선택 다음의 일치 종목 찾기");
searchForm.append(query, searchButton, findPrevious, findNext);
searchForm.addEventListener("submit", event => {
event.preventDefault();
requestList(query.value);
});
const listState = makeElement(documentObject, "p", "mfui-list-state", "목록을 열어 주세요.");
const sortHeader = makeButton(
documentObject,
"종목명 ↕",
"mfui-list-sort",
toggleDisplaySort);
sortHeader.setAttribute("aria-label", "종목명 오름차순, 내림차순으로 전환");
const list = makeElement(documentObject, "div", "mfui-list");
list.setAttribute("role", "listbox");
listPanel.append(searchForm, listState, list);
listPanel.append(searchForm, listState, sortHeader, list);
const editor = makeElement(documentObject, "article", "mfui-editor");
const editorTitle = makeElement(documentObject, "h3", "mfui-editor-title", "입력값");
@@ -925,7 +1043,10 @@
close,
query,
searchButton,
findPrevious,
findNext,
listState,
sortHeader,
list,
editorTitle,
stockStatus,
@@ -1037,10 +1158,19 @@
refs.subtitle.textContent = state.profile
? `${state.profile.table} · ${state.profile.builderKey}/${state.profile.code}`
: "GraphE DB 행을 안전하게 조회합니다.";
refs.query.value = state.query;
refs.query.value = state.findQuery;
refs.listState.textContent = state.status === "loading" && pending.get("list")
? "목록 조회 중"
: `${state.items.length}개 행`;
refs.sortHeader.dataset.direction = state.sortDirection;
refs.sortHeader.setAttribute(
"aria-pressed",
state.sortDirection === DISPLAY_SORT_DESCENDING ? "true" : "false");
refs.sortHeader.setAttribute(
"aria-label",
state.sortDirection === DISPLAY_SORT_ASCENDING
? "종목명 오름차순, 내림차순으로 전환"
: "종목명 내림차순, 오름차순으로 전환");
refs.editorTitle.textContent = state.snapshot
? `${state.snapshot.stockName} 수정`
: "신규 입력";
@@ -1057,6 +1187,8 @@
refs.quarantine.textContent = view.quarantineMessage;
const blocked = writeBlocked();
refs.searchButton.disabled = !!pending.get("list");
refs.findPrevious.disabled = findBlocked();
refs.findNext.disabled = findBlocked();
refs.verify.disabled = externallyLocked() || !!pending.get("stock") ||
!!pending.get("write") || !!pending.get("load") || !!pending.get("selection");
refs.reset.disabled = writeBlocked();
@@ -1085,7 +1217,11 @@
state.profile = workflow.getScreenDefinition(screen);
state.visible = true;
state.query = "";
state.items = [];
state.findQuery = "";
state.hasLoadedList = false;
state.sortDirection = DISPLAY_SORT_ASCENDING;
state.sourceItems = Object.freeze([]);
state.items = Object.freeze([]);
state.snapshot = null;
state.form = emptyForm(screen, seedStockName());
state.stockStatus = "idle";
@@ -1122,6 +1258,7 @@
emptyForm,
recordToForm,
formToRecord,
sortDisplaySnapshots,
createPendingCoordinator,
getProcessWriteQuarantine
};

View File

@@ -134,6 +134,8 @@
mode: null,
manualLocked: false,
available: null,
unavailableCode: "",
unavailableMessage: "",
quarantined: false,
quarantineMessage: "",
audience: "INDIVIDUAL",
@@ -150,6 +152,7 @@
searchMessage: "",
pending: {
status: null,
legacyImport: null,
netRead: null,
netSave: null,
viRead: null,
@@ -167,7 +170,15 @@
}
function canMutate() {
return state.available === true && !state.pending.status && !locked() && !state.quarantined;
return state.available === true && !state.pending.status && !state.pending.legacyImport &&
!locked() && !state.quarantined;
}
function hasPendingManualDataOperation() {
return Boolean(
state.pending.status || state.pending.legacyImport ||
state.pending.netRead || state.pending.netSave ||
state.pending.viRead || state.pending.viSave);
}
function notify(message, kind = "info") {
@@ -197,6 +208,29 @@
send(workflow.createStatusRequest(requestId));
}
function importLegacyManualData() {
if (!canMutate() || hasPendingManualDataOperation()) return false;
const confirmation =
"원본 프로젝트의 개인.dat, 외국인.dat, 기관.dat, VI발동.dat를 " +
"앱 전용 폴더로 한 번만 가져옵니다. 기존 대상 파일이 하나라도 있으면 " +
"아무것도 덮어쓰지 않고 중단합니다. 계속하시겠습니까?";
let confirmed = false;
try {
confirmed = root && typeof root.confirm === "function" && root.confirm(confirmation) === true;
} catch {
confirmed = false;
}
if (!confirmed) return false;
const request = workflow.createLegacyImportRequest(nextId());
state.pending.legacyImport = { requestId: request.payload.requestId };
state.netVerified = null;
state.viVerified = null;
send(request);
render();
return true;
}
function requestNetRead(audience, verification = null) {
state.netVerified = null;
const requestId = nextId();
@@ -281,7 +315,7 @@
const entry = workflow.createNetSellPlaylistEntry(state.audience, {
id: nextId(),
fadeDuration: DEFAULT_FADE_DURATION
});
}, verified.freshRead);
appendPlaylistEntry(entry);
notify("수동 순매도 컷을 playlist에 추가했습니다.", "success");
log(`수동 순매도 컷 추가 · ${state.audience}`);
@@ -381,7 +415,7 @@
const entry = workflow.createViPlaylistEntry(state.viItems, state.viVersion, {
id: nextId(),
fadeDuration: DEFAULT_FADE_DURATION
});
}, verified.freshRead);
appendPlaylistEntry(entry);
notify("VI 발동 컷을 playlist에 추가했습니다.", "success");
log(`VI 발동 컷 추가 · ${state.viItems.length}종목 · ${entry.pageCount}페이지`);
@@ -399,6 +433,8 @@
if (!normalized) return false;
state.pending.status = null;
state.available = normalized.available;
state.unavailableCode = normalized.available ? "" : normalized.code;
state.unavailableMessage = normalized.available ? "" : (normalized.message || "");
if (normalized.writeQuarantined) {
state.quarantined = true;
state.quarantineMessage = normalized.message || "수동 저장 결과를 확인할 수 없습니다.";
@@ -407,6 +443,24 @@
return true;
}
function handleLegacyImport(payload) {
const pending = state.pending.legacyImport;
if (!pending) return false;
const normalized = workflow.normalizeLegacyImportResponse(payload, pending.requestId);
if (!normalized) return false;
state.pending.legacyImport = null;
state.netVerified = null;
state.viVerified = null;
notify(
`원본 수동 데이터를 가져왔습니다. 순매도 ${normalized.netSellRowCount}행 · VI ${normalized.viItemCount}종목`,
"success");
log(`원본 수동 데이터 가져오기 완료 · marker v${normalized.markerVersion}`);
if (state.mode === "net") requestNetRead(state.audience);
if (state.mode === "vi") requestViRead();
render();
return true;
}
function handleNetData(payload) {
const pending = state.pending.netRead;
if (!pending) return false;
@@ -420,7 +474,8 @@
rowsEqual(pending.verification.rows, normalized.rows)) {
state.netVerified = {
audience: state.audience,
rows: copyRows(normalized.rows)
rows: copyRows(normalized.rows),
freshRead: normalized
};
notify("저장 파일 재조회가 입력과 일치합니다.", "success");
} else {
@@ -465,7 +520,8 @@
} else {
state.viVerified = {
version: normalized.version,
items: copyItems(normalized.items)
items: copyItems(normalized.items),
freshRead: normalized
};
if (verification) {
notify(
@@ -501,6 +557,7 @@
const pendingOperations = Object.freeze({
status: "status",
legacyImport: "import-legacy-manual-data",
netRead: "read-net-sell",
netSave: "save-net-sell",
viRead: "read-vi",
@@ -514,7 +571,11 @@
const normalized = workflow.normalizeError(payload, pending.requestId);
if (!normalized || normalized.operation !== operation) return false;
state.pending[key] = null;
if (key === "status") state.available = false;
if (key === "status") {
state.available = false;
state.unavailableCode = normalized.code;
state.unavailableMessage = normalized.message;
}
if (key === "viSave" && normalized.code === "STALE_DATA") {
state.viBaseVersion = null;
state.viVersion = null;
@@ -566,6 +627,7 @@
function handleMessage(type, payload) {
switch (type) {
case workflow.bridgeContract.statusResultType: return handleStatus(payload);
case workflow.bridgeContract.legacyImportResultType: return handleLegacyImport(payload);
case workflow.bridgeContract.netSellDataType: return handleNetData(payload);
case workflow.bridgeContract.netSellSaveResultType: return handleNetSave(payload);
case workflow.bridgeContract.viDataType: return handleViData(payload);
@@ -603,7 +665,12 @@
state.open = false;
render();
}, { className: "manual-lists-close" });
header.append(title, close);
const legacyImport = button("원본 데이터 가져오기", importLegacyManualData, {
className: "manual-lists-button",
disabled: !canMutate() || hasPendingManualDataOperation(),
title: "원본 프로젝트의 FSell/VI 파일을 앱 전용 폴더로 한 번만 가져옵니다."
});
header.append(title, legacyImport, close);
panel.append(header);
}
@@ -624,7 +691,7 @@
panel.append(element(
"div",
"manual-lists-banner manual-lists-banner-danger",
"수동 데이터 저장소를 사용할 수 없습니다."));
state.unavailableMessage || "수동 데이터 저장소를 사용할 수 없습니다."));
}
}
@@ -806,6 +873,8 @@
mode: state.mode,
locked: locked(),
available: state.available,
unavailableCode: state.unavailableCode,
unavailableMessage: state.unavailableMessage,
quarantined: state.quarantined,
audience: state.audience,
netRows: Object.freeze(copyRows(state.netRows)),
@@ -896,6 +965,7 @@
clearViItems,
saveVi,
addViCut,
importLegacyManualData,
requestViRead: () => requestViRead(),
close: () => {
state.open = false;

View File

@@ -16,6 +16,16 @@
const rowVersionPattern = /^[a-f0-9]{64}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const audiences = Object.freeze(["INDIVIDUAL", "FOREIGN", "INSTITUTION"]);
const statusCodes = new Set([
"READY",
"IMPORT_INTERRUPTED",
"IMPORTED_STATE_INVALID",
"INITIALIZATION_FAILED",
"UNSAFE_CONFIGURATION"
]);
const trustedNetSellReadResponses = new WeakSet();
const trustedViReadResponses = new WeakSet();
const trustedPlaylistEntries = new WeakMap();
const audienceLabels = Object.freeze({
INDIVIDUAL: "개인",
FOREIGN: "외국인",
@@ -25,6 +35,8 @@
const bridgeContract = Object.freeze({
statusRequestType: "request-manual-operator-data-status",
statusResultType: "manual-operator-data-status",
legacyImportType: "import-legacy-manual-operator-data",
legacyImportResultType: "legacy-manual-operator-import-result",
netSellReadType: "request-manual-net-sell-data",
netSellDataType: "manual-net-sell-data",
netSellSaveType: "save-manual-net-sell-data",
@@ -107,6 +119,10 @@
return request(bridgeContract.statusRequestType, requestId);
}
function createLegacyImportRequest(requestId) {
return request(bridgeContract.legacyImportType, requestId);
}
function createNetSellReadRequest(requestId, audience) {
const normalized = normalizeAudience(audience);
if (!normalized) throw new RangeError("The manual net-sell audience is invalid.");
@@ -139,15 +155,31 @@
}
function normalizeStatusResponse(value, expectedRequestId) {
if (!hasExactKeys(value, ["requestId", "available", "writeQuarantined", "message"]) ||
if (!hasExactKeys(value, ["requestId", "available", "code", "writeQuarantined", "message"]) ||
!isRequestId(value.requestId) ||
typeof value.available !== "boolean" ||
!statusCodes.has(value.code) ||
value.available !== (value.code === "READY") ||
typeof value.writeQuarantined !== "boolean" ||
!(value.message === null || isSafeText(value.message, 512, false)) ||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
return Object.freeze({ ...value });
}
function normalizeLegacyImportResponse(value, expectedRequestId) {
if (!hasExactKeys(value, [
"requestId", "markerVersion", "sourceSha256", "importedAt",
"netSellRowCount", "viItemCount"
]) || !isRequestId(value.requestId) || value.markerVersion !== 1 ||
!isRowVersion(value.sourceSha256) ||
typeof value.importedAt !== "string" || !Number.isFinite(Date.parse(value.importedAt)) ||
value.netSellRowCount !== 15 ||
!Number.isInteger(value.viItemCount) || value.viItemCount < 0 ||
value.viItemCount > MAXIMUM_VI_ITEMS ||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
return Object.freeze({ ...value });
}
function normalizeNetSellDataResponse(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "audience", "rows"]) ||
!isRequestId(value.requestId) ||
@@ -155,7 +187,10 @@
(expectedRequest &&
(value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null;
const rows = normalizeNetSellRows(value.rows);
return rows && Object.freeze({ requestId: value.requestId, audience: value.audience, rows });
if (!rows) return null;
const response = Object.freeze({ requestId: value.requestId, audience: value.audience, rows });
trustedNetSellReadResponses.add(response);
return response;
}
function normalizeNetSellSaveResponse(value, expectedRequest) {
@@ -178,13 +213,15 @@
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
const items = normalizeViItems(value.items);
if (!items || items.length !== value.itemCount) return null;
return Object.freeze({
const response = Object.freeze({
requestId: value.requestId,
itemCount: value.itemCount,
pageCount: value.pageCount,
items,
version: value.version
});
trustedViReadResponses.add(response);
return response;
}
function normalizeViSaveResponse(value, expectedRequestId) {
@@ -213,14 +250,23 @@
}
function requireOptions(options) {
if (!hasExactKeys(options, ["id", "fadeDuration"]) || !isRequestId(options.id) ||
!Number.isInteger(options.fadeDuration) || options.fadeDuration < 0 || options.fadeDuration > 60) {
const keys = options && typeof options === "object" ? Object.keys(options).sort() : [];
const exact = keys.length === 2 && keys[0] === "fadeDuration" && keys[1] === "id";
const exactWithEnabled = keys.length === 3 && keys[0] === "enabled" &&
keys[1] === "fadeDuration" && keys[2] === "id";
if ((!exact && !exactWithEnabled) || !isRequestId(options.id) ||
!Number.isInteger(options.fadeDuration) || options.fadeDuration < 0 || options.fadeDuration > 60 ||
(exactWithEnabled && typeof options.enabled !== "boolean")) {
throw new TypeError("Safe manual-list playlist options are required.");
}
return options;
return Object.freeze({
id: options.id,
fadeDuration: options.fadeDuration,
enabled: exactWithEnabled ? options.enabled : true
});
}
function createNetSellPlaylistEntry(audience, options) {
function createNetSellEntryObject(audience, options) {
const normalizedAudience = normalizeAudience(audience);
const normalizedOptions = requireOptions(options);
if (!normalizedAudience) throw new RangeError("The manual net-sell audience is invalid.");
@@ -242,7 +288,7 @@
category: "market",
market: "index",
selection,
enabled: true,
enabled: normalizedOptions.enabled,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
@@ -255,7 +301,7 @@
};
}
function createViPlaylistEntry(items, rowVersion, options) {
function createViEntryObject(items, rowVersion, options) {
const normalizedItems = normalizeViItems(items, false);
const normalizedOptions = requireOptions(options);
if (!normalizedItems || !isRowVersion(rowVersion)) {
@@ -282,7 +328,7 @@
pageSize: VI_PAGE_SIZE,
pageCount,
selection,
enabled: true,
enabled: normalizedOptions.enabled,
fadeDuration: normalizedOptions.fadeDuration,
graphicType: selection.graphicType,
subtype: selection.subtype,
@@ -302,6 +348,43 @@
};
}
function createNetSellPlaylistEntry(audience, options, freshRead) {
const entry = createNetSellEntryObject(audience, options);
if (trustedNetSellReadResponses.has(freshRead) &&
freshRead.audience === entry.operator.audience && freshRead.rows.length === 5) {
trustedPlaylistEntries.set(entry, freshRead);
}
return entry;
}
function createViPlaylistEntry(items, rowVersion, options, freshRead) {
const entry = createViEntryObject(items, rowVersion, options);
if (trustedViReadResponses.has(freshRead) && freshRead.version === entry.operator.rowVersion &&
freshRead.itemCount === entry.itemCount && freshRead.pageCount === entry.pageCount &&
freshRead.items.length === entry.operator.items.length &&
freshRead.items.every((item, index) => item.code === entry.operator.items[index].code &&
item.name === entry.operator.items[index].name)) {
trustedPlaylistEntries.set(entry, freshRead);
}
return entry;
}
function isPlaylistEntryPlayable(value) {
if (!value) return false;
const freshRead = trustedPlaylistEntries.get(value);
const restored = restorePlaylistEntry(value);
if (!freshRead || !restored) return false;
if (restored.operator.kind === "net-sell") {
return trustedNetSellReadResponses.has(freshRead) && freshRead.rows.length === 5 &&
freshRead.audience === restored.operator.audience;
}
return trustedViReadResponses.has(freshRead) && freshRead.version === restored.operator.rowVersion &&
freshRead.itemCount === restored.itemCount && freshRead.pageCount === restored.pageCount &&
freshRead.items.length === restored.operator.items.length &&
freshRead.items.every((item, index) => item.code === restored.operator.items[index].code &&
item.name === restored.operator.items[index].name);
}
function sameSelection(left, right) {
const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"];
return hasExactKeys(left, keys) && hasExactKeys(right, keys) && keys
@@ -334,10 +417,10 @@
}
let recreated;
try {
const options = { id: value.id, fadeDuration: value.fadeDuration };
const options = { id: value.id, fadeDuration: value.fadeDuration, enabled: value.enabled };
recreated = operator.kind === "net-sell"
? createNetSellPlaylistEntry(operator.audience, options)
: createViPlaylistEntry(operator.items, operator.rowVersion, options);
? createNetSellEntryObject(operator.audience, options)
: createViEntryObject(operator.items, operator.rowVersion, options);
} catch {
return null;
}
@@ -355,7 +438,7 @@
operator.pagePreview.pageSize !== recreated.operator.pagePreview.pageSize ||
operator.pagePreview.pageCount !== recreated.operator.pagePreview.pageCount ||
operator.pagePreview.maximumPageCount !== recreated.operator.pagePreview.maximumPageCount)) return null;
return { ...recreated, enabled: value.enabled };
return recreated;
}
return {
@@ -372,11 +455,13 @@
normalizeViItem,
normalizeViItems,
createStatusRequest,
createLegacyImportRequest,
createNetSellReadRequest,
createNetSellSaveRequest,
createViReadRequest,
createViSaveRequest,
normalizeStatusResponse,
normalizeLegacyImportResponse,
normalizeNetSellDataResponse,
normalizeNetSellSaveResponse,
normalizeViDataResponse,
@@ -384,6 +469,7 @@
normalizeError,
createNetSellPlaylistEntry,
createViPlaylistEntry,
isPlaylistEntryPlayable,
restorePlaylistEntry,
refreshPlaylistEntry: restorePlaylistEntry
};

252
Web/market-nav-reorder.js Normal file
View File

@@ -0,0 +1,252 @@
(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnMarketNavReorder = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const fixedMarket = "dashboard";
const originalMarkets = Object.freeze([
"overseas", "exchange", "index", "kospi", "kosdaq",
"compare", "theme", "foreign-stock", "expert", "halted"
]);
const originalMarketSet = new Set(originalMarkets);
const dragMime = "application/x-mbn-market-nav";
const dragPrefix = "mbn-market-nav:v1:";
function isOriginalMarket(value) {
return typeof value === "string" && originalMarketSet.has(value);
}
function normalizeOrder(value) {
if (!Array.isArray(value) || value.length !== originalMarkets.length) return null;
const seen = new Set();
for (const market of value) {
if (!isOriginalMarket(market) || seen.has(market)) return null;
seen.add(market);
}
return Object.freeze([...value]);
}
// MainForm.swapTabPages exchanges the source and hovered TabPage. Keep the
// exact identity-based swap instead of deriving screen meaning from indices.
function swapOrder(order, sourceMarket, targetMarket) {
const normalized = normalizeOrder(order);
if (!normalized || !isOriginalMarket(sourceMarket) ||
!isOriginalMarket(targetMarket)) return null;
if (sourceMarket === targetMarket) {
return Object.freeze({ changed: false, order: normalized });
}
const sourceIndex = normalized.indexOf(sourceMarket);
const targetIndex = normalized.indexOf(targetMarket);
const next = [...normalized];
[next[sourceIndex], next[targetIndex]] = [next[targetIndex], next[sourceIndex]];
return Object.freeze({ changed: true, order: Object.freeze(next) });
}
function moveAdjacent(order, sourceMarket, direction) {
const normalized = normalizeOrder(order);
if (!normalized || !isOriginalMarket(sourceMarket) ||
!Number.isInteger(direction) || ![-1, 1].includes(direction)) return null;
const sourceIndex = normalized.indexOf(sourceMarket);
const targetIndex = sourceIndex + direction;
if (targetIndex < 0 || targetIndex >= normalized.length) {
return Object.freeze({ changed: false, order: normalized });
}
return swapOrder(normalized, sourceMarket, normalized[targetIndex]);
}
function createDragToken(market) {
if (!isOriginalMarket(market)) throw new TypeError("A reorderable market is required.");
return `${dragPrefix}${market}`;
}
function parseDragToken(value) {
if (typeof value !== "string" || !value.startsWith(dragPrefix)) return null;
const market = value.slice(dragPrefix.length);
return isOriginalMarket(market) && value === createDragToken(market) ? market : null;
}
function directMarketButton(nav, target, includeDashboard = false) {
if (!nav || !target || typeof target.closest !== "function") return null;
const button = target.closest("button[data-market]");
if (!button || button.parentElement !== nav) return null;
const market = button.dataset?.market;
if (isOriginalMarket(market) || (includeDashboard && market === fixedMarket)) return button;
return null;
}
function readOrder(nav) {
if (!nav || !nav.children) return null;
const buttons = Array.from(nav.children);
if (buttons.length !== originalMarkets.length + 1) return null;
const dashboard = directMarketButton(nav, buttons[0], true);
if (!dashboard || dashboard.dataset.market !== fixedMarket) return null;
return normalizeOrder(buttons.slice(1).map(button => {
const direct = directMarketButton(nav, button);
return direct?.dataset?.market;
}));
}
function applyOrder(nav, order) {
const normalized = normalizeOrder(order);
const current = readOrder(nav);
if (!normalized || !current) return false;
const byMarket = new Map(Array.from(nav.children).map(button => [
button.dataset.market,
button
]));
const dashboard = byMarket.get(fixedMarket);
if (!dashboard || typeof nav.append !== "function" || typeof nav.prepend !== "function") {
return false;
}
nav.prepend(dashboard);
for (const market of normalized) nav.append(byMarket.get(market));
return true;
}
function createController(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Market-nav reorder options are required.");
}
const nav = options.nav;
if (!nav || typeof nav.addEventListener !== "function" ||
typeof nav.removeEventListener !== "function") {
throw new TypeError("A market nav element is required.");
}
const isBlocked = typeof options.isBlocked === "function" ? options.isBlocked : () => false;
const onReordered = typeof options.onReordered === "function" ? options.onReordered : () => {};
let mounted = false;
let draggingMarket = null;
function blocked() {
try {
return isBlocked() === true;
} catch {
return true;
}
}
function reorder(result, reason, sourceButton) {
if (!result?.changed || !applyOrder(nav, result.order)) return false;
if (typeof sourceButton?.focus === "function") sourceButton.focus();
onReordered(Object.freeze({
reason,
order: result.order,
activeMarket: Array.from(nav.children)
.find(button => button.classList?.contains("active"))?.dataset?.market ?? null
}));
return true;
}
function onDragStart(event) {
const button = directMarketButton(nav, event.target);
const market = button?.dataset?.market;
if (!button || blocked() || !event.dataTransfer) {
event.preventDefault?.();
draggingMarket = null;
return;
}
draggingMarket = market;
button.classList?.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(dragMime, createDragToken(market));
}
function onDragOver(event) {
if (blocked() || !draggingMarket || !directMarketButton(nav, event.target)) return;
event.preventDefault?.();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
}
function clearDragging() {
draggingMarket = null;
for (const button of Array.from(nav.children)) button.classList?.remove("dragging");
}
function onDrop(event) {
const target = directMarketButton(nav, event.target);
if (!target || blocked() || !draggingMarket || !event.dataTransfer) {
clearDragging();
return;
}
event.preventDefault?.();
const sourceMarket = parseDragToken(event.dataTransfer.getData(dragMime));
if (!sourceMarket || sourceMarket !== draggingMarket) {
clearDragging();
return;
}
const order = readOrder(nav);
const source = Array.from(nav.children)
.find(button => button.dataset?.market === sourceMarket);
const result = swapOrder(order, sourceMarket, target.dataset.market);
reorder(result, "drag", source);
clearDragging();
}
function onKeyDown(event) {
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
!["ArrowUp", "ArrowDown"].includes(event.key)) return;
const button = directMarketButton(nav, event.target);
if (!button) return;
event.preventDefault?.();
event.stopPropagation?.();
if (blocked() || event.repeat) return;
const direction = event.key === "ArrowUp" ? -1 : 1;
reorder(
moveAdjacent(readOrder(nav), button.dataset.market, direction),
"keyboard",
button);
}
function mount() {
if (mounted) return true;
if (!readOrder(nav)) return false;
for (const button of Array.from(nav.children)) {
const reorderable = isOriginalMarket(button.dataset?.market);
button.draggable = reorderable;
if (reorderable) {
button.setAttribute?.("aria-keyshortcuts", "Alt+ArrowUp Alt+ArrowDown");
button.setAttribute?.("title", "드래그 또는 Alt+화살표로 업무 탭 순서 변경");
}
}
nav.addEventListener("dragstart", onDragStart);
nav.addEventListener("dragover", onDragOver);
nav.addEventListener("drop", onDrop);
nav.addEventListener("dragend", clearDragging);
nav.addEventListener("keydown", onKeyDown);
mounted = true;
return true;
}
function destroy() {
if (!mounted) return;
nav.removeEventListener("dragstart", onDragStart);
nav.removeEventListener("dragover", onDragOver);
nav.removeEventListener("drop", onDrop);
nav.removeEventListener("dragend", clearDragging);
nav.removeEventListener("keydown", onKeyDown);
clearDragging();
mounted = false;
}
return Object.freeze({ mount, destroy, readOrder: () => readOrder(nav) });
}
return {
fixedMarket,
originalMarkets,
dragMime,
isOriginalMarket,
normalizeOrder,
swapOrder,
moveAdjacent,
createDragToken,
parseDragToken,
readOrder,
applyOrder,
createController
};
});

View File

@@ -0,0 +1,209 @@
(function (root, factory) {
"use strict";
const comparison = typeof module === "object" && module.exports
? require("./comparison-workflow.js")
: root.MbnComparisonWorkflow;
const api = Object.freeze(factory(comparison));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnNamedComparisonRestoreWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (comparison) {
"use strict";
if (!comparison) throw new Error("Comparison workflow is unavailable.");
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const pendingValues = new WeakSet();
const failureCodes = new Set([
"INVALID_ROW",
"MISSING_MARKET_IDENTITY",
"IDENTITY_NOT_FOUND",
"IDENTITY_AMBIGUOUS",
"UNSUPPORTED_ACTION",
"DATABASE_DATA_INVALID",
"RESTORE_FAILED"
]);
const actionIds = new Set(comparison.actions.map(action => action.id));
const returnActionBySubtype = Object.freeze({
"5일": "return-5d",
"1개월": "return-1m",
"3개월": "return-3m",
"6개월": "return-6m",
"12개월": "return-12m"
});
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasExactKeys(value, keys) {
if (!isPlainObject(value)) return false;
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function safeText(value, maximum, allowEmpty = false) {
return typeof value === "string" && value.length <= maximum &&
(allowEmpty || value.length > 0) && value === value.trim() &&
!value.includes("^") && !unsafeTextPattern.test(value);
}
function actionForRaw(raw) {
if (raw.graphicType === "2열판") {
if (raw.subtype === "") return "two-column-current";
if (raw.subtype === "예상체결") return "two-column-expected";
return null;
}
if (raw.graphicType === "종목별 비교분석_캔들 그래프" && raw.subtype === "") {
return "comparison-candle";
}
if (raw.graphicType === "종목별 비교분석_라인 그래프" && raw.subtype === "") {
return "comparison-line";
}
return raw.graphicType === "종목별 수익률 비교"
? returnActionBySubtype[raw.subtype] || null
: null;
}
function classify(raw, options = {}) {
if (!isPlainObject(raw) || typeof raw.enabled !== "boolean" ||
!Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 || raw.itemIndex >= 1000 ||
!safeText(raw.groupCode, 256, true) || !safeText(raw.subject, 4000) ||
!safeText(raw.graphicType, 256) || !safeText(raw.subtype, 256, true) ||
raw.pageText !== "1/1" || raw.dataCode !== "" ||
!safeText(raw.pageText, 9) || !safeText(raw.dataCode, 1024, true) ||
!requestIdPattern.test(options.id) || !Number.isInteger(options.fadeDuration) ||
options.fadeDuration < 0 || options.fadeDuration > 60) return null;
const names = raw.subject.split(",");
const actionId = actionForRaw(raw);
if (!actionId || names.length !== 2 || names.some(name => !safeText(name, 200))) return null;
const pending = Object.freeze({
itemIndex: raw.itemIndex,
enabled: raw.enabled,
actionId,
entry: Object.freeze({ id: options.id }),
fadeDuration: options.fadeDuration,
rawSelection: Object.freeze({
groupCode: raw.groupCode,
subject: raw.subject,
graphicType: raw.graphicType,
subtype: raw.subtype,
dataCode: raw.dataCode
})
});
pendingValues.add(pending);
return pending;
}
function normalizeWireTarget(value) {
if (!isPlainObject(value)) return null;
let candidate;
switch (value.kind) {
case "market-target":
if (!hasExactKeys(value, ["kind", "target"])) return null;
candidate = value;
break;
case "krx-stock":
case "nxt-stock":
if (!hasExactKeys(value, ["kind", "market", "stockName", "stockCode"])) return null;
candidate = value;
break;
case "world-stock":
if (!hasExactKeys(value, ["kind", "inputName", "symbol", "nation"])) return null;
candidate = { ...value, displayName: value.inputName };
break;
default:
return null;
}
return comparison.normalizeTarget(candidate);
}
function normalizeOutcome(value, pending) {
if (!pendingValues.has(pending) || !isPlainObject(value) ||
value.itemIndex !== pending.itemIndex) return null;
if (value.status === "failed") {
if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) ||
!failureCodes.has(value.failure)) return null;
return Object.freeze({
itemIndex: value.itemIndex,
status: value.status,
failure: value.failure
});
}
if (value.status !== "resolved" ||
!hasExactKeys(value, ["itemIndex", "status", "actionId", "first", "second"]) ||
value.actionId !== pending.actionId || !actionIds.has(value.actionId)) return null;
const first = normalizeWireTarget(value.first);
const second = normalizeWireTarget(value.second);
if (!first || !second || !comparison.isActionAllowed(value.actionId, [first, second])) return null;
return Object.freeze({
itemIndex: value.itemIndex,
status: value.status,
actionId: value.actionId,
first,
second
});
}
function normalizeResponse(value, expectedRequestId, pendingRows) {
if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) ||
pendingRows.length < 1 || pendingRows.length > 1000 ||
pendingRows.some(pending => !pendingValues.has(pending)) ||
!hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) ||
value.requestId !== expectedRequestId ||
typeof value.retrievedAt !== "string" ||
!isoTimestampPattern.test(value.retrievedAt) ||
!Number.isFinite(Date.parse(value.retrievedAt)) ||
value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) ||
value.rows.length !== pendingRows.length) return null;
const rows = [];
for (let index = 0; index < pendingRows.length; index += 1) {
const outcome = normalizeOutcome(value.rows[index], pendingRows[index]);
if (!outcome) return null;
rows.push(outcome);
}
return Object.freeze({
requestId: value.requestId,
retrievedAt: value.retrievedAt,
rows: Object.freeze(rows)
});
}
function normalizeError(value, expectedRequestId) {
return requestIdPattern.test(expectedRequestId) &&
hasExactKeys(value, ["requestId", "code", "message", "retryable"]) &&
value.requestId === expectedRequestId && safeText(value.code, 64) &&
safeText(value.message, 1024) && value.retryable === false
? Object.freeze({ ...value })
: null;
}
function materialize(pending, outcome) {
if (!pendingValues.has(pending) || !outcome || outcome.status !== "resolved" ||
outcome.itemIndex !== pending.itemIndex || outcome.actionId !== pending.actionId) return null;
try {
const entry = comparison.createComparisonPlaylistEntry(
outcome.actionId,
[outcome.first, outcome.second],
{ id: pending.entry.id, fadeDuration: pending.fadeDuration });
return comparison.restoreComparisonPlaylistEntry({ ...entry, enabled: pending.enabled });
} catch {
return null;
}
}
function isPending(value) {
return pendingValues.has(value);
}
return {
classify,
normalizeResponse,
normalizeError,
materialize,
isPending
};
});

View File

@@ -19,20 +19,22 @@
const versionPattern = /^[a-f0-9]{64}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const pendingValues = new WeakSet();
const storedPendingValues = new WeakSet();
const legacyAudienceByGroup = Object.freeze({
"개인 순매도 상위(수동)": "INDIVIDUAL",
"외국인 순매도 상위(수동)": "FOREIGN",
"기관 순매도 상위(수동)": "INSTITUTION"
});
const marketByGroup = Object.freeze({
KOSPI: "kospi", KOSDAQ: "kosdaq", NXT_KOSPI: "nxt-kospi", NXT_KOSDAQ: "nxt-kosdaq",
const canonicalMarketByGroup = Object.freeze({
KOSPI: "kospi", KOSDAQ: "kosdaq", NXT_KOSPI: "nxt-kospi", NXT_KOSDAQ: "nxt-kosdaq"
});
const historicalMarketByGroup = Object.freeze({
"코스피": "kospi", "코스닥": "kosdaq", "코스피_NXT": "nxt-kospi", "코스닥_NXT": "nxt-kosdaq"
});
const financialByGraphic = Object.freeze(Object.fromEntries(
manualFinancial.sourceContract.screens.flatMap(screen => [
[screen.graphicType, screen],
[screen.label, screen]
])));
const canonicalFinancialByGraphic = Object.freeze(Object.fromEntries(
manualFinancial.sourceContract.screens.map(screen => [screen.graphicType, screen])));
const historicalFinancialByGraphic = Object.freeze(Object.fromEntries(
manualFinancial.sourceContract.screens.map(screen => [screen.label, screen])));
function safeText(value, maximum, allowEmpty = false) {
return typeof value === "string" && value.length <= maximum &&
@@ -45,13 +47,14 @@
typeof raw.enabled !== "boolean" || !Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 ||
!safeText(raw.groupCode, 256, true) || !safeText(raw.subject, 4096, true) ||
!safeText(raw.graphicType, 256, true) || !safeText(raw.subtype, 256, true) ||
!safeText(raw.dataCode, 1024, true)) return null;
!safeText(raw.dataCode, 1024, true) || !safeText(raw.pageText, 9)) return null;
return Object.freeze({
groupCode: raw.groupCode,
subject: raw.subject,
graphicType: raw.graphicType,
subtype: raw.subtype,
dataCode: raw.dataCode
dataCode: raw.dataCode,
pageText: raw.pageText
});
}
@@ -66,8 +69,7 @@
if (!safeText(subject, 4096) || subject.startsWith("@MBN_VI_SNAPSHOT_")) return null;
const names = subject.split(",");
if (names.length < 1 || names.length > manualLists.maximumViItems ||
names.some(name => !safeText(name, 200) || name !== name.trim()) ||
new Set(names).size !== names.length) return null;
names.some(name => !safeText(name, 200) || name !== name.trim())) return null;
return Object.freeze(names);
}
@@ -80,8 +82,10 @@
const canonicalAudience = manualLists.normalizeAudience(selected.groupCode);
const legacyAudience = legacyAudienceByGroup[selected.groupCode] || null;
if ((canonicalAudience && selected.subject === "INDEX" &&
selected.graphicType === "MANUAL_NET_SELL" && selected.subtype === "MANUAL_NET_SELL") ||
(legacyAudience && selected.subject === "" &&
selected.graphicType === "MANUAL_NET_SELL" && selected.subtype === "MANUAL_NET_SELL" &&
selected.pageText === "1/1") ||
(legacyAudience && selected.subject === "지수" &&
selected.pageText === "1/1" &&
selected.graphicType === "순매도 상위" && selected.subtype === "순매도 상위")) {
if (selected.dataCode !== "") return null;
descriptor = {
@@ -90,22 +94,27 @@
historical: Boolean(legacyAudience)
};
} else if (selected.groupCode === "PAGED_VI" && selected.graphicType === "INPUT_ORDER" &&
selected.subtype === "CURRENT") {
selected.subtype === "CURRENT" && /^1\/(?:[1-9]|1[0-9]|20)$/.test(selected.pageText)) {
if (selected.subject === manualLists.viSnapshotSubject && versionPattern.test(selected.dataCode)) {
descriptor = { kind: "vi", expectedVersion: selected.dataCode, historicalNames: null };
} else if (selected.dataCode === "") {
const names = exactHistoricalViNames(selected.subject);
if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
}
} else if (selected.groupCode === "" && selected.graphicType === "5단 표그래프" &&
selected.subtype === "VI 발동(수동)" && selected.dataCode === "") {
const names = exactHistoricalViNames(selected.subject);
if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
if (names && selected.pageText === `1/${Math.ceil(names.length / manualLists.viPageSize)}`) {
descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
}
} else {
const profile = financialByGraphic[selected.graphicType];
const market = marketByGroup[selected.groupCode];
const canonicalProfile = canonicalFinancialByGraphic[selected.graphicType];
const historicalProfile = historicalFinancialByGraphic[selected.graphicType];
const canonicalMarket = canonicalMarketByGroup[selected.groupCode];
const historicalMarket = historicalMarketByGroup[selected.groupCode];
const profile = canonicalProfile && canonicalMarket
? canonicalProfile
: (historicalProfile && historicalMarket ? historicalProfile : null);
const market = canonicalProfile && canonicalMarket ? canonicalMarket : historicalMarket;
if (profile && market && safeText(selected.subject, 200) &&
selected.subtype === "" && selected.dataCode === "") {
selected.subtype === "" && selected.dataCode === "" && selected.pageText === "1/1") {
descriptor = {
kind: "financial",
screen: profile.screen,
@@ -125,7 +134,14 @@
enabled: raw.enabled,
entry: Object.freeze({ id: normalizedOptions.id }),
fadeDuration: normalizedOptions.fadeDuration,
rawSelection: selected
rawSelection: Object.freeze({
groupCode: selected.groupCode,
subject: selected.subject,
graphicType: selected.graphicType,
subtype: selected.subtype,
dataCode: selected.dataCode
}),
pageText: selected.pageText
});
pendingValues.add(pending);
return pending;
@@ -151,11 +167,13 @@
if (!response) return null;
entry = manualLists.createNetSellPlaylistEntry(pending.audience, {
id: pending.entry.id,
fadeDuration: pending.fadeDuration
});
fadeDuration: pending.fadeDuration,
enabled: pending.enabled
}, response);
} else if (pending.kind === "vi") {
const response = manualLists.normalizeViDataResponse(payload, requestEnvelope.payload.requestId);
if (!response || response.itemCount < 1 ||
pending.pageText !== `1/${response.pageCount}` ||
(pending.expectedVersion !== null && response.version !== pending.expectedVersion)) return null;
if (pending.historicalNames !== null) {
const names = response.items.map(item => item.name);
@@ -164,10 +182,11 @@
}
entry = manualLists.createViPlaylistEntry(response.items, response.version, {
id: pending.entry.id,
fadeDuration: pending.fadeDuration
});
fadeDuration: pending.fadeDuration,
enabled: pending.enabled
}, response);
}
return manualLists.restorePlaylistEntry({ ...entry, enabled: pending.enabled });
return manualLists.restorePlaylistEntry(entry) ? entry : null;
}
function isPending(value, kind) {
@@ -194,11 +213,110 @@
return false;
}
function isTrustedEntryForSave(entry, itemIndex = 0) {
if (!entry || typeof entry !== "object" || Array.isArray(entry) ||
!Number.isInteger(itemIndex) || itemIndex < 0 || typeof entry.enabled !== "boolean") return false;
const source = entry.operator?.source;
const fresh = source === "manual-financial-workflow"
? manualFinancial.isPlaylistEntryPlayable(entry)
: source === "legacy-manual-lists-workflow" && manualLists.isPlaylistEntryPlayable(entry);
if (!fresh) return false;
const pageText = entry.operator?.kind === "vi"
? (Number.isInteger(entry.pageCount) ? `1/${entry.pageCount}` : "")
: "1/1";
const pending = classify({
itemIndex,
enabled: entry.enabled,
groupCode: entry.selection?.groupCode,
subject: entry.selection?.subject,
graphicType: entry.selection?.graphicType,
subtype: entry.selection?.subtype,
dataCode: entry.selection?.dataCode,
pageText
}, { id: entry.id, fadeDuration: entry.fadeDuration });
return !!pending && matchesMaterializedEntry(pending, entry);
}
function createStoredManualListPending(entry, itemIndex = 0) {
const restored = manualLists.restorePlaylistEntry(entry);
if (!restored || !Number.isInteger(itemIndex) || itemIndex < 0) return null;
const pageText = restored.operator.kind === "vi" ? `1/${restored.pageCount}` : "1/1";
const pending = classify({
itemIndex,
enabled: restored.enabled,
groupCode: restored.selection.groupCode,
subject: restored.selection.subject,
graphicType: restored.selection.graphicType,
subtype: restored.selection.subtype,
dataCode: restored.selection.dataCode,
pageText
}, { id: restored.id, fadeDuration: restored.fadeDuration });
if (!pending || !["net-sell", "vi"].includes(pending.kind) ||
!matchesMaterializedEntry(pending, restored)) return null;
storedPendingValues.add(pending);
return pending;
}
function isStoredManualListPending(value) {
return storedPendingValues.has(value);
}
function legacyFieldsForEntry(entry, itemIndex = 0) {
if (!isTrustedEntryForSave(entry, itemIndex)) return null;
const enabled = entry.enabled ? "1" : "0";
let fields = null;
if (entry.operator.source === "legacy-manual-lists-workflow" &&
entry.operator.kind === "net-sell") {
const groupCode = `${manualLists.audienceLabels[entry.operator.audience]} 순매도 상위(수동)`;
fields = [enabled, groupCode, "지수", "순매도 상위", "순매도 상위", "1/1", ""];
} else if (entry.operator.source === "legacy-manual-lists-workflow" &&
entry.operator.kind === "vi") {
fields = [
enabled,
"",
entry.operator.items.map(item => item.name).join(","),
"5단 표그래프",
"VI 발동(수동)",
`1/${entry.pageCount}`,
""
];
} else if (entry.operator.source === "manual-financial-workflow") {
const market = {
kospi: "코스피",
kosdaq: "코스닥",
"nxt-kospi": "코스피_NXT",
"nxt-kosdaq": "코스닥_NXT"
}[entry.market];
const profile = manualFinancial.getScreenDefinition(entry.operator.screen);
if (market && profile) {
fields = [enabled, market, entry.stockName, profile.label, "", "1/1", ""];
}
}
if (!fields) return null;
const pending = classify({
itemIndex,
enabled: fields[0] === "1",
groupCode: fields[1],
subject: fields[2],
graphicType: fields[3],
subtype: fields[4],
pageText: fields[5],
dataCode: fields[6]
}, { id: entry.id, fadeDuration: entry.fadeDuration });
return pending && matchesMaterializedEntry(pending, entry)
? Object.freeze(fields)
: null;
}
return {
classify,
createManualListReadRequest,
materializeManualList,
isPending,
matchesMaterializedEntry
matchesMaterializedEntry,
isTrustedEntryForSave,
createStoredManualListPending,
isStoredManualListPending,
legacyFieldsForEntry
};
});

View File

@@ -0,0 +1,263 @@
(function (root, factory) {
"use strict";
const themes = typeof module === "object" && module.exports
? require("./theme-workflow.js")
: root.MbnThemeWorkflow;
const api = Object.freeze(factory(themes));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnNamedNxtThemeRestoreWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (themes) {
"use strict";
if (!themes) throw new Error("Theme workflow is unavailable.");
const SCHEMA_VERSION = 1;
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
const themeCodePattern = /^[0-9]{3,8}$/;
const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:Z|[+-]\d{2}:\d{2})$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const pendingValues = new WeakSet();
const materializedValues = new WeakSet();
const failureCodes = new Set([
"INVALID_ROW",
"UNSUPPORTED_ACTION",
"IDENTITY_NOT_FOUND",
"IDENTITY_AMBIGUOUS",
"PREVIEW_EMPTY",
"LIVE_ITEMS_EMPTY",
"DATABASE_DATA_INVALID",
"RESTORE_FAILED"
]);
const profiles = Object.freeze({
"five-row-current": Object.freeze({ builderKey: "s5074", cutCode: "5074", pageSize: 5 }),
"six-row-current": Object.freeze({ builderKey: "s5077", cutCode: "5077", pageSize: 6 }),
"twelve-row-current": Object.freeze({ builderKey: "s5088", cutCode: "5088", pageSize: 12 })
});
const bridgeContract = Object.freeze({
resultType: "named-nxt-theme-restore-results",
errorType: "named-nxt-theme-restore-error"
});
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function hasExactKeys(value, keys) {
if (!isPlainObject(value)) return false;
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function safeText(value, maximumLength, allowEmpty = false) {
return typeof value === "string" && value.length <= maximumLength &&
(allowEmpty || value.length > 0) && value === value.trim() &&
!value.includes("^") && !unsafeTextPattern.test(value);
}
function safeRaw(raw) {
return isPlainObject(raw) && typeof raw.enabled === "boolean" &&
Number.isInteger(raw.itemIndex) && raw.itemIndex >= 0 && raw.itemIndex < 1000 &&
safeText(raw.groupCode, 256) && safeText(raw.subject, 256, true) &&
safeText(raw.graphicType, 256, true) && safeText(raw.subtype, 256, true) &&
safeText(raw.pageText, 9, true) && safeText(raw.dataCode, 256, true);
}
// Route every exact group row. Malformed or unsupported grammar is still a
// correlated pending row so the native verifier can return a closed reason.
function classify(raw, options = {}) {
if (!safeRaw(raw) || raw.groupCode !== "테마_NXT" ||
!requestIdPattern.test(options.id) || !Number.isInteger(options.fadeDuration) ||
options.fadeDuration < 0 || options.fadeDuration > 60) return null;
const pending = Object.freeze({
itemIndex: raw.itemIndex,
enabled: raw.enabled,
fadeDuration: options.fadeDuration,
entry: Object.freeze({ id: options.id }),
rawSelection: Object.freeze({
groupCode: raw.groupCode,
subject: raw.subject,
graphicType: raw.graphicType,
subtype: raw.subtype,
dataCode: raw.dataCode
})
});
pendingValues.add(pending);
return pending;
}
function normalizeOutcome(value, pending) {
if (!pendingValues.has(pending) || !isPlainObject(value) ||
value.itemIndex !== pending.itemIndex) return null;
if (value.status === "failed") {
if (!hasExactKeys(value, ["itemIndex", "status", "failure"]) ||
!failureCodes.has(value.failure)) return null;
return Object.freeze({ ...value });
}
const keys = [
"itemIndex", "status", "actionId", "builderKey", "cutCode", "pageSize", "sort",
"session", "themeTitle", "storedThemeCode", "currentThemeCode", "previewItemCount",
"liveItemCount", "previewIsTruncated", "codeWasRemapped"
];
if (value.status !== "resolved" || !hasExactKeys(value, keys)) return null;
const profile = profiles[value.actionId];
if (!profile || value.builderKey !== profile.builderKey ||
value.cutCode !== profile.cutCode || value.pageSize !== profile.pageSize ||
!["INPUT_ORDER", "GAIN_DESC", "GAIN_ASC"].includes(value.sort) ||
!["PRE_MARKET", "AFTER_MARKET"].includes(value.session) ||
!safeText(value.themeTitle, 128) || value.themeTitle.includes("(NXT)") ||
pending.rawSelection.subject !== `${value.themeTitle}(NXT)` ||
!themeCodePattern.test(value.storedThemeCode) ||
value.storedThemeCode !== pending.rawSelection.dataCode ||
!themeCodePattern.test(value.currentThemeCode) ||
value.codeWasRemapped !== (value.storedThemeCode !== value.currentThemeCode) ||
!Number.isInteger(value.previewItemCount) || value.previewItemCount < 1 ||
value.previewItemCount > 1000000 ||
!Number.isInteger(value.liveItemCount) || value.liveItemCount < 1 ||
value.liveItemCount > value.previewItemCount ||
typeof value.previewIsTruncated !== "boolean" ||
value.previewIsTruncated !== (value.previewItemCount > 240)) return null;
return Object.freeze({ ...value });
}
function normalizeResponse(value, expectedRequestId, pendingRows) {
if (!requestIdPattern.test(expectedRequestId) || !Array.isArray(pendingRows) ||
pendingRows.length < 1 || pendingRows.length > 1000 ||
pendingRows.some(pending => !pendingValues.has(pending)) ||
!hasExactKeys(value, ["requestId", "retrievedAt", "totalRowCount", "rows"]) ||
value.requestId !== expectedRequestId || typeof value.retrievedAt !== "string" ||
!isoTimestampPattern.test(value.retrievedAt) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
value.totalRowCount !== pendingRows.length || !Array.isArray(value.rows) ||
value.rows.length !== pendingRows.length) return null;
const rows = [];
for (let index = 0; index < pendingRows.length; index += 1) {
const outcome = normalizeOutcome(value.rows[index], pendingRows[index]);
if (!outcome) return null;
rows.push(outcome);
}
return Object.freeze({
requestId: value.requestId,
retrievedAt: value.retrievedAt,
rows: Object.freeze(rows)
});
}
function normalizeError(value, expectedRequestId) {
return requestIdPattern.test(expectedRequestId) &&
hasExactKeys(value, ["requestId", "code", "message", "retryable"]) &&
value.requestId === expectedRequestId && safeText(value.code, 64) &&
safeText(value.message, 1024) && value.retryable === false
? Object.freeze({ ...value })
: null;
}
function materialize(pending, outcome) {
if (!pendingValues.has(pending) || outcome?.status !== "resolved" ||
outcome.itemIndex !== pending.itemIndex) return null;
try {
const created = themes.createThemePlaylistEntry(
outcome.actionId,
{
market: "nxt",
themeCode: outcome.currentThemeCode,
title: outcome.themeTitle,
session: outcome.session,
itemCount: outcome.liveItemCount
},
outcome.sort,
{ id: pending.entry.id, fadeDuration: pending.fadeDuration });
const entry = themes.restoreThemePlaylistEntry({
...created,
enabled: pending.enabled,
operator: Object.freeze({
...created.operator,
namedNxtRestore: Object.freeze({
schemaVersion: SCHEMA_VERSION,
rawSelection: pending.rawSelection,
currentThemeCode: outcome.currentThemeCode,
previewItemCount: outcome.previewItemCount,
liveItemCount: outcome.liveItemCount,
codeWasRemapped: outcome.codeWasRemapped
})
})
});
if (!entry) return null;
// restoreThemePlaylistEntry recreates its operator, so retain the native
// proof only after the closed theme entry itself has round-tripped.
const materialized = Object.freeze({
...entry,
operator: Object.freeze({
...entry.operator,
namedNxtRestore: Object.freeze({
schemaVersion: SCHEMA_VERSION,
rawSelection: pending.rawSelection,
currentThemeCode: outcome.currentThemeCode,
previewItemCount: outcome.previewItemCount,
liveItemCount: outcome.liveItemCount,
codeWasRemapped: outcome.codeWasRemapped
})
})
});
materializedValues.add(materialized);
return materialized;
} catch {
return null;
}
}
function refreshDynamicSession(entry, now = new Date()) {
if (!materializedValues.has(entry) || !(now instanceof Date) ||
!Number.isFinite(now.getTime())) return null;
const proof = entry.operator.namedNxtRestore;
const session = now.getHours() <= 12 ? "PRE_MARKET" : "AFTER_MARKET";
const recreated = themes.createThemePlaylistEntry(
entry.operator.actionId,
{ ...entry.operator.theme, session },
entry.operator.sort,
{ id: entry.id, fadeDuration: entry.fadeDuration });
const refreshed = Object.freeze({
...recreated,
enabled: entry.enabled,
pageText: entry.pageText,
operator: Object.freeze({ ...recreated.operator, namedNxtRestore: proof })
});
materializedValues.add(refreshed);
return refreshed;
}
function matchesRaw(entry, raw) {
if (!materializedValues.has(entry) || !safeRaw(raw)) return false;
const saved = entry.operator?.namedNxtRestore?.rawSelection;
return saved && raw.enabled === entry.enabled &&
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
.every(key => saved[key] === raw[key]);
}
function legacySelectionForEntry(entry) {
if (!materializedValues.has(entry)) return null;
return entry.operator.namedNxtRestore.rawSelection;
}
function isPending(value) {
return pendingValues.has(value);
}
function isMaterializedEntry(value) {
return materializedValues.has(value);
}
return {
bridgeContract,
classify,
normalizeResponse,
normalizeError,
materialize,
refreshDynamicSession,
matchesRaw,
legacySelectionForEntry,
isPending,
isMaterializedEntry
};
});

View File

@@ -13,6 +13,9 @@
const MAXIMUM_PAGE_COUNT = 20;
const MAXIMUM_STORED_PAGE_COUNT = 9999;
const DEFAULT_MAXIMUM_DEFINITIONS = 200;
const MAXIMUM_SELECTION_FIELD_LENGTH = 256;
const MAXIMUM_SUBJECT_FIELD_LENGTH = 4000;
const MAXIMUM_LIST_TEXT_LENGTH = 4000;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const programCodePattern = /^[0-9]{8}$/;
const cutCodePattern = /^[0-9]{1,8}$/;
@@ -145,11 +148,11 @@
if (!hasExactKeys(value, ["groupCode", "subject", "graphicType", "subtype", "dataCode"])) {
return null;
}
const groupCode = exactText(value.groupCode, 256, true);
const subject = exactText(value.subject, 256, true);
const graphicType = exactText(value.graphicType, 256, true);
const subtype = exactText(value.subtype, 256, true);
const dataCode = exactText(value.dataCode, 256, true);
const groupCode = exactText(value.groupCode, MAXIMUM_SELECTION_FIELD_LENGTH, true);
const subject = exactText(value.subject, MAXIMUM_SUBJECT_FIELD_LENGTH, true);
const graphicType = exactText(value.graphicType, MAXIMUM_SELECTION_FIELD_LENGTH, true);
const subtype = exactText(value.subtype, MAXIMUM_SELECTION_FIELD_LENGTH, true);
const dataCode = exactText(value.dataCode, MAXIMUM_SELECTION_FIELD_LENGTH, true);
if ([groupCode, subject, graphicType, subtype, dataCode].some(field => field === null)) return null;
return Object.freeze({ groupCode, subject, graphicType, subtype, dataCode });
}
@@ -184,13 +187,18 @@
fieldsValue.some(field => typeof field !== "string" || field.includes("^") ||
controlCharacterPattern.test(field)) ||
!["0", "1"].includes(fieldsValue[0]) ||
fieldsValue.slice(1, 5).some(field => exactText(field, 256, true) === null) ||
exactText(fieldsValue[6], 256, true) === null ||
exactText(fieldsValue[1], MAXIMUM_SELECTION_FIELD_LENGTH, true) === null ||
exactText(fieldsValue[2], MAXIMUM_SUBJECT_FIELD_LENGTH, true) === null ||
exactText(fieldsValue[3], MAXIMUM_SELECTION_FIELD_LENGTH, true) === null ||
exactText(fieldsValue[4], MAXIMUM_SELECTION_FIELD_LENGTH, true) === null ||
exactText(fieldsValue[6], MAXIMUM_SELECTION_FIELD_LENGTH, true) === null ||
(fieldsValue[5] !== "" && !parseStoredPageText(fieldsValue[5]))) {
throw new TypeError("Exactly seven safe legacy playlist fields are required.");
}
const value = fieldsValue.join("^");
if (value.length > 4000) throw new RangeError("The legacy LIST_TEXT value is too long.");
if (value.length > MAXIMUM_LIST_TEXT_LENGTH) {
throw new RangeError("The legacy LIST_TEXT value is too long.");
}
return value;
}
@@ -209,6 +217,9 @@
if (options.pageTextForEntry !== undefined && typeof options.pageTextForEntry !== "function") {
throw new TypeError("pageTextForEntry must be a function when supplied.");
}
if (options.fieldsForEntry !== undefined && typeof options.fieldsForEntry !== "function") {
throw new TypeError("fieldsForEntry must be a function when supplied.");
}
const items = playlistEntries.map((entry, itemIndex) => {
if (options.isTrustedEntry(entry, itemIndex) !== true) {
@@ -217,7 +228,16 @@
const pageText = options.pageTextForEntry
? options.pageTextForEntry(entry, itemIndex)
: "1/1";
const fields = toLegacySevenFields(entry, pageText);
const projected = options.fieldsForEntry
? options.fieldsForEntry(entry, itemIndex, pageText)
: undefined;
const fields = options.fieldsForEntry
? projected
: toLegacySevenFields(entry, pageText);
if (!Array.isArray(fields) || fields.length !== 7 ||
fields[0] !== (entry.enabled ? "1" : "0") || fields[5] !== pageText) {
throw new TypeError(`Playlist entry ${itemIndex} has no exact legacy seven-field projection.`);
}
legacyListText(fields);
return Object.freeze({
itemIndex,
@@ -367,18 +387,29 @@
return Object.freeze(copy);
}
function trustedEntryMatchesRaw(entry, rawRow) {
function compatibleSelection(entry, rawRow, compatibilityVerifier) {
if (sameSelection(entry, rawRow)) return true;
if (typeof compatibilityVerifier !== "function") return false;
try {
return compatibilityVerifier(entry, rawRow) === true;
} catch {
return false;
}
}
function trustedEntryMatchesRaw(entry, rawRow, compatibilityVerifier) {
return isPlainObject(entry) && requestId(entry.id) &&
typeof entry.builderKey === "string" && builderKeyPattern.test(entry.builderKey) &&
typeof entry.code === "string" && cutCodePattern.test(entry.code) &&
entry.enabled === rawRow.enabled && sameSelection(entry, rawRow);
entry.enabled === rawRow.enabled && compatibleSelection(entry, rawRow, compatibilityVerifier);
}
function restoreRawRows(loadValue, trustedResolver) {
function restoreRawRows(loadValue, trustedResolver, compatibilityVerifier) {
const load = loadValue?.kind === "named-playlist-load"
? loadValue
: normalizeLoadResponse(loadValue);
if (!load || typeof trustedResolver !== "function") {
if (!load || typeof trustedResolver !== "function" ||
(compatibilityVerifier !== undefined && typeof compatibilityVerifier !== "function")) {
throw new TypeError("A normalized load response and trusted resolver are required.");
}
const rows = load.rawRows.map(rawRow => {
@@ -388,7 +419,7 @@
} catch {
candidate = null;
}
const trusted = trustedEntryMatchesRaw(candidate, rawRow);
const trusted = trustedEntryMatchesRaw(candidate, rawRow, compatibilityVerifier);
return Object.freeze({
rawRow,
entry: trusted ? freezeTrustedEntry(candidate) : null,

View File

@@ -30,7 +30,8 @@
.mbn-operator-catalog-tabs,
.mbn-operator-catalog-toolbar,
.mbn-operator-catalog-actions,
.mbn-operator-catalog-item-form { display: flex; flex-wrap: wrap; gap: 6px; }
.mbn-operator-catalog-item-form,
.mbn-operator-catalog-stock-search { display: flex; flex-wrap: wrap; gap: 6px; }
.mbn-operator-catalog-tabs button[aria-pressed="true"] { border-color: #32d5a4; color: #68e2bd; }
.mbn-operator-catalog-toolbar [data-role="query"] { flex: 1 1 240px; }
.mbn-operator-catalog-body { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(360px, 1.2fr); gap: 10px; min-height: 360px; }
@@ -41,8 +42,12 @@
.mbn-operator-catalog-editor { display: grid; align-content: start; gap: 10px; }
.mbn-operator-catalog-identity { display: grid; grid-template-columns: 96px 1fr; gap: 8px; align-items: center; }
.mbn-operator-catalog-identity output { color: #8bdcc4; font: 12px Consolas, monospace; }
.mbn-operator-catalog-item-form [data-role="stock-code"] { width: 120px; }
.mbn-operator-catalog-item-form [data-role="stock-name"] { flex: 1 1 180px; }
.mbn-operator-catalog-stock-search [data-role="stock-query"] { flex: 1 1 260px; }
.mbn-operator-catalog-stock-status { color: #8aa0b5; font: 11px Consolas, monospace; }
.mbn-operator-catalog-stock-results { max-height: 150px; margin: 0; padding: 5px; overflow: auto; border: 1px solid #1c334a; border-radius: 8px; list-style: none; }
.mbn-operator-catalog-stock-row button { width: 100%; margin-bottom: 4px; text-align: left; }
.mbn-operator-catalog-stock-row button.selected { border-color: #32d5a4; background: rgba(50, 213, 164, .12); }
.mbn-operator-catalog-selected-stock { flex: 1 1 260px; align-self: center; color: #8bdcc4; font: 11px Consolas, monospace; }
.mbn-operator-catalog-item-form [data-role="buy-amount"] { width: 130px; }
.mbn-operator-catalog-draft-row { display: grid; grid-template-columns: 1fr auto auto auto; gap: 5px; align-items: center; padding: 5px; border-bottom: 1px solid #172b3e; }
.mbn-operator-catalog-draft-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

View File

@@ -15,19 +15,22 @@
const globalScope = typeof globalThis === "object" ? globalThis : null;
const fixedRequestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
const themeStockCodePattern = /^[A-Za-z0-9]{1,12}$/;
const expertStockCodePattern = /^[A-Za-z0-9]{1,32}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const themePrefixes = Object.freeze({
krx: Object.freeze(["P", "D"]),
nxt: Object.freeze(["X", "T"])
const themeStockMarkets = Object.freeze({
krx: Object.freeze(["kospi", "kosdaq"]),
nxt: Object.freeze(["nxt-kospi", "nxt-kosdaq"])
});
const themePrefixByStockMarket = Object.freeze({
kospi: "P",
kosdaq: "D",
"nxt-kospi": "X",
"nxt-kosdaq": "T"
});
function exactOptions(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const required = ["postNative", "isLocked", "showToast", "addLog", "createId"];
return required.every(key => typeof value[key] === "function") &&
(value.requestStockSearch === undefined || typeof value.requestStockSearch === "function");
return required.every(key => typeof value[key] === "function");
}
function safeText(value, maximumLength) {
@@ -35,6 +38,17 @@
value === value.trim() && !unsafeTextPattern.test(value);
}
function themeStoredStockName(identity) {
if (!identity || typeof identity.name !== "string") return null;
if (identity.market !== "nxt-kospi" && identity.market !== "nxt-kosdaq") {
return safeText(identity.name, 200) ? identity.name : null;
}
const suffix = "(NXT)";
if (!identity.name.endsWith(suffix)) return null;
const storedName = identity.name.slice(0, -suffix.length);
return safeText(storedName, 200) ? storedName : null;
}
function clone(value) {
if (value === null || value === undefined) return value;
return JSON.parse(JSON.stringify(value));
@@ -65,7 +79,15 @@
themeResults: [],
expertResults: [],
selectedCatalogIndex: -1,
selectedDraftIndex: -1,
edit: null,
stockSearch: {
query: "",
status: "idle",
results: [],
selectedIndex: -1,
error: ""
},
status: "대기",
error: ""
};
@@ -195,11 +217,23 @@
: workflow.createExpertEditDraft(context.selection, context.preview);
}
function resetStockSearch() {
state.stockSearch = {
query: "",
status: "idle",
results: [],
selectedIndex: -1,
error: ""
};
}
function openEntity(entity, context) {
state.open = true;
state.entity = entity;
state.selectedCatalogIndex = -1;
state.selectedDraftIndex = -1;
state.error = "";
resetStockSearch();
if (context !== undefined && context !== null) {
state.edit = normalizeEditContext(context, entity);
state.mode = "edit";
@@ -251,6 +285,68 @@
return state.edit?.draft || null;
}
function selectedStockIdentity() {
return state.stockSearch.results[state.stockSearch.selectedIndex] || null;
}
function stockIsCompatibleWithDraft(identity, draft = currentDraft()) {
if (!identity || !draft) return false;
if (state.entity === "expert") return true;
return themeStockMarkets[draft.market]?.includes(identity.market) === true;
}
function updateStockQuery(value) {
if (typeof value !== "string") return false;
if (value !== state.stockSearch.query) {
state.stockSearch.query = value;
state.stockSearch.status = "idle";
state.stockSearch.results = [];
state.stockSearch.selectedIndex = -1;
state.stockSearch.error = "";
render();
}
return true;
}
function searchStocks(query) {
if (!currentDraft() || (state.mode !== "new" && state.mode !== "edit")) {
return notifyError("먼저 ThemeA/EList 새 정의 또는 편집을 여세요.");
}
try {
const request = workflow.createStockMasterSearchRequest(
nextRequestId("stock-master"),
query,
workflow.limits.maximumResults);
state.stockSearch.query = request.query;
state.stockSearch.status = "loading";
state.stockSearch.results = [];
state.stockSearch.selectedIndex = -1;
state.stockSearch.error = "";
state.error = "";
enqueueRead(
"stock-master-search",
workflow.stockMasterContract.request,
request,
null);
return true;
} catch (error) {
return notifyError(error?.message || "종목 master 검색어가 올바르지 않습니다.");
}
}
function selectStockResult(index) {
const identity = state.stockSearch.results[index];
if (!Number.isInteger(index) || !identity) return false;
if (!stockIsCompatibleWithDraft(identity)) {
return notifyError("현재 ThemeA 시장과 일치하는 종목을 선택하세요.");
}
state.stockSearch.selectedIndex = index;
state.stockSearch.error = "";
state.stockSearch.status = "selected";
render();
return true;
}
function updateDraft(values) {
const draft = currentDraft();
if (!draft || !values || typeof values !== "object" || Array.isArray(values)) return false;
@@ -269,38 +365,46 @@
return true;
}
function addThemeItem(prefix, stockCode, stockName) {
function addThemeItem() {
const draft = currentDraft();
const allowed = draft && themePrefixes[draft.market];
if (!draft || state.entity !== "theme" || !allowed?.includes(prefix) ||
typeof stockCode !== "string" || !themeStockCodePattern.test(stockCode) ||
!safeText(stockName, 200) || draft.items.length >= workflow.limits.maximumThemeItems) {
return notifyError("시장별 prefix와 종목 코드·이름을 정확히 입력하세요.");
const identity = selectedStockIdentity();
if (!draft || state.entity !== "theme" || !stockIsCompatibleWithDraft(identity, draft) ||
draft.items.length >= workflow.limits.maximumThemeItems) {
return notifyError("검색 결과에서 현재 ThemeA 시장의 종목 하나를 선택하세요.");
}
const itemCode = `${prefix}${stockCode}`;
const storedName = themeStoredStockName(identity);
if (!storedName) {
return notifyError("NXT 표시명의 끝 (NXT) suffix를 안전하게 변환할 수 없습니다.");
}
const itemCode = `${themePrefixByStockMarket[identity.market]}${identity.code}`;
const candidate = {
market: draft.market,
themeCode: draft.themeCode,
themeTitle: safeText(draft.themeTitle, 128) ? draft.themeTitle : "검증",
items: [...draft.items, { inputIndex: draft.items.length, itemCode, itemName: stockName }]
items: [...draft.items, {
inputIndex: draft.items.length,
itemCode,
itemName: storedName
}]
};
if (!workflow.normalizeThemeDraft(candidate)) {
return notifyError("중복되거나 허용되지 않은 ThemeA 종목입니다.");
}
draft.items.push({ inputIndex: draft.items.length, itemCode, itemName: stockName });
draft.items.push({ inputIndex: draft.items.length, itemCode, itemName: storedName });
resetStockSearch();
state.error = "";
render();
return true;
}
function addExpertRecommendation(stockCode, stockName, buyAmount) {
function addExpertRecommendation(buyAmount) {
const draft = currentDraft();
const identity = selectedStockIdentity();
const amount = typeof buyAmount === "number" ? buyAmount : Number(buyAmount);
if (!draft || state.entity !== "expert" ||
typeof stockCode !== "string" || !expertStockCodePattern.test(stockCode) ||
!safeText(stockName, 200) || !Number.isSafeInteger(amount) || amount <= 0 ||
if (!draft || state.entity !== "expert" || !identity ||
!Number.isSafeInteger(amount) || amount <= 0 ||
draft.recommendations.length >= workflow.limits.maximumRecommendations) {
return notifyError("종목 코드·이름과 1 이상의 정수 매수가를 정확히 입력하세요.");
return notifyError("검색 결과에서 종목을 선택하고 1 이상의 정수 매수가를 입력하세요.");
}
const candidate = {
expertCode: draft.expertCode,
@@ -309,8 +413,8 @@
...draft.recommendations,
{
playIndex: draft.recommendations.length,
stockCode,
stockName,
stockCode: identity.code,
stockName: identity.name,
buyAmount: amount
}
]
@@ -320,10 +424,11 @@
}
draft.recommendations.push({
playIndex: draft.recommendations.length,
stockCode,
stockName,
stockCode: identity.code,
stockName: identity.name,
buyAmount: amount
});
resetStockSearch();
state.error = "";
render();
return true;
@@ -340,6 +445,8 @@
if (!rows || !Number.isInteger(index) || ![-1, 1].includes(direction) ||
index < 0 || target < 0 || index >= rows.length || target >= rows.length) return false;
[rows[index], rows[target]] = [rows[target], rows[index]];
if (state.selectedDraftIndex === index) state.selectedDraftIndex = target;
else if (state.selectedDraftIndex === target) state.selectedDraftIndex = index;
reindexDraftRows(rows, state.entity === "theme" ? "inputIndex" : "playIndex");
render();
return true;
@@ -348,13 +455,74 @@
function removeDraftItem(index) {
const draft = currentDraft();
const rows = state.entity === "theme" ? draft?.items : draft?.recommendations;
if (!rows || !Number.isInteger(index) || index < 0 || index >= rows.length) return false;
if (locked() || coordinator.getState().active || !rows || !Number.isInteger(index) ||
index < 0 || index >= rows.length) return false;
rows.splice(index, 1);
reindexDraftRows(rows, state.entity === "theme" ? "inputIndex" : "playIndex");
if (rows.length === 0) state.selectedDraftIndex = -1;
else if (state.selectedDraftIndex === index) {
state.selectedDraftIndex = Math.min(index, rows.length - 1);
} else if (state.selectedDraftIndex > index) {
state.selectedDraftIndex -= 1;
}
render();
return true;
}
function selectDraftItem(index) {
const draft = currentDraft();
const rows = state.entity === "theme" ? draft?.items : draft?.recommendations;
if (!rows || !Number.isInteger(index) || index < 0 || index >= rows.length) return false;
state.selectedDraftIndex = index;
for (const [rowIndex, row] of [...(dom?.draftList?.children || [])].entries()) {
row.className = rowIndex === index
? "mbn-operator-catalog-draft-row selected"
: "mbn-operator-catalog-draft-row";
row.setAttribute("aria-selected", String(rowIndex === index));
}
return true;
}
function editableDeleteTarget(target) {
let current = target;
while (current && current !== dom?.dialog) {
const tagName = String(current.tagName || "").toUpperCase();
const contentEditable = current.getAttribute?.("contenteditable");
if (tagName === "INPUT" || tagName === "TEXTAREA" || current.isContentEditable === true ||
(contentEditable !== null && contentEditable !== undefined &&
String(contentEditable).toLowerCase() !== "false")) return true;
current = current.parentElement;
}
return false;
}
function draftIndexFromTarget(target) {
let current = target;
while (current && current !== dom?.dialog) {
const value = current.getAttribute?.("data-draft-index");
if (value !== null && value !== undefined && /^\d+$/u.test(String(value))) {
return Number(value);
}
current = current.parentElement;
}
return -1;
}
function handleDraftDeleteKey(event) {
if (!dom?.dialog || !state.open || event?.key !== "Delete" ||
editableDeleteTarget(event.target) ||
(typeof dom.dialog.contains === "function" && !dom.dialog.contains(event.target))) return false;
const focusedIndex = draftIndexFromTarget(event.target);
const index = focusedIndex >= 0 ? focusedIndex : state.selectedDraftIndex;
const draft = currentDraft();
const rows = state.entity === "theme" ? draft?.items : draft?.recommendations;
if (!rows || index < 0 || index >= rows.length) return false;
event.preventDefault();
event.stopPropagation();
if (locked() || coordinator.getState().active) return false;
return removeDraftItem(index);
}
function canWrite() {
if (locked() || quarantined() || coordinator.getState().active || state.activeRead ||
state.readQueue.length > 0 || !state.schema) return false;
@@ -506,6 +674,7 @@
state.canMutate.theme = state.canMutate.theme !== false && result.canMutate;
state.entity = "theme";
state.mode = "new";
state.selectedDraftIndex = -1;
state.edit = {
expectedIdentity: null,
draft: workflow.createBlankThemeDraft(result.market, result.themeCode)
@@ -524,6 +693,7 @@
state.canMutate.expert = state.canMutate.expert !== false && result.canMutate;
state.entity = "expert";
state.mode = "new";
state.selectedDraftIndex = -1;
state.edit = {
expectedIdentity: null,
draft: workflow.createBlankExpertDraft(result.expertCode)
@@ -532,12 +702,57 @@
}
function failCurrentRead(pending, message) {
completeRead(pending, () => {
return completeRead(pending, () => {
state.error = message;
state.status = "응답 차단";
});
}
function handleStockMasterSearchResult(payload) {
const pending = state.activeRead;
if (!pending || pending.type !== workflow.stockMasterContract.request ||
payload?.requestId !== pending.request.requestId) return false;
const result = workflow.normalizeStockMasterSearchResult(payload, pending.request);
if (!result) {
completeRead(pending, () => {
state.stockSearch.status = "error";
state.stockSearch.results = [];
state.stockSearch.selectedIndex = -1;
state.stockSearch.error =
"잘렸거나 중복·동명이인·형식 오류가 있는 종목 응답을 차단했습니다. 검색어를 좁히세요.";
state.error = state.stockSearch.error;
state.status = "응답 차단";
});
return true;
}
completeRead(pending, () => {
state.stockSearch.query = result.query;
state.stockSearch.status = "ready";
state.stockSearch.results = [...result.results];
state.stockSearch.selectedIndex = -1;
state.stockSearch.error = "";
state.error = "";
});
return true;
}
function handleStockMasterSearchError(payload) {
const pending = state.activeRead;
if (!pending || pending.type !== workflow.stockMasterContract.request ||
payload?.requestId !== pending.request.requestId) return false;
const error = workflow.normalizeStockMasterSearchError(payload, pending.request);
completeRead(pending, () => {
state.stockSearch.status = "error";
state.stockSearch.results = [];
state.stockSearch.selectedIndex = -1;
state.stockSearch.error = error?.message ||
"현재 종목 검색 요청과 연결할 수 없는 오류 응답을 차단했습니다.";
state.error = state.stockSearch.error;
state.status = "조회 실패";
});
return true;
}
function handleMutationResult(payload) {
const active = coordinator.getState().active;
if (!active || payload?.requestId !== active.requestId) return;
@@ -552,6 +767,7 @@
state.error = "";
state.mode = "list";
state.edit = null;
state.selectedDraftIndex = -1;
options.addLog(`${result.entity} ${result.operation} 완료 · ${result.identityCode} · ${result.operationId}`);
options.showToast("ThemeA/EList DB 작업이 완료되었습니다.");
render();
@@ -588,6 +804,12 @@
}
function handleMessage(type, payload) {
if (type === workflow.stockMasterContract.result) {
return handleStockMasterSearchResult(payload);
}
if (type === workflow.stockMasterContract.error) {
return handleStockMasterSearchError(payload);
}
switch (type) {
case contract.events.schemaPreflight:
handleSchemaResult(payload);
@@ -674,6 +896,11 @@
themeResults: clone(state.themeResults),
expertResults: clone(state.expertResults),
selectedCatalogIndex: state.selectedCatalogIndex,
selectedDraftIndex: state.selectedDraftIndex,
stockSearch: clone({
...state.stockSearch,
selected: selectedStockIdentity()
}),
edit: clone(state.edit),
draft: clone(currentDraft())
});
@@ -766,23 +993,38 @@
name.autocomplete = "off";
identity.append(code, name);
const stockSearchForm = makeElement(
documentValue,
"form",
"mbn-operator-catalog-stock-search");
const stockQuery = assignRole(makeElement(documentValue, "input"), "stock-query");
stockQuery.type = "search";
stockQuery.maxLength = workflow.limits.maximumQueryLength;
stockQuery.autocomplete = "off";
stockQuery.placeholder = "종목명 검색 (직접 입력값은 저장되지 않음)";
const stockSearchButton = assignRole(
makeElement(documentValue, "button", "", "종목 검색"),
"stock-search");
stockSearchButton.type = "submit";
stockSearchForm.append(stockQuery, stockSearchButton);
const stockStatus = assignRole(
makeElement(documentValue, "output", "mbn-operator-catalog-stock-status"),
"stock-status");
const stockResults = assignRole(
makeElement(documentValue, "ol", "mbn-operator-catalog-stock-results"),
"stock-results");
const itemForm = makeElement(documentValue, "form", "mbn-operator-catalog-item-form");
const prefix = assignRole(makeElement(documentValue, "select"), "item-prefix");
const stockCode = assignRole(makeElement(documentValue, "input"), "stock-code");
stockCode.type = "text";
stockCode.maxLength = 32;
stockCode.autocomplete = "off";
const stockName = assignRole(makeElement(documentValue, "input"), "stock-name");
stockName.type = "text";
stockName.maxLength = 200;
stockName.autocomplete = "off";
const selectedStock = assignRole(
makeElement(documentValue, "output", "mbn-operator-catalog-selected-stock"),
"selected-stock");
const buyAmount = assignRole(makeElement(documentValue, "input"), "buy-amount");
buyAmount.type = "number";
buyAmount.min = "1";
buyAmount.step = "1";
const addButton = assignRole(makeElement(documentValue, "button", "", "추가"), "add-item");
addButton.type = "submit";
itemForm.append(prefix, stockCode, stockName, buyAmount, addButton);
itemForm.append(selectedStock, buyAmount, addButton);
const draftList = assignRole(makeElement(documentValue, "ol", "mbn-operator-catalog-draft"), "draft-list");
const actions = makeElement(documentValue, "footer", "mbn-operator-catalog-actions");
const saveButton = assignRole(makeElement(documentValue, "button", "", "DB 반영"), "save");
@@ -790,7 +1032,14 @@
const cancelButton = assignRole(makeElement(documentValue, "button", "", "목록으로"), "cancel-edit");
saveButton.type = deleteButton.type = cancelButton.type = "button";
actions.append(saveButton, deleteButton, cancelButton);
editor.append(identity, itemForm, draftList, actions);
editor.append(
identity,
stockSearchForm,
stockStatus,
stockResults,
itemForm,
draftList,
actions);
body.append(catalog, editor);
const error = assignRole(makeElement(documentValue, "p", "mbn-operator-catalog-error"), "error");
@@ -815,11 +1064,15 @@
editor,
code,
name,
stockSearchForm,
stockQuery,
stockSearchButton,
stockStatus,
stockResults,
selectedStock,
itemForm,
prefix,
stockCode,
stockName,
buyAmount,
addButton,
draftList,
saveButton,
deleteButton,
@@ -845,14 +1098,17 @@
name.addEventListener("input", () => updateDraft(state.entity === "theme"
? { themeTitle: name.value }
: { expertName: name.value }));
stockQuery.addEventListener("input", () => updateStockQuery(stockQuery.value));
stockSearchForm.addEventListener("submit", event => {
event.preventDefault();
searchStocks(stockQuery.value);
});
itemForm.addEventListener("submit", event => {
event.preventDefault();
const added = state.entity === "theme"
? addThemeItem(prefix.value, stockCode.value, stockName.value)
: addExpertRecommendation(stockCode.value, stockName.value, buyAmount.value);
? addThemeItem()
: addExpertRecommendation(buyAmount.value);
if (added) {
stockCode.value = "";
stockName.value = "";
buyAmount.value = "";
}
});
@@ -865,8 +1121,11 @@
cancelButton.addEventListener("click", () => {
state.mode = "list";
state.edit = null;
state.selectedDraftIndex = -1;
resetStockSearch();
render();
});
dialog.addEventListener("keydown", handleDraftDeleteKey);
state.mounted = true;
render();
@@ -899,7 +1158,14 @@
const draft = currentDraft();
const rows = state.entity === "theme" ? draft?.items : draft?.recommendations;
(rows || []).forEach((item, index) => {
const row = makeElement(dom.document, "li", "mbn-operator-catalog-draft-row");
const selected = index === state.selectedDraftIndex;
const row = makeElement(
dom.document,
"li",
selected ? "mbn-operator-catalog-draft-row selected" : "mbn-operator-catalog-draft-row");
row.tabIndex = 0;
row.setAttribute("data-draft-index", String(index));
row.setAttribute("aria-selected", String(selected));
const label = makeElement(
dom.document,
"span",
@@ -913,15 +1179,44 @@
up.type = down.type = remove.type = "button";
up.disabled = index === 0 || locked();
down.disabled = index + 1 === rows.length || locked();
remove.disabled = locked();
remove.disabled = locked() || Boolean(coordinator.getState().active);
row.addEventListener("focus", () => selectDraftItem(index));
row.addEventListener("click", event => {
if (event.target === row || event.target === label) selectDraftItem(index);
});
up.addEventListener("click", () => moveDraftItem(index, -1));
down.addEventListener("click", () => moveDraftItem(index, 1));
remove.addEventListener("click", () => removeDraftItem(index));
remove.addEventListener("click", event => {
event.stopPropagation();
removeDraftItem(index);
});
row.append(label, up, down, remove);
dom.draftList.appendChild(row);
});
}
function renderStockRows() {
if (!dom) return;
dom.stockResults.replaceChildren();
state.stockSearch.results.forEach((identity, index) => {
const compatible = stockIsCompatibleWithDraft(identity);
const row = makeElement(dom.document, "li", "mbn-operator-catalog-stock-row");
const button = makeElement(
dom.document,
"button",
index === state.stockSearch.selectedIndex ? "selected" : "",
`${identity.name} · ${identity.code} · ${identity.market} · ${identity.source}`);
button.type = "button";
button.disabled = !compatible || locked();
button.title = compatible
? "이 DB 종목 identity를 선택"
: "현재 ThemeA 시장과 다른 종목";
button.addEventListener("click", () => selectStockResult(index));
row.appendChild(button);
dom.stockResults.appendChild(row);
});
}
function render() {
if (!dom) return snapshot();
const isLocked = locked();
@@ -951,29 +1246,33 @@
: "";
dom.name.placeholder = state.entity === "theme" ? "ThemeA 이름" : "전문가 이름";
dom.name.disabled = isLocked || mutationActive;
dom.prefix.hidden = state.entity !== "theme";
dom.buyAmount.hidden = state.entity !== "expert";
if (draft && state.entity === "theme") {
const previous = dom.prefix.value;
dom.prefix.replaceChildren();
for (const value of themePrefixes[draft.market]) {
const option = makeElement(dom.document, "option", "", `${value} prefix`);
option.value = value;
dom.prefix.appendChild(option);
}
dom.prefix.value = themePrefixes[draft.market].includes(previous)
? previous
: themePrefixes[draft.market][0];
}
for (const control of [dom.stockCode, dom.stockName, dom.buyAmount, dom.prefix]) {
control.disabled = isLocked || mutationActive;
}
dom.stockQuery.value = state.stockSearch.query;
dom.stockQuery.disabled = isLocked || mutationActive || !draft;
dom.stockSearchButton.disabled = isLocked || mutationActive || !draft ||
state.stockSearch.status === "loading";
dom.stockStatus.textContent = state.stockSearch.error ||
(state.stockSearch.status === "loading"
? "종목 master 조회 중…"
: (state.stockSearch.status === "selected"
? "DB에서 확인된 종목 identity를 선택했습니다."
: (state.stockSearch.status === "ready"
? `${state.stockSearch.results.length}건 · 반드시 한 종목을 선택하세요.`
: "검색어는 조회에만 사용되며 코드·이름으로 직접 저장되지 않습니다.")));
const selectedStock = selectedStockIdentity();
dom.selectedStock.textContent = selectedStock
? `${selectedStock.name} · ${selectedStock.code} · ${selectedStock.market}`
: "선택된 DB 종목 없음";
dom.buyAmount.disabled = isLocked || mutationActive || state.entity !== "expert";
dom.addButton.disabled = isLocked || mutationActive || !draft || !selectedStock ||
!stockIsCompatibleWithDraft(selectedStock, draft);
dom.saveButton.disabled = !canWrite() || !draft;
dom.deleteButton.disabled = !canWrite() ||
(state.mode !== "edit" && !selectedCatalog());
dom.cancelButton.disabled = mutationActive;
dom.closeButton.disabled = mutationActive;
renderCatalogRows();
renderStockRows();
renderDraftRows();
if (state.open) {
@@ -1003,6 +1302,9 @@
beginNewTheme,
beginNewExpert,
updateDraft,
updateStockQuery,
searchStocks,
selectStockResult,
addThemeItem,
addExpertRecommendation,
moveDraftItem,

View File

@@ -13,9 +13,11 @@
const MAXIMUM_QUERY_LENGTH = 64;
const MAXIMUM_WEB_SAFE_INTEGER = Number.MAX_SAFE_INTEGER;
const requestIdPattern = /^[A-Za-z0-9_-]{1,128}$/;
const themeCodePattern = /^[0-9]{8}$/;
const newThemeCodePattern = /^[0-9]{8}$/;
const existingThemeCodePattern = /^[0-9]{3,8}$/;
const expertCodePattern = /^[0-9]{4}$/;
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
const stockMarkets = Object.freeze(["kospi", "kosdaq", "nxt-kospi", "nxt-kosdaq"]);
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
@@ -44,6 +46,12 @@
})
});
const stockMasterContract = Object.freeze({
request: "search-stocks",
result: "stock-search-results",
error: "stock-search-error"
});
function hasExactKeys(value, keys) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const actual = Object.keys(value).sort();
@@ -95,7 +103,7 @@
if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle"])) return null;
const market = normalizeMarket(value.market);
if (!market || !normalizeSession(value.session, market) ||
!themeCodePattern.test(value.themeCode) ||
!existingThemeCodePattern.test(value.themeCode) ||
!isSafeCanonicalText(value.themeTitle, 128) ||
(market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null;
return Object.freeze({
@@ -141,7 +149,7 @@
function normalizeThemeDraft(value) {
if (!hasExactKeys(value, ["market", "themeCode", "themeTitle", "items"])) return null;
const market = normalizeMarket(value.market);
if (!market || !themeCodePattern.test(value.themeCode) ||
if (!market || !existingThemeCodePattern.test(value.themeCode) ||
!isSafeCanonicalText(value.themeTitle, 128) ||
(market === "nxt" && /\(NXT\)/i.test(value.themeTitle))) return null;
const items = normalizeThemeItems(value.items, market);
@@ -250,6 +258,75 @@
return Object.freeze({ requestId: requireRequestId(requestId) });
}
function createStockMasterSearchRequest(requestId, query, maximumResults = MAXIMUM_RESULTS) {
requireRequestId(requestId);
const normalizedQuery = normalizeQuery(query);
if (!normalizedQuery) throw new TypeError("A non-empty stock-master search query is required.");
if (!Number.isInteger(maximumResults) || maximumResults < 1 ||
maximumResults > MAXIMUM_RESULTS) {
throw new RangeError("Stock-master results must be limited from 1 through 500.");
}
return Object.freeze({ requestId, query: normalizedQuery, maximumResults });
}
function normalizeStockMasterIdentity(value) {
if (!hasExactKeys(value, ["market", "source", "name", "code"]) ||
!stockMarkets.includes(value.market) || !stockCodePattern.test(value.code) ||
!isSafeCanonicalText(value.name, 200)) return null;
const expectedSource = value.market === "kospi" || value.market === "kosdaq"
? "oracle"
: "mariaDb";
if (value.source !== expectedSource) return null;
return Object.freeze({
market: value.market,
source: value.source,
name: value.name,
code: value.code
});
}
function normalizeStockMasterSearchResult(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
]) || !expectedRequest || value.requestId !== expectedRequest.requestId ||
value.query !== expectedRequest.query || !requestIdPattern.test(value.requestId) ||
normalizeQuery(value.query) !== value.query ||
!isSafeCanonicalText(value.retrievedAt, 64) ||
!Number.isFinite(Date.parse(value.retrievedAt)) ||
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
value.truncated !== false || !Array.isArray(value.results) ||
value.results.length !== value.totalRowCount ||
value.results.length > expectedRequest.maximumResults) return null;
const results = [];
const identities = new Set();
const names = new Set();
for (const raw of value.results) {
const item = normalizeStockMasterIdentity(raw);
const identity = item && `${item.market}\u0000${item.source}\u0000${item.code}\u0000${item.name}`;
if (!item || identities.has(identity) || names.has(item.name)) return null;
identities.add(identity);
names.add(item.name);
results.push(item);
}
return Object.freeze({
requestId: value.requestId,
query: value.query,
retrievedAt: value.retrievedAt,
totalRowCount: value.totalRowCount,
truncated: false,
results: Object.freeze(results)
});
}
function normalizeStockMasterSearchError(value, expectedRequest) {
if (!hasExactKeys(value, ["requestId", "query", "message"]) || !expectedRequest ||
value.requestId !== expectedRequest.requestId || value.query !== expectedRequest.query ||
!requestIdPattern.test(value.requestId) || normalizeQuery(value.query) !== value.query ||
!isSafeCanonicalText(value.message, 512)) return null;
return Object.freeze({ ...value });
}
function normalizeThemeCatalogSelection(value) {
if (!hasExactKeys(value, ["market", "session", "themeCode", "themeTitle", "source"])) return null;
const identity = normalizeThemeIdentity({
@@ -392,7 +469,7 @@
}
function createBlankThemeDraft(market, themeCode) {
if (!normalizeMarket(market) || !themeCodePattern.test(themeCode)) {
if (!normalizeMarket(market) || !newThemeCodePattern.test(themeCode)) {
throw new TypeError("A typed market and suggested eight-digit theme code are required.");
}
return { market, themeCode, themeTitle: "", items: [] };
@@ -427,7 +504,9 @@
function createThemeCreateRequest(requestId, draftValue) {
requireRequestId(requestId);
const draft = cloneThemeDraft(draftValue);
if (!draft) throw new TypeError("A complete theme draft is required.");
if (!draft || !newThemeCodePattern.test(draft.themeCode)) {
throw new TypeError("A complete theme draft with a new eight-digit code is required.");
}
return Object.freeze({ requestId, draft });
}
@@ -550,7 +629,7 @@
if (!hasExactKeys(value, [
"requestId", "market", "themeCode", "canMutate", "writeQuarantined"
]) || !requestIdPattern.test(value.requestId) || !normalizeMarket(value.market) ||
!themeCodePattern.test(value.themeCode) || typeof value.canMutate !== "boolean" ||
!newThemeCodePattern.test(value.themeCode) || typeof value.canMutate !== "boolean" ||
typeof value.writeQuarantined !== "boolean" ||
(value.writeQuarantined && value.canMutate)) return null;
if (expectedRequest && (value.requestId !== expectedRequest.requestId ||
@@ -591,7 +670,8 @@
]) || !requestIdPattern.test(value.requestId) ||
(value.entity !== "theme" && value.entity !== "expert") ||
!mutationOperations.has(value.operation) ||
!(value.entity === "theme" ? themeCodePattern : expertCodePattern).test(value.identityCode) ||
!(value.entity === "theme" ? existingThemeCodePattern : expertCodePattern)
.test(value.identityCode) ||
!uuidPattern.test(value.operationId) || !isSafeCanonicalText(value.committedAt, 64) ||
!Number.isFinite(Date.parse(value.committedAt)) || value.writeQuarantined !== false) return null;
if (expected && ["requestId", "entity", "operation", "identityCode"]
@@ -721,6 +801,7 @@
return {
bridgeContract,
stockMasterContract,
limits: Object.freeze({
maximumThemeItems: MAXIMUM_THEME_ITEMS,
maximumRecommendations: MAXIMUM_RECOMMENDATIONS,
@@ -736,6 +817,10 @@
createSchemaPreflightRequest,
createThemeNextCodeRequest,
createExpertNextCodeRequest,
createStockMasterSearchRequest,
normalizeStockMasterIdentity,
normalizeStockMasterSearchResult,
normalizeStockMasterSearchError,
normalizeThemeCatalogSelection,
normalizeExpertCatalogSelection,
createThemeEditDraft,

View File

@@ -79,6 +79,8 @@ button { color: inherit; }
.nav-item:hover { background: rgba(255,255,255,.035); color: var(--text); }
.nav-item.active { border-color: rgba(50,213,164,.18); background: var(--mint-soft); color: #dffdf4; font-weight: 650; }
.nav-item.active span { color: var(--mint); }
.nav-item[draggable="true"] { cursor: grab; }
.nav-item.dragging { opacity: .55; cursor: grabbing; }
.sidebar-status { padding: 14px 15px 17px; border-top: 1px solid var(--border-soft); background: rgba(0,0,0,.09); }
.status-row { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 7px; margin: 7px 0; color: var(--muted); font-size: 10px; }
@@ -141,7 +143,7 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
.playout-summary.dry-run > b { color: var(--amber); }
.playout-summary.error > b { color: var(--red); }
.console-grid { display: grid; grid-template-columns: minmax(250px, .73fr) minmax(410px, 1.2fr) minmax(330px, 1fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
.console-grid { display: grid; grid-template-columns: minmax(250px, .78fr) minmax(250px, .85fr) minmax(400px, 1.3fr) minmax(330px, 1.05fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
.panel { display: flex; min-width: 0; min-height: 0; flex-direction: column; border: 1px solid var(--border); border-radius: 12px; background: rgba(13,25,41,.92); box-shadow: var(--shadow); overflow: hidden; }
.panel-header { display: flex; align-items: center; justify-content: space-between; min-height: 67px; padding: 0 16px; border-bottom: 1px solid var(--border-soft); }
@@ -165,6 +167,8 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
.stock-workflow[hidden] { display: none; }
.stock-workflow.collapsed { min-height: 49px; max-height: 49px; flex-basis: 49px; }
.stock-workflow.collapsed > :not(.stock-workflow-header) { display: none; }
.stock-panel .stock-workflow { min-height: 0; max-height: none; flex: 1 1 auto; border-bottom: 0; }
.stock-panel .stock-workflow.collapsed { min-height: 49px; max-height: 49px; flex: 0 0 49px; }
.stock-workflow-header { display: flex; min-height: 49px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 6px; border-bottom: 1px solid var(--border-soft); }
.stock-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.stock-workflow-header .panel-kicker { margin-bottom: 3px; }
@@ -200,10 +204,17 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
.stock-cut-header small { margin-top: 1px; color: #5f7288; font-size: 7px; }
.stock-cut-header label { display: flex; align-items: center; gap: 3px; color: #8296ad; font-size: 7px; white-space: nowrap; }
.stock-cut-header input { width: 11px; height: 11px; }
.stock-cut-selection-tools { display: grid; grid-template-columns: minmax(0,1fr) auto auto; align-items: center; gap: 4px; padding: 5px 7px; border-bottom: 1px solid rgba(53,75,99,.25); background: rgba(7,17,29,.55); }
.stock-cut-selection-tools span { color: #71879e; font: 7px Consolas, monospace; }
.stock-cut-selection-tools button { min-height: 24px; padding: 0 6px; border-color: #29415b; color: #8298af; font-size: 7px; }
.stock-cut-selection-tools button:first-of-type { border-color: rgba(50,213,164,.3); color: #8be2c8; }
.stock-cut-selection-tools button:disabled { cursor: not-allowed; opacity: .38; }
.stock-cut-list { min-height: 90px; flex: 1; overflow-y: auto; }
.stock-cut-section { position: sticky; z-index: 1; top: 0; padding: 4px 8px; border-bottom: 1px solid rgba(53,75,99,.35); background: #0b1827; color: #6f89a4; font: 7px Consolas, monospace; letter-spacing: .05em; }
.stock-cut-row { display: grid; min-height: 31px; grid-template-columns: 28px minmax(0, 1fr) 26px; align-items: center; gap: 6px; padding: 3px 7px; border-bottom: 1px solid rgba(53,75,99,.24); cursor: default; }
.stock-cut-row:hover { background: rgba(255,255,255,.025); }
.stock-cut-row.selected { background: rgba(86,168,255,.08); box-shadow: inset 2px 0 var(--mint); }
.stock-cut-row:focus-visible { outline: 1px solid rgba(86,168,255,.65); outline-offset: -1px; }
.stock-cut-row.disabled { opacity: .38; }
.stock-cut-number { color: #536b84; font: 7px Consolas, monospace; text-align: center; }
.stock-cut-copy strong, .stock-cut-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -224,6 +235,10 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
.legacy-fixed-header .panel-kicker { margin-bottom: 3px; }
.legacy-fixed-tools { display: flex; align-items: center; gap: 5px; }
.legacy-fixed-tools button { min-height: 23px; padding: 0 7px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--muted); font-size: 7px; cursor: pointer; }
.legacy-fixed-section > header { cursor: default; }
.legacy-fixed-section-tools { display: flex; align-items: center; gap: 5px; }
.legacy-fixed-batch-add { min-height: 22px; padding: 0 6px; border-color: rgba(50,213,164,.28); color: #86ddc3; font-size: 7px; }
.legacy-fixed-batch-add:disabled { cursor: not-allowed; opacity: .4; }
.legacy-fixed-search { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; gap: 4px; height: 36px; margin: 8px 10px 5px; padding: 0 8px; border: 1px solid #29415b; border-radius: 6px; background: #07111d; }
.legacy-fixed-search span { color: #60748c; font-size: 14px; }
.legacy-fixed-search input { min-width: 0; border: 0; outline: 0; background: transparent; color: #d4e0ec; font-size: 9px; }
@@ -284,6 +299,9 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
.comparison-workflow-header { display: flex; min-height: 49px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 10px 6px; border-bottom: 1px solid var(--border-soft); }
.comparison-workflow-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
.comparison-workflow-header .panel-kicker { margin-bottom: 3px; }
.comparison-import-actions { display: flex; align-items: center; gap: 5px; }
.comparison-import-actions button { min-height: 24px; padding: 0 6px; border-color: rgba(86,168,255,.3); color: #91c8ff; font-size: 6px; }
.comparison-import-actions button.imported { border-color: rgba(50,213,164,.25); color: #83dec3; }
.comparison-source-tabs { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--border-soft); }
.comparison-source-tabs button { min-height: 26px; padding: 0 5px; color: #6f849c; font-size: 7px; }
.comparison-source-tabs button.active { border-color: rgba(50,213,164,.35); background: rgba(50,213,164,.08); color: #8be2c8; }
@@ -451,6 +469,10 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
.trading-halt-search-state.loading { color: #8ec4ff; }
.trading-halt-search-state.error { color: #ff9bad; }
.trading-halt-search-state.ready { color: #8bdcc4; }
.trading-halt-results-header { display: grid; min-height: 25px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; margin: 0 8px; padding: 0 7px; border: 1px solid var(--border-soft); border-bottom: 0; border-radius: 6px 6px 0 0; background: #0b1827; color: #657c94; font-size: 7px; }
.trading-halt-results-header button { min-height: 21px; padding: 0; border: 0; background: transparent; color: #91a6bc; font-size: 7px; text-align: left; }
.trading-halt-results-header button:hover:not(:disabled) { color: #c5d6e7; }
.trading-halt-results-header button:disabled { cursor: not-allowed; opacity: .42; }
.trading-halt-results { min-height: 100px; max-height: 175px; margin: 0 8px 6px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(7,17,29,.7); overflow-y: auto; }
.trading-halt-result { display: grid; width: 100%; min-height: 31px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; border: 0; border-bottom: 1px solid rgba(53,75,99,.28); border-radius: 0; background: transparent; text-align: left; cursor: pointer; }
.trading-halt-result:hover,.trading-halt-result.selected { background: rgba(86,168,255,.075); }
@@ -688,7 +710,7 @@ input[type="checkbox"] { accent-color: var(--mint); }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 8px; background: #263a51; background-clip: padding-box; }
@media (max-width: 1180px) {
@media (max-width: 1450px) {
.app-shell { grid-template-columns: 178px minmax(0, 1fr); }
.brand-block { padding: 0 14px; }
.console-grid { grid-template-columns: minmax(220px, .8fr) minmax(380px, 1.25fr); height: auto; }

View File

@@ -12,7 +12,9 @@
const MAXIMUM_PAGE_COUNT = 20;
const MAXIMUM_PREVIEW_ITEMS = 240;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const themeCodePattern = /^[0-9]{8}$/;
// ThemeA always generated new codes as D8, but the production SB_LIST and
// historical PLAY_LIST still contain original three- and four-digit codes.
const themeCodePattern = /^[0-9]{3,8}$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const sourceContract = Object.freeze({

View File

@@ -117,6 +117,48 @@
return 0;
}
function tradingHaltIdentity(value) {
const normalized = normalizeTradingHalt(value);
return normalized ? `${normalized.market}\u0000${normalized.stockCode}` : "";
}
function nextTradingHaltNameSort(direction) {
return direction === "descending" ? "ascending" : "descending";
}
function compareCanonicalText(left, right) {
if (left < right) return -1;
if (left > right) return 1;
return 0;
}
function sortTradingHaltsForDisplay(values, direction) {
if (!Array.isArray(values)) throw new TypeError("Trading-halt display rows are required.");
if (direction !== null && direction !== "ascending" && direction !== "descending") {
throw new TypeError("Trading-halt name sort must be ascending, descending, or null.");
}
if (direction === null) return [...values];
const nameDirection = direction === "ascending" ? 1 : -1;
return values.map((value, sourceIndex) => {
const normalized = normalizeTradingHalt(value);
if (!normalized) throw new TypeError("A valid trading-halt display row is required.");
return { value, normalized, sourceIndex };
}).sort((left, right) => {
const nameOrder = compareCanonicalText(
left.normalized.displayName,
right.normalized.displayName);
if (nameOrder !== 0) return nameOrder * nameDirection;
const marketOrder = compareCanonicalText(left.normalized.market, right.normalized.market);
if (marketOrder !== 0) return marketOrder;
const stockCodeOrder = compareCanonicalText(
left.normalized.stockCode,
right.normalized.stockCode);
return stockCodeOrder !== 0 ? stockCodeOrder : left.sourceIndex - right.sourceIndex;
}).map(item => item.value);
}
function normalizeTradingHaltSearchResponse(value, expectedRequest) {
if (!hasExactKeys(value, [
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
@@ -349,6 +391,9 @@
tradingHaltActions: actions,
normalizeTradingHalt,
normalizeTradingHaltResult: normalizeTradingHalt,
tradingHaltIdentity,
nextTradingHaltNameSort,
sortTradingHaltsForDisplay,
createTradingHaltSearchRequest,
normalizeTradingHaltSearchResponse,
normalizeTradingHaltError,

View File

@@ -0,0 +1,139 @@
# 원본 기능 재감사 기준표
감사 기준일: 2026-07-12
원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N`
마이그레이션 대상: `C:\Users\MD\source\repos\MBN_STOCK_WEBVIEW`
이 문서는 원본 WinForms/FarPoint 프로그램과 현재 WinUI 3/WebView2 프로그램을 다시 대조한 결과다. 원본 저장소와 실제 Cuts는 읽기 전용으로 조사했다. 자동 테스트나 UI 코드가 존재한다는 사실과 실제 운영 환경에서 동등성이 확인됐다는 사실을 구분한다.
## 상태 정의
| 상태 | 의미 |
|---|---|
| `미적용` | 원본 동작의 대응 구현 또는 필수 검증 경로가 아직 없다. |
| `적용 중` | 현재 작업 트리에 구현 또는 검사 보강이 있으나 최종 패키지·실환경 검증까지 끝나지 않았다. |
| `완료` | 현재 코드와 해당 자동 검증에 반영됐고 이번 재감사에서 완료로 판정했다. |
| `외부자산 필요` | 코드만으로 완료할 수 없으며 Git/MSIX 밖의 승인된 자산이나 운영 환경이 필요하다. |
완료 수는 증분 포팅과 최종 회귀 결과에 따라 바뀌므로 아래 표를 단일 기준으로 사용한다. 기존 문서에서 사용하는 `완료`, `통합 완료`, `전체 통과` 표현도 아래 상태와 검증 범위로 다시 해석해야 한다.
## 우선순위별 결과
| ID | 우선순위 | 기능·누락 후보 | 상태 | 근거 파일·메서드 | 현재 판정 | 검증 방법 |
|---|---|---|---|---|---|---|
| LF-001 | P0 | `s6001` 해외지수 영상 자산 | 외부자산 필요 | 현재 `src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs:491-513`, `S6001SceneMutationBuilder.Metadata`/`Index`; 검증기 `src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs:375-406`, `ResolveAssetPath` | 해외지수 15개 target이 13개 `Video\20201008_<국가>.vrv`에 의존한다. 감사 시 승인 Cuts의 `Video` 폴더는 비어 있어 이 target들은 DryRun PREPARE에서도 COM 호출 전에 차단된다. 유가·금의 PNG/JPG는 존재한다. | 승인된 파일을 Cuts root 아래에 제공한 뒤 강화된 cut/asset 검사와 s6001 각 target DryRun PREPARE를 실행한다. 실제 PGM은 별도 승인 회차에서만 확인한다. |
| LF-002 | P0 | cut coverage의 장면 종속 asset 검사 | 완료 | `scripts/LegacyCutRequirements.json`, `LegacyCutCoverage.psm1`, `Test-LegacyCutCoverage.ps1`; 테스트 `tests/Scripts/Test-LegacyCutCoverage.Tests.ps1`, `LegacyCutRequirementManifestTests.cs` | 45개 active alias와 23개 장면 종속 asset을 분리해 존재·0바이트·root escape·reparse·manifest 중복을 검사한다. 완전/누락/0바이트/reparse/escape fixture가 자동 테스트에 연결됐다. 실제 승인 Cuts read-only 실행은 alias missing/unsafe 0, asset unsafe 0, asset missing 14로 정확히 실패했다: `s5006` 1개와 `s6001` 13개 영상이다. | 실제 누락 14개가 승인 Cuts에 제공되면 같은 명령을 다시 실행해 `Status=Passed`를 확인한다. 검사기 완료와 외부 자산 준비 완료는 LF-001/LF-003에서 분리한다. |
| LF-003 | P0 | `s5006` 큐브 배경 영상 | 외부자산 필요 | 원본 `MBN_STOCK_N/Scene/s5006.cs:30-31`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs:31-48`; `docs/PLAYOUT_OPERATIONS.md:81-85` | `Video\큐브배경.vrv`가 승인 Cuts에 없다. 기존 운영 문서에는 누락이 기록돼 있으나 파일 제공 전에는 `s5006` PREPARE가 완료된 것이 아니다. | asset 검사 통과 후 s5006 DryRun PREPARE, 승인된 Test/PGM 회차 순으로 검증한다. |
| LF-004 | P0 | 실제 운영 DB write | 미적용 | `docs/OPERATOR_UI_PARITY.md:48-62,167-170,214-218,229-246,278`; GraphE `MainWindow.ManualFinancial.cs`; PList `MainWindow.NamedPlaylists.cs`; ThemeA/EList `MainWindow.OperatorCatalogs.cs` | mutation 계약, fake executor, rollback·OutcomeUnknown 테스트는 존재하지만 GraphE, PList/AList, ThemeA, EList의 운영 Oracle write는 실행하지 않았다. FSell/VI도 격리 저장소 write만 검증했다. 따라서 운영 데이터 쓰기 동등성은 미검증이다. | 승인된 테스트용 식별자와 사전 snapshot을 정하고 create/update/delete를 각 1회 실행한다. 매 단계 후 별도 read-only 조회로 결과를 대조하고, timeout·OutcomeUnknown이면 반복하지 않는다. 운영 데이터에는 임의로 실행하지 않는다. |
| LF-005 | P0 | 전체 장면·운영 UI의 실제 Tornado2/PGM 검증 | 미적용 | `docs/MIGRATION.md:69-74`; `docs/OPERATOR_UI_PARITY.md:48-62`; `docs/PLAYOUT.md:348-366` | 승인된 실제 회차의 범위는 5001/5074와 그 Page/playlist NEXT, refresh, TAKE OUT이다. 자동 builder/DB smoke 결과를 나머지 장면의 PGM 동등성으로 확대할 수 없다. | 장면별 허용 자산과 DB 전제조건을 먼저 통과시킨다. 이후 회차별 계획 SHA-256과 명시적 승인을 받아 Network Monitoring 명령, callback, PGM 화면을 함께 기록한다. |
| LF-006 | P1 | 시스템 종료 확인 | 완료 | `MainWindow.CloseConfirmation.cs`, `EnsureCloseConfirmationAttached`, `OnAppWindowClosing`, `BuildCloseConfirmationMessage`, `DetachCloseConfirmation`; 연결부 `MainWindow.xaml.cs:54-63,101-112,OnClosed`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:384-408` | 모든 시스템 close를 먼저 취소하고 기본 버튼이 취소인 확인창을 연다. PREPARED/PROGRAM, command pending, OutcomeUnknown에 맞는 경고를 표시하며 종료 과정에서 TAKE OUT이나 반대 명령을 자동 실행하지 않는다. | Web 통합 테스트를 실행하고 패키지 앱에서 idle, prepared/on-air 경고, 취소, 명시적 종료를 수동 확인한다. |
| LF-007 | P1 | 패키지 앱 단일 인스턴스 | 완료 | `App.xaml.cs:9-57`, `OnLaunched`, `OnMainInstanceActivated`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:72-88` | `AppInstance.FindOrRegisterForKey`가 MainWindow와 DB/WebView/playout runtime 생성 전에 실행된다. 두 번째 인스턴스는 activation을 primary로 전달한 뒤 종료하며 기존 프로세스를 이름으로 찾아 강제 종료하지 않는다. | 통합 테스트 후 설치된 MSIX를 두 번 실행해 프로세스 하나, 창 하나, 기존 창 활성화를 확인한다. |
| LF-008 | P1 | 운영자 모달의 전역 단축키 격리 | 완료 | `Web/app.js:6808-6845`, `hasOpenOperatorModal`, `isolateOperatorModalShortcut`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:410-429` | GraphE, FSell/VI, ThemeA/EList 등 modal이 열려 있으면 F2/F3/F8/Escape, Home/End/Delete/Space, Ctrl+K가 workspace 송출·playlist handler로 전달되지 않는다. F8/Escape에는 modal을 먼저 닫으라는 안내를 표시한다. | Web 통합 테스트 후 각 modal 입력 필드에서 해당 키를 눌러 playout command와 playlist mutation이 발생하지 않는지 확인한다. |
| LF-009 | P1 | PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원 | 적용 중 | 원본 `MBN_STOCK_N/Form/PList.cs:81-124`, `data_load`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs:442-540,742-787`, `LoadAsync`/`ParseItem`; `Web/named-playlist-workflow.js:334-412`, `restoreRawRows`; `Web/app.js:4317-4619,5017-5052` | 운영 Oracle read-only 집계에서 활성 정의 23개와 연결된 2,213행은 모두 정확히 7필드였고 canonical ASCII group은 0행, malformed/null은 0행이었다. 즉 전 행이 기존 한국어 형식이므로 legacy→canonical 복원이 필수다. 삭제된 정의에 연결된 orphan 88개 프로그램/9,369행은 현재 UI load 대상이 아니다. 닫힌 매퍼와 fresh identity 재검증을 보강 중이며 전체 2,213행의 unique/blocked 판정이 끝나기 전에는 완료로 보지 않는다. | 실제 read-only PList 행을 원본과 새 앱에서 같은 프로그램 코드로 읽어 `DC_TITLE`과 caret 7개 필드를 글자 단위로 비교한다. `<60>` 또는 mojibake가 없어야 하며 trusted restore와 fresh page plan 전에는 PREPARE가 계속 차단돼야 한다. raw 값은 로그·문서에 출력하지 않고 분류 건수만 기록한다. |
| LF-010 | P1 | 원본 F2/F3 배경 디렉터리 동등성 | 코드 적용 / 외부자산·패키지 검증 대기 | 원본 `MBN_STOCK_N/MainForm.cs:4008-4054`, `btnback_Click`, `ckback_CheckedChanged`; 현재 `MainWindow.Background.cs`, `PlayoutSceneCompositionFactory`, `TrustedPlayoutAssetPath`, `ValidatedPlayoutOptions`; 문서 `docs/TRUSTED_BACKGROUND_ROOT.md` | Cuts gate를 유지한 채 별도 `LegacyBackgroundDirectory`를 추가했고, 생략 시 원본처럼 `SceneDirectory\..\배경`을 계산한다. F2는 유효할 때만 picker 전 `기본.vrv`를 복원하고 F3-on은 항상 기본 파일을 다시 검증한다. root escape·조상/파일 reparse·hardlink·누락·opened-handle final path를 fail closed로 검사하며 Cuts fallback은 없다. 이 장비의 원본 `bin\Debug\배경``기본.vrv`는 실제로 없어 현재 기본 배경은 잠긴다. | Core Debug/Release 1,259/1,259, Playout Debug/Release 387/387과 앱 Debug/Release x64 build를 통과했다. signed 패키지에서 별도 root 상태 메시지와 F2/F3를 확인하고, 자산 제공 뒤 다음 PREPARE mutation/PGM은 승인 회차에서 검증한다. |
| LF-011 | P1 | `종목비교.dat` 기존 저장쌍 이전 | 적용 중 | 원본 `MBN_STOCK_N/Control/UC3.cs:34-55,181-185`, `UC3_Load`, `Data_Load`, `File_Save`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs`, `MainWindow.LegacyComparisonImport.cs`, `Web/comparison-import-workflow.js`, `Web/app.js`; 테스트 `LegacyComparisonPairImportServiceTests.cs`, `comparison-import-workflow.test.cjs`, `comparison-import-app-integration.test.cjs` | 원본 파일은 CP949 358바이트, 비어 있지 않은 8행, 각 행 정확히 9필드이며 0~3열만 값이 있다는 감사 fixture를 SHA-256 `1EE76BC4…BFC0D2F2`로 고정했다. Web이 경로를 전달하지 않는 고정 read-only source, reparse/크기/2회 quiescent snapshot 검사, strict CP949/9필드 parser, 현재 Oracle/MariaDB 종목·해외 identity exact read, 고정 시장명과 해외 동명이름 충돌 차단, 전체 중복·500쌍 경계, 명시적 버튼과 one-time marker/rollback을 구현했다. 원본은 수정하지 않고 성공 뒤 WebView private `localStorage` schema만 사용한다. 이 장비의 실제 설정으로 동일 서비스 read-only smoke를 실행해 8행 전부와 SHA-256이 일치하고 모든 identity가 해소됨을 확인했다. 다만 패키지 UI 버튼→localStorage→재시작 marker까지의 1회 실행은 아직 하지 않았으므로 완료로 올리지 않는다. | 실제 패키지에서 명시적 버튼 1회로 8행을 모두 fresh DB 재검증하고 local schema/hash/순서를 확인한다. 같은 profile의 두 번째 실행은 marker로 차단되고, 원본 변경·손상·동명이름·기존쌍 중복은 부분 저장 없이 전체 fail closed여야 한다. |
| LF-012 | P1 | FSell/VI 원본 `Data`의 안전한 이전과 설정 문서 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.cs:338-339`, `FSell.cs:31-88`, `VIList.cs:31-34,137-139`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyManualOperatorDataImport.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/LegacyManualOperatorDataImporter.cs`, `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, `Web/manual-lists-ui.js` | UserProfile 아래 고정 원본 경로만 read-only로 두 번 읽고 strict CP949, FSell 파일별 5행+종료 레코드, VI 9열·최대 100건·4,000바이트를 검사한다. 명시적 확인 뒤 private store가 완전히 비어 있을 때만 4개 staged 파일을 no-overwrite로 옮기고 production parser readback 후 v1 marker를 마지막에 기록한다. rollback을 확정하지 못하면 OutcomeUnknown quarantine이며 자동 재시도하지 않는다. 실제 source smoke는 FSell 15행·VI 9건·aggregate SHA-256 `fd39a363…a2114`였고, 이 장비의 private 4개 파일은 이미 원본과 각각 byte-identical이어서 `DestinationNotEmpty`로 source read 전 중단했으며 전후 SHA가 같았다. | 새 빈 package profile fixture에서 버튼→4파일→marker→재시작 차단을 확인한다. 현재 운영 profile에서는 기존 동일 파일을 덮어쓰거나 marker를 임의 생성하지 않는다. |
| LF-013 | P1 | generic native `bridge-error` 표시 | 적용 중 | producer `MainWindow.xaml.cs:431-437`, `MainWindow.Playout.cs:121-126,370-375`; 현재 consumer `Web/app.js:6446-6454`, `handleBridgeError`, `handleNativeMessage`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:431-435` | 재감사 당시 소비자가 없던 오류 표시가 현재 작업 트리에 반영됐다. bounded 메시지와 운영 로그 표시는 확인되지만 최종 회귀·패키지 반영 전이라 완료로 올리지 않는다. | malformed status/quarantine payload fixture로 `bridge-error` 1회, bounded toast/log, playout state 불변을 확인하고 전체 Web 테스트와 패키지 UI를 다시 검증한다. |
| LF-014 | P2 | 영구 운영 로그 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.cs:84-128,132-180,542-543,675,776`, `initAliveChecker`, `LogWrite`, `timerAliveChecker_Tick`; 현재 `src/MBN_STOCK_WEBVIEW.Infrastructure/Diagnostics/NativeOperatorLogWriter.cs`, `NativeOperatorLogTransitions.cs`, `MainWindow.NativeOperatorLog.cs` | `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Logs\Log_MMdd.log`에 Startup/Shutdown, Oracle/MariaDB·Tornado 전이, 실행 gate를 통과한 PREPARE/TAKE IN/NEXT/TAKE OUT의 요청과 terminal 결과만 기록한다. 4MiB/31일, provider별 중복 억제, single-command terminal, reparse/handle 검증을 적용했다. `TimedOut`과 불명확 결과는 `OutcomeUnknown` 한 번으로 끝내며 late result를 기록하지 않는다. 공개 record는 폐쇄 enum만 받아 requestId, 장면, 경로, SQL, DB message와 예외 문자열을 구조적으로 받을 수 없다. | Debug/Release 자동 검증 후 package context에서 시작→DB/DryRun 상태→종료를 실행하고 파일 재시작 잔존, 중복 억제, 금지 문자열 부재를 확인한다. |
| LF-015 | P2 | 호출자가 없는 native bridge handler | 완료 | `MainWindow.xaml.cs`, `OnWebMessageReceived`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs` | Web 호출자가 없던 `open-external``reload` message case를 native allowlist에서 제거했다. 외부 navigation/new-window 처리와 WebView process failure의 내부 reload 복구 메서드는 별도 native event 경로로 유지한다. | 정적 계약 테스트에서 두 message case와 Web 발신 literal이 모두 없음을 확인했다. |
| LF-016 | P2 | 실제 preview 이미지·영상 | 외부자산 필요 | `docs/MIGRATION.md:73-74`; 원본 `MBN_STOCK_N/MainForm.cs:1448-1462,1894-1911,2919-2921` | 현재 preview는 안전한 DTO/mutation 텍스트 중심이다. 실제 이미지·영상 연결은 후속 자산이다. 다만 원본 thumbnail 호출과 watcher는 주석 또는 미호출이므로 활성 원본 동등성의 P0/P1 blocker로 보지 않는다. | 승인된 preview 자산이 생기면 asset root와 노출 정보 검사를 거쳐 패키지 화면에서 확인한다. |
| LF-017 | P1 | 모든 업무 탭에서 계속 보이는 종목 검색·31컷 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.Designer.cs:2022-2036,2454-2462`; 현재 `Web/index.html`의 독립 `stock-panel`, `Web/app.js``renderStockWorkflow`; 테스트 `operator-ui-app-integration.test.cjs` | 종목 검색·31컷을 catalog panel 밖의 독립 열로 옮기고 모든 업무 메뉴에서 항상 표시한다. 1920 브라우저에서 11개 메뉴를 이동해 검색어, 표시, 31개 행이 유지되고 문서 가로 overflow가 없음을 확인했다. | signed MSIX에서 실제 DB 검색 결과·선택 종목이 모든 탭 전환 뒤 유지되고 컷 추가가 같은 identity를 쓰는지 확인한 뒤 완료로 올린다. |
| LF-018 | P2 | 원본 트리·컷 목록 일괄 추가 제스처 | 적용 중 | 원본 부모 double-click `MBN_STOCK_N/Control/UC1.cs:141-180`; 소스 컷 Ctrl/Shift 선택 `MBN_STOCK_N/MainForm.cs:3427-3525,3534-3549,3635-3643`; 현재 `Web/legacy-batch-selection-workflow.js`, `Web/app.js`; 테스트 `legacy-batch-selection-*.test.cjs` | 종목 31컷의 plain/Ctrl/Shift/Ctrl+Shift 선택, 단일 double-click, `선택 컷 추가`를 포팅했다. 해외·환율·지수 fixed source의 섹션 `전체 추가`는 모든 자식을 먼저 materialize한 뒤 원본 순서로 한 번에 commit하며, 수동/지원불가가 한 건이라도 있으면 부분 추가 없이 전체 차단한다. 실제 브라우저에서 5개 선택, 해외 43개 순서 추가, 수동 혼합 섹션 0건 추가를 확인했다. | signed MSIX 확인 뒤 업종·비교·테마 등 전용 source group의 부모 전체 추가가 원본에서 실제로 필요한 범위를 계속 분리 포팅한다. source drag/drop 자체는 오조작 검증 뒤 결정한다. |
| LF-019 | P1 | 저장 PList의 `테마_NXT` stale code 복원 | 적용 중 | 원본 `Control/UC4.cs`, `Scene/s5074.cs`, `s5077.cs`, `s5088.cs`; 현재 `LegacyNamedNxtThemeRestoreService`, `MainWindow.NamedNxtThemeRestore.cs`, `Web/named-nxt-theme-restore-workflow.js`; 상세 `docs/NAMED_NXT_THEME_RESTORE.md` | 원본처럼 제목 exact lookup으로 현재 Maria code를 찾고 현재가 전용 5/6/12행, 정렬 fallback, 00~12 PRE/13~23 AFTER, fresh PageN을 닫힌 batch로 복원한다. 실제 활성 48행/24제목은 모두 current identity가 유일하고 저장 code 48개가 전부 stale였지만 현재 preview가 전부 비어 있어 48행 모두 `PreviewEmpty`로 정상 차단됐다. KRX 테마는 후보가 아니다. | Maria `SB_ITEM`/`v_all_stock` live 연계가 복구된 뒤 같은 read-only audit에서 한 행 이상 live item을 확인하고, fresh page preflight와 Debug/Release/MSIX UI를 검증한다. 실제 PGM은 별도 승인 회차가 필요하다. |
| LF-020 | P2 | 원본 초기 업무 화면 `해외` | 적용 중 | 원본 `MainForm.Designer.cs`, 첫 TabPage `해외`; 현재 `Web/index.html`, `Web/app.js` 초기 `activeMarket` | 원본은 첫 업무 탭인 해외로 시작하지만 재감사 당시 새 앱은 dashboard로 시작했다. dashboard는 새 진단 화면으로 유지하면서 초기 active identity, 제목과 최초 조회를 해외로 맞추는 중이다. | Web 초기화 테스트와 signed MSIX 첫 실행에서 해외만 active이고 최초 DB 요청이 중복되지 않는지 확인한다. |
| LF-021 | P2 | 업무 탭 드래그 순서 변경 | 코드 적용 / 패키지 검증 대기 | 원본 `MainForm.Designer.cs:1063-1066` TabControl drag events, `MainForm.cs:3183-3312` `tab_MouseDown`/`tab_MouseMove`/`tab_DragOver`/`swapTabPages`; 현재 `Web/market-nav-reorder.js`, `Web/app.js``marketNavReorder` controller; 테스트 `market-nav-reorder*.test.cjs` | 새 dashboard는 첫 위치에 고정하고 원본 10개 업무 button만 `data-market` identity로 교환한다. active DOM node와 view/DB/playlist state는 건드리지 않으며 local/session storage에 저장하지 않는다. drag token은 닫힌 형식으로 검증하고, Alt+위/아래는 인접 교환과 첫·마지막 경계를 제공한다. 입력, modal, PREPARE 이후 잠금에서는 실행하지 않는다. | 전용 10/10과 전체 Web 353/353, cut fixture, Debug/Release x64 build가 통과했다. signed MSIX에서 pointer drag, 키보드 포커스, active click 유지와 재시작 시 기본 순서 복원을 확인한다. |
| LF-022 | P2 | GraphE 검색 Enter 다음 찾기 | 적용 중 | 원본 `GraphE.Designer.cs:229`, `GraphE.cs:605`, `downSearch_Click`; 현재 `Web/manual-financial-ui.js`; 테스트 `manual-financial-ui.test.cjs` | 목록 수락 전 Enter는 기존 DB 조회를 유지하고, 이후 Enter는 현재 선택 다음 행부터 대소문자 무시 Contains로 한 건을 찾아 load한다. 원본처럼 마지막에서 처음으로 순환하지 않으며 pending·잠금·숨김 modal은 무동작이다. | 네 GraphE 모드 관련 테스트 39/39 후 signed MSIX에서 한 건·마지막·검색어 변경을 확인한다. |
| LF-023 | P2 | 전문가 추천 편집기의 Delete 키 | 적용 중 | 원본 `UC6.Designer.cs:571`, `UC6.cs:230`; 현재 `Web/operator-catalog-ui.js`; 테스트 `operator-catalog-ui.test.cjs` | expert draft의 포커스/선택 행에서만 Delete를 기존 `removeDraftItem`으로 처리한다. ThemeA, modal 밖, input/textarea/contenteditable에는 영향이 없고 잠금·DB write 중에는 삭제하지 않고 event만 dialog 내부에서 격리한다. | Web 전체 회귀와 signed MSIX EList/전문가 편집 modal에서 확인한다. |
## 활성 PLAY_LIST 2,213행 재분류
운영 Oracle의 활성 프로그램 정의 23개와 연결된 2,213행을 원문을 출력하지 않는 read-only 집계로 다시 분류했다. 삭제된 정의에 매달린 orphan 데이터 88개 프로그램/9,369행은 현재 PList의 정상 load 대상이 아니므로 아래 수치에 포함하지 않았다.
| 복원 분류 | 건수 | 판정 |
|---|---:|---|
| 기존 닫힌 매퍼와 fresh 검증으로 복원 | 1,573 | stock/fixed/expert/GraphE 등 기존 계약과 현재 master를 모두 통과하는 행 |
| DB 없이 추가 복원 가능 | 57 | 업종 fixed 24, 고정 시장·지수 2열판 33 |
| 현재 DB exact lookup 뒤 추가 복원 가능 | 228 | NXT theme 48(복원 경로 적용, 현재 preview empty로 전부 차단), 비교 graph 41, 해외종목 78, 해외지수 candle 61 |
| 현재 identity를 안전하게 증명할 수 없음 | 325 | KRX theme 300, 원본이 시장을 저장하지 않은 종목 2열판 25. 이름이나 과거 코드를 추정하지 않고 재선택이 필요함 |
| whitelist 불일치로 자동 복원 금지 | 30 | `s5001` fixed-nearest 기존 집계가 재감사 후보 40건과 달라 명시적 CASE 확정 전까지 차단 |
| 합계 | 2,213 | raw 행은 closed mapping과 fresh identity가 모두 성공해야만 playable entry가 됨 |
테마 635행은 별도로 다음과 같이 판정했다. KRX exact 문법 375행 중 현재 제목이 유일한 287행만 즉시 신뢰할 수 있고 88행은 stale이다. 정렬 토큰이 없는 199행과 기타 문법 13행은 원본 `s5074/s5077/s5088`의 최종 분기와 같이 모두 `GAIN_ASC` 의미지만, 현재 `SB_LIST`에서 제목이 사라져 playable하지 않다. NXT 48행은 24개 저장 제목으로 구성되고 raw grammar는 `5단 표그래프 / 테마-현재가(입력순)` 한 종류다. 48행 모두 제목의 현재 Maria identity는 유일하고 저장 code가 현재 code와 다르지만, 현재 validated preview가 0건이어서 전부 `PreviewEmpty`로 차단한다. live item이 생긴 경우에만 현재 code를 송출 selection에 사용하며, 원본과 같이 PREPARE 직전 로컬 시각 00~12시는 PRE, 13~23시는 AFTER로 다시 결정한다.
비교 후보 99행은 고정 지수쌍 33, 시장쌍이 보존된 KRX graph 41, 시장이 사라진 `종목` 2열판 25로 확정했다. 앞의 74행만 closed target 또는 Oracle의 시장+이름 exact lookup으로 복원한다. 마지막 25행은 오늘의 master에서 이름이 우연히 유일해도 원본 행이 KOSPI/KOSDAQ/NXT/해외 구분을 보존하지 않았으므로 자동 복원하지 않는다. NXT graph는 원본 UC1 자체가 지원하지 않았고 현재 graph loader도 Oracle KRX pair만 허용하므로 계속 차단한다.
해외지수 candle 61행은 UC5 고정 이름 세 개(Dow 22, Nasdaq 21, S&P500 18)와 5/20/60/120일 기간만 사용하며 240일 활성 행은 없다. 이름과 기간은 닫힌 표이지만 원본 LIST_TEXT가 symbol/nation을 저장하지 않으므로 DB-free로 분류하지 않는다. Oracle master에서 `F_INPUT_NAME`, expected symbol, `F_NATC=US`, `F_FDTC=0`의 exact identity를 다시 확인한 뒤에만 `s8010/PRICE` 선택을 발급한다.
이 수치는 “문자열을 해석할 수 있음”과 “현재 데이터로 송출할 수 있음”을 분리한다. identity 미확정 325행과 whitelist 미확정 30행에는 `R...` 임시 code나 가장 가까운 장면을 넣지 않는다. 화면에는 실패 행을 유지하고 PREPARE를 차단하며, 운영자가 현재 항목으로 다시 선택해야 한다.
## s6001 asset 검사 보강 기준
`s6001` 해외지수 target은 다음 13개 파일을 요구한다.
- `Video\20201008_미국.vrv`
- `Video\20201008_독일.vrv`
- `Video\20201008_영국.vrv`
- `Video\20201008_프랑스.vrv`
- `Video\20201008_일본.vrv`
- `Video\20201008_중국.vrv`
- `Video\20201008_홍콩.vrv`
- `Video\20201008_대만.vrv`
- `Video\20201008_싱가포르.vrv`
- `Video\20201008_태국.vrv`
- `Video\20201008_필리핀.vrv`
- `Video\20201008_말레이시아.vrv`
- `Video\20201008_인도네시아.vrv`
검사 보강은 다음 조건을 만족해야 한다.
1. `.t2s` alias 결과와 asset 결과를 별도 필드로 보고한다.
2. 상대 경로만 허용하고 Cuts root 탈출을 거부한다.
3. 파일 존재, 0바이트 여부, reparse point를 검사한다.
4. 누락된 상대 경로와 영향을 받는 builder를 운영자가 알 수 있게 하되 secret이나 외부 절대 경로를 출력하지 않는다.
5. asset이 없는 현재 장비에서는 전체 migration 완료가 아니라 `외부자산 필요`로 끝나야 한다.
6. 완전 fixture, 단일 누락, 복수 누락, 0바이트, reparse fixture를 자동화한다.
권장 검증 명령 형태:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacyCutCoverage.ps1 `
-CutRoot "<승인된 읽기 전용 Cuts root>"
```
검사 보강 전의 `Missing=0``.t2s` alias만 의미하며 asset-ready 증거로 사용하면 안 된다.
## 원본 I/O 조사에서 제외 판정한 항목
- 활성 Print/Excel/FarPoint export/import 호출은 발견되지 않았다.
- `MainForm.cs:1767-1772,1796-1805`의 CSV playlist save/load는 주석 처리돼 있다.
- `MainForm.GetThumnail`과 preview watcher/`SaveImageFile` 호출도 활성 경로가 아니다.
- `s8086`은 scene builder는 존재하지만 원본 MainForm active alias가 없어 의도적으로 unreachable이다. 이를 UI 누락으로 계산하지 않는다.
- FarPoint의 표·선택·정렬·이동 자체는 Web table/workflow로 변환할 수 있지만, 변환 UI가 존재한다는 사실만으로 DB write나 PGM 동등성이 완료되지는 않는다.
## 기존 완료 문서 교정 후속
다음 문서는 구현 존재, 자동 계약 테스트, 실데이터 read smoke, 외부 asset readiness, 실제 DB write, 실제 PGM 검증을 서로 다른 열로 분리해야 한다.
| 문서 | 교정할 표현 | 후속 교정 내용 |
|---|---|---|
| `docs/MIGRATION.md:69-74` | MainForm/UC1~UC7과 전용 화면을 포괄적으로 `완료`로 표현 | `화면·bridge 구현``운영 동등성 검증`을 분리한다. PList 한국어 복원, s6001 asset, 원본 파일 one-time import, DB write, 장면별 PGM 상태를 이 문서와 맞춘다. |
| `docs/OPERATOR_UI_PARITY.md:48-62` | 다수 화면을 `화면 통합 완료`로 표현 | 해당 표현은 구조적 UI 도달성만 뜻한다고 명시하고 DB-W/PGM 미검증을 상단 요약과 각 행에 유지한다. 완료 판정은 실제 검증 열까지 충족할 때만 사용한다. |
| `docs/OPERATOR_UI_PARITY.md:75,111-113,214-218` | PList와 비교쌍 load/save를 완료로 표현 | PList 한국어 실DB 복원과 `종목비교.dat` one-time import를 별도 미완료 항목으로 추가한다. |
| `docs/OPERATOR_UI_PARITY.md:77` | F2/F3 배경을 완료로 표현 | 별도 sibling `배경` root와 `기본.vrv` 코드/자동 검증은 반영됐다. 실제 자산이 없으므로 package/PGM 완료로 확대하지 않는다. |
| `docs/SCENE_EQUIVALENCE.md:17,98` | 전체 real-data smoke 및 s6001을 asset 경고 없이 통과로 표현 | DB→DTO→mutation smoke와 실제 asset preflight를 분리하고 s6001 국가 영상 13개를 `외부자산 필요`로 기록한다. |
| `docs/PLAYOUT_OPERATIONS.md:86,94` | s5025 환경 변수 안내와 alias-only cut 검사 | 정상 앱의 private manual store 경로와 one-time import 정책을 정확히 설명한다. cut coverage 결과에 asset 검사 상태를 추가한다. |
| `docs/PLAYOUT.md:97,348-366` | Web 배경 변경 불가 설명 및 자동 검증을 35개 완료 근거로 사용 | F2/F3 native picker의 실제 경계를 반영한다. 5001/5074 PGM 성공과 나머지 scene 자동 검증을 명확히 분리한다. |
이 교정은 기존 구현 성과를 취소하는 것이 아니라, `구현됨``실제 방송 동등성 완료`를 같은 의미로 사용하지 않기 위한 것이다.
## 완료 판정에 필요한 최종 증거
1. s5006/s6001 포함 모든 reachable scene의 asset preflight 결과
2. PList 한국어 `DC_TITLE`/`LIST_TEXT` 실DB read 비교
3. 기존 `종목비교.dat`, FSell, VI 데이터의 안전한 one-time migration 결과
4. 승인된 테스트 데이터로 수행한 GraphE/PList/ThemeA/EList 및 manual file write read-back
5. Debug/Release x64 회귀와 Release MSIX package-context 실행
6. 승인된 scene·회차의 Network Monitoring 명령, callback, PGM 화면
7. timeout·OutcomeUnknown에서 재시도하지 않았다는 운영 기록
이 증거가 갖춰지기 전에는 전체 원본 UI 또는 35개 scene의 동등성 목표를 완료 처리하지 않는다.

View File

@@ -12,7 +12,7 @@
원본 작업 트리의 `MainForm.cs``RES/MmoneyCoder.ini`에는 커밋되지 않은 변경이 있습니다. 원본 저장소는 그대로 보존했으며, 새 프로젝트에는 비밀값이 포함된 INI를 복사하지 않았습니다.
## 적용 완료
## 화면/계약 적용 현황
| 기존 구현 | 새 구현 |
|---|---|
@@ -34,6 +34,8 @@
WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일만 제공합니다. 외부 탐색은 WebView 안에서 차단하고 기본 브라우저로 넘기며, 개발자 도구와 기본 컨텍스트 메뉴는 디버거가 연결된 경우에만 활성화합니다.
이 표의 `새 구현`은 화면·bridge·DTO·persistence 계약이 존재한다는 뜻이다. 운영 데이터 쓰기, 기존 운영 데이터의 문자·identity 복원, 외부 asset 준비, 실제 Tornado2/PGM 송출까지 모두 확인했다는 뜻은 아니다. 현재 재감사 판정과 남은 증거는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
## 구현 및 후속 운영 경계
### 데이터베이스
@@ -62,16 +64,18 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일
- 과거 실패·복구 증거: 승인 회차 `MBNWEB-20260711-A`의 CONNECT/PREPARE 뒤 fresh TAKE IN Oracle 조회가 K3D PLAY 전에 명확히 실패했으며 재시도 없이 STOPAL/UNLOAD/BYE로 회수했다.
- 승인 범위 완료: 이후 `MBNWEB-20260711-H`에서 설치된 MSIX WebView로 `5001 PREPARE → fresh TAKE IN → 자동 refresh 1회 → 5074 playlist NEXT → Page NEXT → TAKE OUT`을 실제 PGM과 Network Monitoring에서 함께 확인했다. `PLAY=4`, `SCENE_PLAYED=4`, FAILURE/ERROR/retry=0, 최종 `OutcomeUnknown=false`였다. 이 증거는 승인된 `5001`/`5074`에만 적용한다.
- 완료: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload와 pending callback fail-closed accounting
- 완료: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 5·6·12개/최대 20페이지 포팅과 자동·실제 DB 검증
- 화면/계약 구현: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 5·6·12개/최대 20페이지 포팅과 자동·실제 DB read→DTO→mutation 검증. 실제 장면별 PGM 검증과 asset readiness는 별도 판정
### 화면 기능
### 화면/계약 구현과 운영 동등성 검증
- 완료: MainForm과 UC1~UC7의 종목·고정·업종·비교·테마·해외·전문가·거래정지 workflow
- 완료: GraphE, FSell, VIList, PList/AList, ThemeA, EList 전용 화면과 native bridge
- 완료: 328 fixed + 44 industry + 31 stock + 9 comparison의 412 action 자동 매트릭스
- 완료: F2/F3 배경 선택, 전역 5일선/20일선, 이름 있는 Oracle 플레이리스트
- 후속 승인 범위: `5001`/`5074` 이외 장면의 실제 Tornado2/PGM 확인
- 후속 운영 자산: 필요할 때 trusted scene-root의 실제 Preview 이미지 및 영상 연결
- 화면/계약 구현: MainForm과 UC1~UC7의 종목·고정·업종·비교·테마·해외·전문가·거래정지 workflow, GraphE·FSell·VIList·PList/AList·ThemeA·EList 전용 화면과 native bridge
- 자동 계약 검증: 328 fixed + 44 industry + 31 stock + 9 comparison의 412 action 매트릭스, typed DTO, 저장·복원 fail-closed 경계
- 적용 중: PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원. 기존 운영 DB 행을 원본과 문자 단위로 대조하기 전에는 완료로 판정하지 않음
- 화면/계약 구현·운영 검증 대기: GraphE·FSell·VI 항목을 포함한 manual named save→reload→fresh identity/version/page plan 복원, ThemeA/EList의 KRX/NXT·동명이름·코드 identity 보존
- 미적용: 원본 `Data\종목비교.dat`, FSell, VI 데이터를 패키지 private 저장소로 안전하게 옮기는 one-time import
- 외부자산 필요: `s5006``Video\큐브배경.vrv`, `s6001` 해외지수의 국가별 영상 13개. 현재 alias-only cut 검사를 asset-ready 근거로 사용하지 않음
- 운영 동등성 미검증: GraphE, PList/AList, ThemeA, EList의 실제 Oracle DB-W와 FSell/VI 운영 파일 write/read-back
- 실제 PGM 제한: Round H의 `5001`/`5074`만 완료 증거가 있으며 나머지 장면·화면은 별도 승인 회차가 필요함
## 의도적으로 제외한 항목

View File

@@ -0,0 +1,31 @@
# PLAY_LIST 비교 행 안전 복원
원본 `UC1`은 비교 장면을 `PLAY_LIST.LIST_TEXT`의 7필드로 저장한다. 새 구현은 이 행을 바로 송출 가능한 값으로 취급하지 않고, 원본 행이 보존한 identity만 현재 master DB에서 다시 확인한다.
## 원본 서명과 복원 규칙
| 원본 장면 | 저장된 group | 자동 복원 |
|---|---|---|
| 2열판 현재/예상체결 | `지수` 또는 `종목` | 두 이름이 닫힌 고정 지수 target이고 group이 `지수`인 경우만 복원 |
| 비교 캔들/라인 | `시장1,시장2` | 각 이름을 지정 시장의 Oracle master에서 exact match한 경우만 복원 |
| 종목별 수익률 비교 | `시장1,시장2` | 각 이름과 5일/1·3·6·12개월 subtype이 모두 exact match한 경우만 복원 |
`종목` 2열판은 각 target의 KOSPI/KOSDAQ/NXT/해외 구분과 종목 코드를 저장하지 않았다. 현재 이름 검색이 우연히 하나만 나오더라도 저장 당시 시장을 증명하지 못하므로 `MISSING_MARKET_IDENTITY`로 남긴다. 임의 KOSPI 기본값이나 가짜 코드를 만들지 않는다.
NXT 캔들·라인·수익률은 identity 조회 전에 `UNSUPPORTED_ACTION`으로 차단한다. 원본 `UC1`이 이 action을 명시적으로 거부하고, 현재 comparison workflow 및 Core scene loader도 KRX Oracle pair만 지원하기 때문이다.
## 2026-07-12 실제 DB 읽기 전용 분류
- `DC_LIST`: 23개, 잘못된 정의 0개
- `PLAY_LIST`: 2,213행
- 비교 후보: 99행
- 고정 지수쌍: 33행, 33행 복원
- KRX 시장쌍 보존: 41행, 41행 exact master match 및 복원
- 시장 유실 `종목` 2열판: 25행, 25행 차단
- 실제 DB에서 확인된 NXT graph 행: 0행
총 74행이 명시적 comparison pair/action으로 복원되고 25행은 계속 PREPARE 차단 상태로 남는다. 이 검수는 bound `SELECT`만 실행했으며 원본 파일, Oracle/MariaDB, Tornado2에 쓰기나 송출 명령을 보내지 않았다.
## 실행 경계
named playlist load 결과가 Web에 먼저 표시된 뒤 같은 load correlation으로 native exact-identity 결과를 받는다. 결과가 오기 전에는 DB 작업과 PREPARE를 잠근다. 응답 순서, request id, action, target 형식 또는 현재 raw row가 다르면 결과 전체를 적용하지 않는다. playout 우선 취소나 DB 오류는 자동 재시도하지 않으며 해당 행은 untrusted 상태로 유지한다.

View File

@@ -0,0 +1,28 @@
# PLAY_LIST 업종 전체시장 행 복원
원본 `업종_코스피`·`업종_코스닥` 탭에서 업종 identity 없이도 생성되는 전체시장 action 세 개만 닫힌 규칙으로 복원한다.
| 원본 group | subject | graphicType | 현재 action |
|---|---|---|---|
| `업종_코스피` | `코스피` | `5단 표그래프` | `kospi / five-row` |
| `업종_코스피` | `코스피` | `네모그래프` | `kospi / square` |
| `업종_코스피` | `코스피` | `섹터지수` | `kospi / sector` |
| `업종_코스닥` | `코스닥` | `5단 표그래프` | `kosdaq / five-row` |
| `업종_코스닥` | `코스닥` | `네모그래프` | `kosdaq / square` |
| `업종_코스닥` | `코스닥` | `섹터지수` | `kosdaq / sector` |
모든 행은 subtype과 data code가 비어 있어야 한다. 다른 graphic type, 시장과 맞지 않는 subject, 개별 업종명 또는 업종쌍은 업종 코드를 보존하지 않았으므로 자동 복원하지 않는다. 기존 `R...` 임시 코드를 만드는 named restore 경로는 제거했다.
`five-row`는 저장된 날짜·페이지를 재사용하지 않는다. 현재 로컬 날짜로 canonical selection을 다시 만들고 현재 DB page-plan이 완료될 때까지 PREPARE를 차단한다. `square``sector``industry-workflow`가 만드는 닫힌 selection만 사용한다.
## 2026-07-12 실제 DB 읽기 전용 분류
- 전체 `DC_LIST`: 23개, 잘못된 정의 0개
- 전체 `PLAY_LIST`: 2,213행
- exact fixed 업종 행: 24행
- KOSPI five-row 2, square 10, sector 5
- KOSDAQ square 5, sector 2
- five-row 2행의 저장 page는 `1/6`이며 fresh page-plan 대상으로 분류
- 나머지는 빈 page 20행, `1/1` 2행
분류는 Oracle `DC_LIST/PLAY_LIST` bound read만 수행했다. DB 쓰기, Tornado2 명령 및 원본 저장소 변경은 수행하지 않았다.

View File

@@ -0,0 +1,66 @@
# 저장 PList의 `테마_NXT` 복원
기준일: 2026-07-12
원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N`
현재 구현: `LegacyNamedNxtThemeRestoreService`, `MainWindow.NamedNxtThemeRestore.cs`, `named-nxt-theme-restore-workflow.js`
## 원본 동작
원본 `Control/UC4.cs`는 NXT 테마를 `테마_NXT ^ 제목(NXT) ^ 컷 ^ 테마-현재가(정렬) ^ PageN ^ code`로 저장한다. 그러나 `Scene/s5074.cs`, `s5077.cs`, `s5088.cs`는 저장 code를 그대로 사용하지 않고 장면 생성 때 MariaDB `SB_LIST`에서 `SB_TITLE``SB_MARKET='NXT'`로 code를 다시 찾는다.
- NXT는 현재가만 지원한다. 예상체결가는 UC4에서 즉시 거부한다.
- 지원 컷은 5단(`s5074`, 5개), 6종목(`s5077`, 6개), 12종목(`s5088`, 12개)이다.
- 정렬은 입력순, 상승률순, 하락률순이다. 정렬 표기가 없으면 원본 최종 분기와 같이 하락률순이다.
- 장면 생성 시 로컬 시간이 00:00~12:59이면 PRE, 13:00~23:59이면 AFTER badge를 사용한다.
- 페이지 수는 현재 종목 수를 5/6/12로 나눈 올림값이며 현재 구현의 공통 안전 상한은 20페이지다.
## 현재 닫힌 복원 경계
Oracle named load의 `테마_NXT` 행은 기존 Web 문자열 매퍼를 통과하지 않는다. 모든 해당 행을 한 correlated native batch로 보내고 다음을 모두 만족할 때만 playable entry로 바꾼다.
1. 원본 7필드 grammar와 지원 컷·현재가·정렬이 정확하다.
2. MariaDB에서 suffix를 제거한 제목이 `SB_MARKET='NXT'` 안에서 정확히 한 행이다.
3. 그 현재 code도 NXT `SB_LIST` 안에서 유일하다.
4. `LegacyThemeSelectionService` preview가 schema, code/title/market, item code/index와 순서를 검증한다.
5. 정지되지 않았고 현재가가 0이 아닌 live item이 한 개 이상이다.
성공하면 송출 selection은 `PAGED_NXT_THEME`와 현재 code를 사용한다. 실패한 행은 화면에 남지만 미신뢰 상태이며 PREPARE가 차단된다. batch pending 동안 편집, DB mutation, 페이지 preflight와 PREPARE도 잠긴다. native 오류와 각 행 실패는 `retryable=false`이고 자동 재시도하지 않는다.
## 과거 code와 저장 왕복 정책
제목이 exact unique인 경우에만 과거 code를 현재 code로 매핑한다. 이 매핑은 “비슷한 제목”이나 code 숫자 거리 추정이 아니라 원본 장면이 사용한 exact-title lookup을 재현한 것이다.
- 실행·페이지 조회: 현재 MariaDB code만 사용한다.
- 같은 named list를 다시 저장: native 검증을 통과한 현재 세션에서만 원본 group/title/cut/subtype/과거 code를 그대로 보존하고, PageN만 fresh live 결과로 다시 계산한다.
- 로컬 저장 후 재시작 또는 검증 표식이 없는 객체: 과거 code 왕복 저장 권한이 없다. named list를 다시 load해 fresh 검증해야 한다.
- PREPARE 직전 PRE/AFTER session을 로컬 시각으로 다시 계산하되, code/title 검증 proof와 원본 7필드 identity는 유지한다.
이 정책은 원본 PList와의 상호운용성을 유지하면서도 stale code를 Tornado 장면 조회에 사용하지 않게 한다.
## 실제 read-only 감사 결과
2026-07-12 실제 Oracle/MariaDB에 DB write 없이 실행한 결과다.
| 항목 | 결과 |
|---|---:|
| 활성 `테마_NXT` 행 | 48 |
| 서로 다른 저장 제목 | 24 |
| raw grammar 변형 | 1 |
| grammar | `5단 표그래프 / 테마-현재가(입력순)` 48행 |
| exact unique 현재 title/code로 매핑 | 48 |
| 저장 code와 현재 code가 다름 | 48 |
| validated preview가 비어 있음 | 48 |
| 현재 playable 복원 | 0 |
따라서 구현은 48행을 안전하게 식별하고 현재 code까지 찾지만, 현재 MariaDB의 해당 24개 테마에 preview item이 없으므로 모두 `PreviewEmpty`로 차단하는 것이 정상 결과다. 운영자가 `SB_ITEM``v_all_stock` live 연계를 복구하기 전에는 이 행들을 PREPARE해서는 안 된다.
KRX `테마` 행은 이 batch의 후보가 아니며 이번 변경에서 자동 복원 정책을 바꾸지 않았다.
## 검증
- Core focused: 30/30 통과
- Web workflow/app integration focused: 12/12 통과
- actual DB audit: Oracle/MariaDB healthy, NXT 48행 전부 exact identity mapped, 48행 `PreviewEmpty`
- 전체 DB smoke는 같은 실행에서 비교 장면용 현재 종목 데이터가 변해 `s5026/s5029/s5087` 세 장면이 실패했다. NXT audit 자체 실패가 아니며 전체 smoke 성공으로 기록하지 않는다.
실제 Tornado 명령, 운영 DB write, 원본 파일 수정은 수행하지 않았다.

View File

@@ -4,16 +4,19 @@
이 문서는 원본 WinForms 운영 화면의 사용자 동작을 새 WinUI 3/WebView 구현에 1:1로 연결한다. 장면 builder가 존재하는 것, 네이티브 bridge와 workflow 모듈이 존재하는 것, 실제 Web 화면에서 운영자가 사용할 수 있는 것은 서로 다른 완료 조건으로 취급한다.
상태 표기는 다음과 같다.
상태 표기는 다음과 같다. 아래 기존 행의 `화면 통합 완료` 표현도 `화면/계약 구현`과 같은 구조적 도달성만 뜻하며, 운영 동등성 완료로 읽지 않는다.
| 상태 | 의미 |
|---|---|
| 화면 통합 완료 | 현재 `Web/index.html``Web/app.js`에서 검색·선택·플레이리스트 추가 또는 저장 동작을 실행할 수 있다. |
| 화면 통합 완료 / 실환경 검증 대기 | Web 화면, native bridge, workflow와 자동 테스트까지 연결됐지만 운영 DB write 또는 해당 경로의 실제 PGM 확인 수행하지 않았다. |
| 일부 검증 | 화면 동작은 구현됐지만 해당 기능 전체를 실제 운영 DB write 또는 PGM으로 확인하지 않았다. |
| 화면/계약 구현 | 현재 `Web/index.html``Web/app.js`에서 검색·선택·플레이리스트 추가 또는 저장 동작에 도달하며 native bridge·workflow 계약이 있다. |
| 화면/계약 구현 / 실환경 검증 대기 | 화면, native bridge, workflow와 자동 테스트까지 연결됐지만 운영 DB write, 기존 데이터 identity/문자 복원 또는 해당 경로의 실제 PGM 확인 수행하지 않았다. |
| 운영 동등성 일부 검증 | 승인된 제한 데이터·scene·회차만 실제 DB/PGM으로 확인했다. 확인하지 않은 범위로 확대하지 않는다. |
| 운영 동등성 완료 | 원본과 같은 입력·저장·재조회·identity·PGM 결과를 해당 기능 범위 전체에서 확인한 상태다. 현재 전체 UI에 이 판정을 적용하지 않는다. |
`DB-W`는 운영 데이터베이스에 실제 쓰기를 수행해 확인했는지를 뜻한다. `PGM`은 실제 Tornado2 PGM 출력으로 해당 UI 경로를 확인했는지를 뜻한다. fake executor, DryRun, SQL 계약 테스트는 자동 테스트 근거이지 실제 DB-W 또는 PGM 검증으로 계산하지 않는다.
기능별 미적용·적용 중·외부자산 필요 판정과 완료에 필요한 증거는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
## 원본 보존 상태
- 원본 저장소는 읽기 전용으로만 분석했고 파일을 수정하지 않았다.
@@ -43,23 +46,23 @@
## 화면 단위 완료 요약
| 원본 화면 | 핵심 역할 | 화면/모듈 상태 | DB-W | 해당 UI의 PGM |
| 원본 화면 | 핵심 역할 | 화면/계약 구현 상태 | 운영 동등성 DB-W | 운영 동등성 PGM |
|---|---|---|---|---|
| MainForm | 종목 검색, 31개 컷, playlist, 송출·배경·저장 | 화면 통합 완료 | 없음/해당 없음 | 일부: 승인된 `5001`, `5074`, TAKE OUT |
| UC1 | 328 fixed, 업종·비교 action 트리 | 전체 action과 수동 6개 전용 편집 화면 통합 완료 | 해당 없음 | 일부: generic `5074`; 전체 action 미검증 |
| UC2 | 업종 목록과 두 업종 선택 | 화면 통합 완료 | 해당 없음 | 미검증 |
| UC3 | 비교 대상·저장 비교쌍·9 action | 화면 통합 완료 | 원본 파일 저장을 browser schema 저장으로 대체 | 미검증 |
| UC4 | 테마 검색·preview·5/6/12행 항목 | 조회/선택 및 ThemeA CRUD 화면 통합 완료 | CRUD 미실행 | 미검증 |
| UC5 | 해외업종·해외종목·해외지수 | 화면 통합 완료 | 해당 없음 | 미검증 |
| UC6 | 전문가 조회·추천 preview·5행 항목 | 조회/선택 및 EList/추천 CRUD 화면 통합 완료 | CRUD 미실행 | 미검증 |
| UC7 | 거래정지 검색·현재/120일 캔들 | 화면 통합 완료 | 해당 없음 | 미검증 |
| GraphE | 4개 수동 재무 CRUD와 playlist 추가 | 화면 통합 완료 / 실환경 검증 대기 | 미실행 | 미검증 |
| FSell | 개인·외국인·기관 수동 순매도 5행 | 화면 통합 완료 / 실환경 검증 대기 | 운영 데이터 write 미실행 | 미검증 |
| VIList | 수동 VI 목록·순서·5개 단위 page | 화면 통합 완료 / 실환경 검증 대기 | 운영 데이터 write 미실행 | 수동 VI dataset 미검증 |
| PList | 이름 있는 playlist 목록·저장·불러오기·삭제 | 화면 통합 완료 | 운영 DB write 미실행 | 복원 경로 미검증 |
| AList | 프로그램 코드·이름 생성 | PList의 named playlist 화면에 통합 | 운영 DB write 미실행 | 해당 없음 |
| ThemeA | KRX/NXT 테마 구성 CRUD | 화면 통합 완료 / 실환경 검증 대기 | 미실행 | 미검증 |
| EList | 전문가 생성과 추천 구성 CRUD | 화면 통합 완료 / 실환경 검증 대기 | 미실행 | 미검증 |
| MainForm | 종목 검색, 31개 컷, playlist, 송출·배경·저장 | 화면/계약 구현. sibling `배경` root 코드는 적용됐고 실제 외부 배경 자산·package 검증 대기 | 없음/해당 없음 | 일부: 승인된 `5001`, `5074`, TAKE OUT |
| UC1 | 328 fixed, 업종·비교 action 트리 | 412 action과 수동 6개 전용 편집 화면/계약 구현 | 해당 없음 | 일부: generic `5074`; 전체 action 미검증 |
| UC2 | 업종 목록과 두 업종 선택 | 화면/계약 구현 | 해당 없음 | 미검증 |
| UC3 | 비교 대상·저장 비교쌍·9 action | 화면/계약과 strict `종목비교.dat` one-time importer 구현 | importer는 read-only이며 DB-W 없음 | importer 자동 fixture 통과; 실제 패키지 1회 실행·PGM 미검증 |
| UC4 | 테마 검색·preview·5/6/12행 항목 | 조회/선택 및 ThemeA CRUD 화면/identity 계약 구현 | CRUD 미실행; 실제 KRX/NXT identity 미검증 | 미검증 |
| UC5 | 해외업종·해외종목·해외지수 | 화면/계약 구현 | 해당 없음 | 미검증; `s6001` 국가 영상 13개 외부자산 필요 |
| UC6 | 전문가 조회·추천 preview·5행 항목 | 조회/선택 및 EList/추천 CRUD 화면/identity 계약 구현 | CRUD 미실행; 실제 identity 미검증 | 미검증 |
| UC7 | 거래정지 검색·현재/120일 캔들 | 화면/계약 구현 | 해당 없음 | 미검증 |
| GraphE | 4개 수동 재무 CRUD와 playlist 추가 | 화면/계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 |
| FSell | 개인·외국인·기관 수동 순매도 5행 | 화면/계약 구현 / 원본 파일 import 미적용 | 운영 데이터 write 미실행 | 미검증 |
| VIList | 수동 VI 목록·순서·5개 단위 page | 화면/계약 구현 / 원본 파일 import 미적용 | 운영 데이터 write 미실행 | 수동 VI dataset 미검증 |
| PList | 이름 있는 playlist 목록·저장·불러오기·삭제 | 화면/계약 구현. 한국어 legacy 복원과 manual named save 실환경 검증 중 | 운영 DB write 미실행 | 복원 항목별 미검증 |
| AList | 프로그램 코드·이름 생성 | PList 화면/계약에 통합 | 운영 DB write 미실행 | 해당 없음 |
| ThemeA | KRX/NXT 테마 구성 CRUD | 화면/identity 계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 |
| EList | 전문가 생성과 추천 구성 CRUD | 화면/identity 계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 |
## MainForm 매핑
@@ -68,13 +71,14 @@
| 원본 이벤트·컨트롤 | 원본 동작과 입력/저장 경계 | 새 구현 파일·bridge/workflow | 현재 상태 | 자동 테스트 근거 | DB-W / PGM |
|---|---|---|---|---|---|
| `Form1_Load`, `tabControl1_SelectedIndexChanged` | 실행 자산을 읽고 UC1~UC7을 탭에 배치하며 Tornado에 연결한다. | `MainWindow.xaml.cs`, `MainWindow.Playout.cs`, `Web/index.html`, `Web/app.js` | 화면 통합 완료 | `LegacyWebWorkflowIntegrationTests.cs`, `PlayoutBridgeProtocolTests.cs`, Web 전체 회귀 | DB-W 해당 없음 / PGM 일부 완료 |
| `tab_MouseDown`/`tab_MouseMove`/`tab_DragOver`, `swapTabPages` | 원본 10개 업무 탭의 표시 순서를 drag 중 identity 단위로 교환한다. | `Web/market-nav-reorder.js`; dashboard 고정; `data-market` 기반 drag와 Alt+위/아래; 세션 한정 | 코드 적용 / signed package 검증 대기. active view·DB 요청·playlist 상태를 변경하지 않음 | 전용 10/10, 전체 Web 353/353, Debug/Release x64 build | DB-W·PGM 해당 없음 |
| `button9_Click`, `txtSearch_KeyPress`, `searchStock`, `jongmokCode` | 코스피·코스닥·NXT 두 시장씩 검색하고 이름, 코드, 시장을 선택한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs`; native `search-stocks`; `Web/operator-workflow.js` | 화면 통합 완료 | `LegacyStockSearchServiceTests.cs`, `operator-workflow.test.cjs` | DB-W 없음 / `5001` 경로 일부 PGM 완료 |
| `listView1_*`, `GridView1_*`, `SetPlayList`, `PlayList_DragDrop` | 실행 자산의 종목 컷을 선택·드래그해 enabled, 시장, 종목, 종류, 세부사항, page, 종목코드 행을 만든다. | `Web/operator-workflow.js`, `Web/app.js`의 종목 workflow와 표준 playlist entry | 화면 통합 완료 | `operator-workflow.test.cjs`, `candle-options-workflow.test.cjs` | DB-W 없음 / 전체 31개 PGM 미검증 |
| `b1_Click` (`주요매출 구성`, `성장성 지표`, `매출액`, `영업이익`) | `GraphE` 네 모드를 연다. | `MainWindow.ManualFinancial.cs`, `Web/manual-financial-workflow.js`, `Web/manual-financial-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | GraphE 표 참조 | DB-W 없음 / PGM 없음 |
| `PlayList_CellClick`, `PlayList_KeyPress`, `checkBox1_CheckedChanged`, `btnup_Click`, `btndown_Click`, `btndelete_Click`, `btntdelete_Click` | 단일·Ctrl·Shift 선택, Space/전체 송출 체크, 순서 이동, 선택·전체 삭제. 송출 중 이동·전체 삭제를 막는다. | `Web/app.js`의 playlist 상태, snapshot lock, `Web/playout-safety.js` | 화면 통합 완료 | `playout-safety.test.cjs`, named/각 workflow의 trusted restore 테스트 | DB-W 없음 / PGM 제어 일부 완료 |
| `button5_Click`, `btnload_Click` | `PList`를 열어 이름 있는 DB 플레이리스트를 저장·불러온다. | `MainWindow.NamedPlaylists.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `Web/named-playlist-workflow.js` | 화면 통합 완료 | named playlist Core/Infrastructure/Web/bridge 테스트 | DB-W 없음 / 복원 항목별 PGM 없음 |
| `button5_Click`, `btnload_Click` | `PList`를 열어 이름 있는 DB 플레이리스트를 저장·불러온다. | `MainWindow.NamedPlaylists.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `Web/named-playlist-workflow.js` | 화면/계약 구현. 기존 한국어 행과 manual 항목의 실DB fresh restore는 적용 중 | named playlist Core/Infrastructure/Web/bridge 테스트 | DB-W 없음 / 복원 항목별 PGM 없음 |
| `btnPrepare_Click`, `btnTake_Click`/F8, `btnNext_Click`, `btnTakeout_Click`/Esc | PREPARE, TAKE IN, 같은 장면 page NEXT 또는 다음 행 NEXT, StopAll 기반 TAKE OUT. | `MainWindow.Playout.cs`; `IPlayoutEngine`; native `playout-command`, `request-playlist-page-plans`; `Web/playout-safety.js` | 화면 통합 완료 | playout/Core/PageN/PGM sequence 테스트와 `playout-safety.test.cjs` | DB-W 해당 없음 / 승인된 `5001`, `5074` page 1·2 및 TAKE OUT만 PGM 확인 |
| `btnback_Click`/F2, `ckback_CheckedChanged`/F3 | 허용 배경 파일 선택, 배경 없음 토글, 송출 중 변경 금지. | `MainWindow.Background.cs`; native `request-background-status`, `choose-background`, `toggle-background`; Web 배경 컨트롤 | 화면 통합 완료 | bridge·경로 검증과 앱 빌드 | DB-W 해당 없음 / 기능별 PGM 미검증 |
| `btnback_Click`/F2, `ckback_CheckedChanged`/F3 | 허용 배경 파일 선택, 배경 없음 토글, 송출 중 변경 금지. | `MainWindow.Background.cs`; 별도 `LegacyBackgroundDirectory`; opened-handle/hardlink/reparse 검증; native `request-background-status`, `choose-background`, `toggle-background`; Web 배경 컨트롤 | sibling `배경` root와 `기본.vrv` 코드 적용. 현재 원본 외부 폴더가 없어 fail closed이며 package/PGM 검증 대기 | 별도 root 내부/escape/reparse/hardlink/opened-handle/누락 fixture, Core·Playout Debug/Release 전부 통과, 앱 Debug/Release build | DB-W 해당 없음 / 기능별 PGM 미검증 |
| `timer1_Tick`, `timer2_Tick`, `timer3_Tick`, `MainForm_FormClosing`, `MainForm_Resize`, keyboard handlers | 자동 갱신, 상태 표시, 종료 정리, 단축키를 담당한다. | playout refresh scheduler, cancellation/lifetime hooks, Web 상태·단축키 | 화면 통합 완료 | refresh epoch/scheduler, playout safety, bridge 테스트 | DB-W 해당 없음 / 일부 PGM 완료 |
실제 PGM 완료는 이미 승인된 제한 회차의 `5001``5074` 흐름만 뜻한다. 412개 전체나 아래 편집 화면의 실제 PGM 동등성을 뜻하지 않는다.
@@ -108,9 +112,9 @@
| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 |
|---|---|---|---|
| `UC3_Load`, `Data_Load` | 24개 고정 비교 대상을 읽고 로컬 `종목비교.dat`의 저장쌍을 불러온다. | `Web/comparison-workflow.js`; 24개 typed target; schema-versioned pair list | 화면 통합 완료. Web은 안전한 browser storage를 사용하며 raw 원본 파일을 신뢰하지 않는다. |
| `UC3_Load`, `Data_Load` | 24개 고정 비교 대상을 읽고 로컬 `종목비교.dat`의 저장쌍을 불러온다. | `Web/comparison-workflow.js`; 24개 typed target; schema-versioned pair list; `LegacyComparisonPairImport`의 고정 read-only source와 strict CP949 9필드 importer | 화면/계약과 명시적 one-time 가져오기 구현. 성공 후 원본을 계속 참조하지 않고 private browser schema만 사용한다. 실제 운영 DB 연결 패키지에서 1회 실행은 미검증이다. |
| `btnsearch_Click`, `listBox_DoubleClick`, `listBox1_DoubleClick`, `btnsearch2_Click`, `FpSpread2_CellDoubleClick` | 국내 종목, 24개 시장 대상, 해외 종목을 두 슬롯에 회전 입력한다. | native `search-stocks`, `search-world-stocks`; comparison target normalizer | 화면 통합 완료. `comparison-workflow.test.cjs`가 회전·시장·해외 identity를 검증한다. |
| `btnswap_Click`, `btnAdd_Click`, `btn1/2/3/4_Click`, `File_Save` | 쌍 교환, 추가, 선택/전체 삭제, 위/아래 이동 후 저장한다. | comparison pair append/delete/delete-all/reorder와 Web storage | 화면 통합 완료. 최대치·중복·손상 복원을 테스트한다. |
| `btnswap_Click`, `btnAdd_Click`, `btn1/2/3/4_Click`, `File_Save` | 쌍 교환, 추가, 선택/전체 삭제, 위/아래 이동 후 저장한다. | comparison pair append/delete/delete-all/reorder와 Web storage; explicit legacy import button | 화면/계약 구현. 기존 CP949 파일은 exact 9필드·fresh DB identity·중복·500쌍 경계를 모두 통과해야 전체가 원자적으로 저장되고 marker가 남는다. 실제 1회 실행은 미검증이다. |
| UC1 비교 leaf 실행 | 현재/예상 2열, 비교 캔들/라인, 5일·1/3/6/12개월 수익률의 9개 action | `comparison-workflow.js` | 9개 화면 통합 완료. 실제 DB-W 없음 / 비교 PGM 없음. |
### UC4 — 테마 검색·미리보기·송출 선택
@@ -213,12 +217,12 @@
|---|---|---|---|
| `PList_Load`, `Get_FileName_Folder` | `DC_LIST`의 프로그램 제목·코드를 조회한다. | native `request-named-playlist-list`; `named-playlist-workflow.js` | 화면 통합 완료 |
| `AList_Load`, `GetNextCode`, `btnok_Click` | 다음 8자리 코드와 프로그램명을 생성해 `DC_LIST`에 추가한다. | native `create-named-playlist` | 화면 통합 완료. parameterized transaction 사용. |
| `PList.data_save`, double-click/확인 | 기존 `PLAY_LIST`를 지운 뒤 enabled와 6개 표시 필드를 `LIST_TEXT` 및 ITEM_INDEX 순서로 저장한다. | native `replace-named-playlist`; trusted entry serialization | 화면 통합 완료. raw·untrusted entry는 저장 또는 PREPARE 불가. |
| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면 통합 완료. raw DB row는 builder/selection/page를 재검증할 때까지 PREPARE 불가. |
| `PList.data_save`, double-click/확인 | 기존 `PLAY_LIST`를 지운 뒤 enabled와 6개 표시 필드를 `LIST_TEXT` 및 ITEM_INDEX 순서로 저장한다. | native `replace-named-playlist`; trusted entry serialization | 화면/계약 구현. raw·untrusted entry는 저장 또는 PREPARE 불가. GraphE·FSell·VI 등 manual 항목의 실제 save→reload→fresh identity/version 보존은 미검증. |
| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면/계약 구현. raw DB row는 builder/selection/page를 재검증할 때까지 PREPARE 불가. 기존 한국어 `DC_TITLE`/7필드 `LIST_TEXT` 무손상 복원은 적용 중. |
| `PList.button1_Click` | 프로그램 정의 삭제 | native `delete-named-playlist` | 화면 통합 완료. 실제 운영 DB-W 없음. |
| 취소 | 아무 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. |
구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트다. 실제 운영 DB write named playlist 전용 PGM 검증은 아직 없다.
구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트다. 실제 운영 DB write, 기존 한국어 데이터의 원본 대조, manual named save의 fresh restore 및 named playlist 전용 PGM 검증은 아직 없다.
### ThemeA — 테마 추가·편집
@@ -233,7 +237,7 @@
| `btnok_Click`, `GetNextCode` | 신규 코드를 만들거나 기존 `SB_LIST`/`SB_ITEM`을 교체하고 입력 순서를 저장한다. KRX와 NXT 저장소를 구분한다. | native `request-theme-catalog-next-code`, `create-theme-catalog`, `replace-theme-catalog`; atomic mutation executor | 화면 통합 완료. 실제 운영 DB-W/PGM 없음. |
| UC4 테마 삭제 | `SB_ITEM``SB_LIST` 삭제 | native `delete-theme-catalog` | 화면 통합 완료. |
구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs`, `MainWindow.OperatorCatalogs.cs`, `Web/operator-catalog-workflow.js`다. 자동 근거는 theme catalog, schema, executor, Web workflow 테스트다.
구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs`, `MainWindow.OperatorCatalogs.cs`, `Web/operator-catalog-workflow.js`다. 자동 근거는 theme catalog, schema, executor, Web workflow 테스트다. expected identity와 응답 correlation 계약은 구현했지만 실제 운영 DB에서 KRX/NXT, 동명이름, 코드가 같은 선택 행을 정확히 replace/delete하는지는 검증 대기다.
### EList — 전문가 신규 생성
@@ -246,7 +250,7 @@
| UC6 저장·삭제 | 전문가와 `RECOMMEND_LIST` 구성 종목·금액·PLAYINDEX를 교체 또는 삭제한다. | native `replace-expert-catalog`, `delete-expert-catalog` | 화면 통합 완료. 실제 PGM 없음. |
| `btncn_Click` | 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. |
구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs`, 공통 schema/executor/native partial, `Web/operator-catalog-workflow.js`다. 자동 근거는 expert catalog, schema, executor, Web workflow 테스트다.
구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs`, 공통 schema/executor/native partial, `Web/operator-catalog-workflow.js`다. 자동 근거는 expert catalog, schema, executor, Web workflow 테스트다. 전문가 코드·이름과 추천 행 identity 계약은 구현했지만 실제 운영 DB의 create/replace/delete read-back은 검증 대기다.
## bridge와 화면 로딩 상태 요약
@@ -277,4 +281,8 @@
| 자동 테스트 | Core 1,116개, Infrastructure 125개, Playout 374개가 Debug/Release x64에서 각각 통과했고 Web workflow·bridge 235/235도 통과했다. 412 action의 폐쇄형 매트릭스, 계약·경계·복원 실패와 native fail-closed gate를 검증한다. |
| 실제 운영 DB write | **미실행.** GraphE, named playlist, ThemeA, EList의 mutation executor는 fake/계약 테스트로 검증했으며 운영 DB에는 쓰지 않았다. FSell/VI는 테스트용 격리 저장소만 사용했다. |
| 실제 Tornado2 PGM | 제한된 승인 회차에서 `5001`, `5074` page 1·2, TAKE OUT을 확인했다. 이는 전체 412 action, GraphE, FSell, VI, ThemeA, EList, 이름 있는 playlist 각각의 UI 동등성 검증이 아니다. |
| 남은 실환경 검증 | 다섯 전용 화면의 Web 연결과 새 signed `1.0.2.0` Release MSIX package-context 실행은 완료됐다. 실제 입력→운영 DB write→재조회와 각 전용 scene PGM 검증은 별도 승인·테스트 데이터·롤백 계획이 있는 회차에서만 수행한다. 운영 DB write 또는 새 Live TAKE IN을 승인 없이 실행하지 않는다. |
| PList legacy 복원 | raw restore와 fail-closed materialization 계약은 구현했다. 실제 기존 `DC_TITLE`/`LIST_TEXT`의 한국어 무손상 복원은 적용 중이며 완료 증거가 없다. |
| manual named save | GraphE·FSell·VI descriptor 계약과 복원 테스트는 있다. 실제 Oracle save→reload→fresh manual identity/version→page plan→PGM은 미검증이다. |
| ThemeA/EList identity | expected identity·상관 응답 계약은 구현했다. 실제 운영 DB의 KRX/NXT·동명이름·코드 경계와 정확한 선택 행 replace/delete는 미검증이다. |
| legacy file import | `종목비교.dat` importer는 strict parser/DB identity/local one-time marker까지 구현했으나 실제 패키지 1회 실행은 미검증이다. FSell/VI 원본 파일 importer는 미적용이다. |
| 남은 실환경 검증 | 다섯 전용 화면의 Web 연결과 새 signed `1.0.2.0` Release MSIX package-context 실행은 화면/계약 검증 범위에서 확인됐다. 실제 입력→운영 DB write→재조회와 각 전용 scene PGM 검증은 별도 승인·테스트 데이터·롤백 계획이 있는 회차에서만 수행한다. 운영 DB write 또는 새 Live TAKE IN을 승인 없이 실행하지 않는다. |

View File

@@ -6,6 +6,8 @@
이 저장소의 작업과 자동 검증은 실제 PGM 출력 또는 라이브 `TAKE IN`을 허가하지 않습니다. `Test` 검증은 PGM과 분리된 전용 테스트 인스턴스, 테스트 출력 채널 및 허용 목록에 든 테스트 씬을 모두 확인한 뒤에만 수행합니다. `Live`는 운영 책임자의 명시적인 회차별 허가 없이는 사용하지 않습니다.
이 문서의 adapter·builder·DryRun 완료 표현은 화면/계약 구현 범위다. 운영 DB-W, 외부 asset readiness, 기존 데이터 복원과 장면별 실제 PGM은 별도 증거가 있어야 한다. 현재 미적용·적용 중·외부자산 필요 항목은 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
## 확인된 x64 COM 등록
현재 개발 장비에서 확인할 등록 기준은 다음과 같습니다.
@@ -94,7 +96,7 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
| `layoutIndex` | 씬 플레이어 layout 위치 |
| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본값 6, 허용 범위 0~60 |
| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web에서 변경할 수 없음 |
| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web은 임의 경로를 보내지 않고 F2/F3가 native picker/toggle을 호출한다. 현재 picker는 `sceneDirectory` 내부만 허용하므로 원본의 sibling `배경` root와는 아직 동등하지 않음 |
| `legacySceneBackgroundAssetPath` | `sceneDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 |
| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경의 bounded loop 설정 |
| `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 |
@@ -339,12 +341,20 @@ CLI용 Test JSON은 위처럼 앱 기본 경로인 `playout.local.json`과 다
3. 잘못된 설정 또는 엔진 부재가 앱 종료가 아니라 연결 상태와 안전한 오류 메시지로 표시됩니다.
4. x64 MSIX를 설치해도 같은 dry-run 흐름이 동작합니다.
최종 서명 Release x64 MSIX를 설치한 package context에서 실제 Oracle/MariaDB를 읽는 `DryRun` 검증을 완료했습니다. Web catalog 35개(34개 송출 가능), DB 상태 2/2 정상, alias `5001`/`N5001`, fade 6, mutation preview와 asset 경로 비노출을 확인했습니다. 이어 `5001 PREPARE → fresh TAKE IN → callback drain 뒤 full 2초 cooldown의 자동 refresh → 5074 playlist NEXT → 같은 entry/scene의 Page NEXT`를 수행했고, 5074 `page 1/20 → page 2/20` 전환과 Page NEXT 뒤 refresh 정지를 확인했습니다. 5·6·12개 단위, 최대 20페이지, partial/마지막 page의 clear·hide, `isLastPage`, `END OF PLAYLIST`와 NEXT 비활성 경계는 원본에서 도출해 고정한 자동 테스트로 검증했습니다. TAKE OUT 뒤에는 scene/refresh가 정리되고 playlist 편집 잠금이 해제되었습니다. 전 과정의 안전 배지는 `DRY RUN · PROGRAM 차단`이었고 COM/KTAP/Tornado2 연결은 발생하지 않았습니다.
최종 서명 Release x64 MSIX를 설치한 package context에서 실제 Oracle/MariaDB를 읽는 `DryRun` 검증을 완료했습니다. Web catalog 35개(34개 runtime route 보유), DB 상태 2/2 정상, alias `5001`/`N5001`, fade 6, mutation preview와 asset 경로 비노출을 확인했습니다. 34개 runtime route가 모두 외부 asset까지 준비됐다는 뜻은 아니다. 이어 `5001 PREPARE → fresh TAKE IN → callback drain 뒤 full 2초 cooldown의 자동 refresh → 5074 playlist NEXT → 같은 entry/scene의 Page NEXT`를 수행했고, 5074 `page 1/20 → page 2/20` 전환과 Page NEXT 뒤 refresh 정지를 확인했습니다. 5·6·12개 단위, 최대 20페이지, partial/마지막 page의 clear·hide, `isLastPage`, `END OF PLAYLIST`와 NEXT 비활성 경계는 원본에서 도출해 고정한 자동 테스트로 검증했습니다. TAKE OUT 뒤에는 scene/refresh가 정리되고 playlist 편집 잠금이 해제되었습니다. 전 과정의 안전 배지는 `DRY RUN · PROGRAM 차단`이었고 COM/KTAP/Tornado2 연결은 발생하지 않았습니다.
WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. Web catalog는 35개 builder와 도달 가능한 45개 alias, row별 `enabled`를 보존하고 PREPARE 성공 뒤 playlist snapshot을 freeze합니다. pending command, `OutcomeUnknown`과 timeout quarantine 중에도 snapshot 편집 잠금을 유지합니다. native status의 current entry/builder/page size/current rows/last-page와 bounded preview가 authoritative 값이며 asset path는 preview에서 숨깁니다. refresh active/next/last-success/fault도 전달하고 refresh fault는 TAKE OUT 외 mutation 명령을 막습니다. 성공한 TAKE OUT 뒤에는 전용 refresh error marker만 reset하며 다른 unknown latch를 지우지 않습니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 유지됩니다. 브라우저 응답 제한 시간은 검증된 native operation timeout에 5초 전달 여유를 더해 사용하며, 상관 응답이 오지 않으면 strict `ParseTimeoutQuarantine` 요청을 native에 보냅니다. MainWindow는 await 전에 process-lifetime latch를 세우고 vendor session을 quarantine하므로 WebView reload로도 해제되지 않습니다. 명령 진행 중 trusted navigation, reload 또는 WebView2 process failure로 JavaScript 상관관계가 사라지는 경우도 같은 native latch를 먼저 세웁니다. 늦은 응답이나 UI 재시도로 같은 명령을 다시 보내지 않으며 native 명령이 끝나면 authoritative status를 다시 게시합니다. NEXT는 원본의 `m_TakeIn` 조건처럼 on-air 장면이 있을 때만 native와 Web 양쪽에서 허용됩니다. Test on-air 배지는 실제 PROGRAM과 구분해 `TEST ON AIR`로 표시합니다.
On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 종료 및 세션 재활용 경로도 SDK `Disconnect`를 호출하지 않습니다. 중앙 `ReleaseSessionAsync` 방어가 세션을 quarantine/abandon하고 결과를 불명확 상태로 승격하므로, 운영자는 먼저 성공한 `TAKE OUT`을 확인한 다음 정상 종료해야 합니다.
### 화면/계약 구현과 운영 동등성 경계
- PList raw restore와 manual descriptor 계약은 구현됐지만 기존 한국어 `DC_TITLE`/`LIST_TEXT`, GraphE·FSell·VI를 포함한 manual named save→fresh restore는 운영 DB에서 검증되지 않았다.
- ThemeA/EList의 expected identity와 응답 상관 계약은 구현됐지만 KRX/NXT·동명이름·코드 경계의 실제 create/replace/delete read-back은 검증되지 않았다.
- 원본 `종목비교.dat`, FSell, VI 파일을 패키지 private 저장소로 옮기는 one-time import는 구현되지 않았다.
- `s5006``Video\큐브배경.vrv``s6001` 해외지수 국가 영상 13개는 현재 승인 Cuts에 없으며, alias-only `Missing=0`은 asset-ready 증거가 아니다.
- GraphE·PList/AList·ThemeA·EList 운영 DB-W와 `5001`/`5074` 밖의 실제 PGM은 미검증이다. 아래 Round H 기록은 승인된 두 scene의 역사적 증거로만 보존한다.
승인 컷의 과거 load/play 경로와 현재 WebView 기반 Scene/PageN 데이터 동등성은 서로 다른 검증 범위입니다. 복합 mutation·페이지 계산·fresh TAKE IN·Page NEXT·timer refresh는 구현 및 자동/실데이터 검증을 마쳤으며 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)과 [35개 Scene 매트릭스](SCENE_EQUIVALENCE.md)에 기록합니다. 첫 Live WebView 회차 `MBNWEB-20260711-A`의 DB 실패와 Round G의 refresh starvation은 모두 결과를 추정하거나 반복하지 않고 안전하게 종료했습니다. 수정된 Round H에서는 승인된 5001/5074 범위의 실제 TAKE IN, 자동 refresh, playlist/Page NEXT와 TAKE OUT을 Network Monitoring과 PGM에서 함께 완료했습니다. 이 결과를 나머지 scene의 실제 PGM 검증으로 확대하지 않습니다.
일반 앱과 정규 Test 경로의 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode``Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → fresh TAKE IN → Page/playlist NEXT → timer refresh → TAKE OUT` 결과를 관찰합니다. 의도하지 않은 PGM/운영 출력 변화가 보이면 추가 명령을 중단합니다. native 결과가 명확하고 Gate A에 포함된 경우에만 TAKE OUT을 한 번 요청하며, timeout·`OutcomeUnknown`·`WEB_TIMEOUT`·refresh fault라면 앱 종료나 반대 명령으로 자동 롤백하지 않고 session을 quarantine한 채 운영자가 실제 출력 상태를 먼저 확인합니다.

View File

@@ -1,6 +1,6 @@
# MBN_STOCK_WEBVIEW 송출 운영·검증 절차
이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다.
이 문서는 마이그레이션된 WebView → `IPlayoutEngine` → K3D/Tornado2 경로를 검증할 때의 승인, 실행, 감시, 장애 복구와 rollback 기준이다. 기존 [`PLAYOUT.md`](PLAYOUT.md)의 K3D 설치·해시 핀·격리 진단 규칙을 대체하지 않고, 35개 scene과 PageN 동등성 검증에 필요한 운영 절차를 추가한다. 화면/계약 구현과 운영 동등성의 최신 재감사 상태는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
## 현재 상태
@@ -10,9 +10,10 @@
| 자동 테스트 | 최종 전체 재검증에서 Core Debug/Release x64 각각 1,116/1,116, Playout 각각 374/374, Infrastructure 각각 125/125(합계 각 1,615/1,615), Web 235/235 통과; 실패·skip·경고 0건 |
| Visual Studio 2026 | MSBuild `18.7.8.30822` Debug x64와 signed Release x64 패키지 빌드 성공 |
| trusted Release x64 MSIX | `1.0.2.0` 생성·서명 검증·x64 설치·package context 실행 성공. SHA-256 `4A4ED867D16B75C1C82CB55DA5111E0502573F486B8B897889069843D9DF5072`; source Web 24개 일치, 금지 자산 0건, 기본 DryRun과 전체 UI·실제 DB 검색·오류 이벤트 0건 확인. Round H의 실제 DB DryRun/Live workflow 증거는 아래에 별도 보존 |
| 실제 데이터 전체 scene smoke | 34개 도달 가능 builder 통과: 33개 DB + `s5025` trusted 외부 CP949 파일. `s8086` diagnostic 포함 Oracle/MariaDB query 55건 통과 |
| 실제 DB read→DTO→mutation smoke | 34개 도달 가능 data route 통과: 33개 DB + `s5025` trusted 외부 CP949 파일. `s8086` diagnostic 포함 Oracle/MariaDB query 55건 통과. 종속 asset·운영 DB-W·PGM 검증은 포함하지 않음 |
| `s5025` trusted 수동 파일 | 외부 CP949 source 통합 검증 통과; 실제 파일/환경은 계속 Git 밖에서 회차별 preflight |
| `s5006` `Video\큐브배경.vrv` | 현재 승인 Cuts root에서 누락 |
| `s6001` 해외지수 영상 | 국가별 `Video\20201008_<국가>.vrv` 13개가 현재 승인 Cuts root에서 누락; 외부자산 필요 |
| 이번 마이그레이션 WebView workflow의 실제 Tornado2 검증 | **승인된 5001/5074 범위에서 Round H 실제 PGM 완료; 35개 전체의 실제 PGM 검증으로 확대 해석하지 않음** |
과거 고정 `5001 → 5006` runner의 실제 PGM 기록은 연결과 기본 K3D 명령 표면의 증거다. 현재 WebView playlist, fresh TAKE IN, Page NEXT, timer refresh, callback/unload의 동등성 완료 증거는 아니다.
@@ -62,6 +63,14 @@ Round H 전에 Core 790, Infrastructure 65, Playout 374개 테스트를 Debug/Re
이 실제 Live PGM 증거는 회차에 허용된 5001과 5074에 한정된다. 35개 scene builder 전체 동등성은 자동 테스트, 55-query 실데이터 smoke와 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md) 매트릭스로 유지하며, 나머지 scene이 실제 PGM에서 각각 송출됐다는 의미로 기록하지 않는다.
## 화면/계약과 운영 검증의 분리
- 화면·native bridge·DTO·mutation·identity 상관 계약이 존재하고 자동 테스트가 통과한 상태를 화면/계약 구현으로 기록한다.
- PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원과 GraphE·FSell·VI를 포함한 manual named save→fresh restore는 적용 중이며 운영 검증 완료가 아니다.
- ThemeA/EList의 expected identity 계약은 구현됐지만 실제 운영 DB에서 KRX/NXT·동명이름·코드 경계와 정확한 selected-row replace/delete를 확인하지 않았다.
- 원본 `종목비교.dat`, FSell, VI 파일의 one-time private-store import는 미적용이다.
- GraphE·PList/AList·ThemeA·EList의 실제 운영 DB-W와 승인된 `5001`/`5074` 밖의 PGM은 미검증이다. 이 작업은 승인된 테스트 데이터·rollback과 장면별 별도 회차가 있어야 한다.
## 절대 안전 규칙
1. 앱의 기본값은 항상 `DryRun`으로 유지한다.
@@ -82,8 +91,8 @@ Round H 전에 Core 790, Infrastructure 65, Playout 374개 테스트를 Debug/Re
- 원본 test Cuts root: `C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts`
- 실제 `.t2s`, image, texture, video와 기타 scene 자산
- `s5006`의 상대 자산 `Video\큐브배경.vrv`; 현재 위 Cuts root에는 없으므로 제공 전 실제 PREPARE 금지
- `s5025` trusted CP949 수동 파일 디렉터리와 환경 변수 `MBN_STOCK_S5025_MANUAL_DATA_DIRECTORY`
- `s5006`의 상대 자산 `Video\큐브배경.vrv``s6001` 해외지수의 국가별 영상 13개; 현재 위 Cuts root에는 없으므로 제공 전 해당 장면의 실제 PREPARE 금지
- 정상 앱의 FSell/VI private 저장소 `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\OperatorData`. 원본 `Data`를 이 위치로 검증·복사하는 one-time importer는 아직 없으며, runtime factory의 `MBN_STOCK_S5025_MANUAL_DATA_DIRECTORY` fallback을 정상 앱 이전 절차로 사용하지 않음
- Oracle/MariaDB host, SID/service, database, user, password와 운영 query selector
- K3D/Tornado vendor DLL, Interop, license와 설치 경로
- `MBN_STOCK_K3D_NATIVE_SHA256`, `MBN_STOCK_K3D_INTEROP_SHA256` 승인 값과 승인 근거
@@ -91,7 +100,7 @@ Round H 전에 Core 790, Infrastructure 65, Playout 374개 테스트를 Debug/Re
- 실제 Tornado host/port, output channel, PGM 창 정보와 운영 설정
- Network Monitoring/PGM screenshot·영상·manifest 등 실제 방송 증거
Cuts root는 이번 작업에서 읽기 전용으로 취급한다. [`Test-LegacyCutCoverage.ps1`](../scripts/Test-LegacyCutCoverage.ps1) 45개 active alias의 cut 존재 여부만 검사하며 자산을 복사하거나 수정하지 않는다.
Cuts root는 이번 작업에서 읽기 전용으로 취급한다. 현재 작업 트리의 [`Test-LegacyCutCoverage.ps1`](../scripts/Test-LegacyCutCoverage.ps1) 보강은 기존 45개 active alias 결과와 [`LegacyCutRequirements.json`](../scripts/LegacyCutRequirements.json)의 builder 소유 image·texture·video 23개를 함께 검사하는 단계다. 절대 경로, root 탈출, 누락·빈 파일과 root부터 파일까지의 reparse point를 실패로 보고하도록 구성했지만 최종 전체 회귀·패키지 반영 전에는 `적용 중`으로 판정한다. 현재 승인 Cuts에서는 `s6001`의 서로 다른 국가 영상 13개와 `s5006``Video\큐브배경.vrv``Missing`으로 명시되어야 정상이며, 누락 상태에서는 해당 장면의 PREPARE로 진행하지 않는다.
## 승인 게이트
@@ -133,6 +142,7 @@ PREPARE 결과와 Network Monitoring 상태를 운영자가 확인한 후, TAKE
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacySceneBaseline.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacyCutCoverage.ps1 `
-CutRoot "C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts"
powershell -NoProfile -ExecutionPolicy Bypass -File .\tests\Scripts\Test-LegacyCutCoverage.Fixture.ps1
```
2. Debug/Release x64 테스트와 Web bridge 검사를 실행한다.
@@ -156,7 +166,7 @@ PREPARE 결과와 Network Monitoring 상태를 운영자가 확인한 후, TAKE
4. 설치된 trusted Release x64 MSIX가 package context에서 실행되는지 확인한다. `bin` 또는 loose EXE를 실제 검증에 사용하지 않는다.
5. K3D Registry64, AMD64 PE, TypeLib/Interop metadata, 두 SHA-256 pin, license를 확인한다. DLL을 repo 또는 앱 폴더로 복사해 우회하지 않는다.
6. 정확히 하나의 승인 대상 Tornado2 프로세스, PGM 창, Network Server TCP port와 LISTEN 소유권을 확인한다. 매뉴얼 예시 port를 추정해 사용하지 않는다.
7. `s5025`를 선택했다면 trusted 외부 디렉터리와 파일 preflight가 성공해야 한다. `s5006`를 선택했다면 `Video\큐브배경.vrv`가 승인 asset root 안에 있어야 한다.
7. `s5025`를 선택했다면 trusted 외부 디렉터리와 파일 preflight가 성공해야 한다. `s5006` `Video\큐브배경.vrv`, `s6001` 해외지수는 선택 국가에 해당하는 `Video\20201008_<국가>.vrv`가 승인 asset root 안에 있어야 한다. coverage 결과의 `MissingAssets`, `RootEscapes`, `ReparseAssets` 중 하나라도 0이 아니면 해당 장면으로 진행하지 않는다.
8. 실제 COM을 사용하는 Test/Live playlist의 모든 cut이 로컬 폐쇄형 allowlist 안에 있고 selector가 closed enum/lookup 규칙을 통과하는지 DryRun에서 확인한다. allowlist가 비어 있으면 실제 모드 초기화 자체를 거부해야 한다.
9. 로컬 playout 설정의 `legacySceneFadeDuration` 기본값 6과 `legacySceneBackgroundKind`를 확인한다. 공통 background를 쓸 때 `legacySceneBackgroundAssetPath`는 `sceneDirectory` 아래의 승인된 상대 경로여야 한다. `PlayoutSceneCompositionFactory`의 DryRun preflight가 파일 존재, 허용 확장자, root 탈출, 절대 경로와 reparse point를 모두 거부하는지 확인한다. Web에는 이 asset 경로를 보내지 않는다.
10. Web DryRun에서 45개 active alias, row별 `enabled`, PREPARE 뒤 snapshot freeze, current entry/builder/page size/current rows/last-page/preview와 refresh 상태를 확인한다. pending command와 `OutcomeUnknown`/timeout quarantine에서도 playlist 편집이 잠겨야 한다. preview에 image/texture/video 경로가 나타나면 실제 회차를 중단한다.
@@ -307,4 +317,4 @@ Gate A에 포함된 TAKE OUT을 한 번 실행한다. 성공 callback과 PGM 종
7. callback과 unload 순서가 정상이고 Disconnect가 성공했다.
8. DB password, vendor DLL/license, 인증서/private key, 실제 cut/asset, 운영 설정이 Git 변경에 포함되지 않았다.
검증 후 결과를 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md)의 해당 행에 기록한다. 일부 scene 성공이나 과거 runner 성공으로 나머지 행을 일괄 완료 처리하지 않는다.
검증 후 결과를 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md)의 해당 행과 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)의 관련 항목에 기록한다. 일부 scene 성공이나 과거 runner 성공으로 나머지 행을 일괄 완료 처리하지 않는다.

View File

@@ -1,8 +1,8 @@
# 35개 Scene 동등성 완료 매트릭스
# 35개 Scene 동등성 매트릭스
기준 시각은 2026-07-12이다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도 원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는 [`legacy-scene-source-hashes.json`](legacy-scene-source-hashes.json), 원본 구조 기준선 검사는 [`Test-LegacySceneBaseline.ps1`](../scripts/Test-LegacySceneBaseline.ps1)에 있다.
기준 시각은 2026-07-12이다. 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했으며, 이 문서 작업에서도 원본 파일을 수정하지 않았다. 원본 35개 builder의 파일 해시는 [`legacy-scene-source-hashes.json`](legacy-scene-source-hashes.json), 원본 구조 기준선 검사는 [`Test-LegacySceneBaseline.ps1`](../scripts/Test-LegacySceneBaseline.ps1)에 있다. 화면/계약 구현과 운영 동등성의 전체 재감사 판정은 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다.
이 문서에서 **구현 완료**는 DTO → mutation builder, 실제 데이터 loader, playlist selection resolver 또는 명시적 runtime route, 자동 테스트가 모두 존재한다는 뜻이다. **자동 동등성 완료**는 원본 35개 builder의 source hash/inventory 기준선을 고정하고, 원본 구현에서 도출한 오브젝트·mutation·호출 순서·45개 alias·PageN 기대값을 새 구현의 매트릭스와 자동 suite로 검증했다는 뜻이다. 실행 중인 구 구현과 새 구현을 같은 입력으로 직접 호출해 의미를 대조하는 comparator가 있다는 뜻은 아니다. **실제 Tornado 검증**은 승인된 scene회차에만 적용한다. 현재 마이그레이션 기준선은 완료됐지만 실제 PGM 증거를 승인되지 않은 나머지 scene으로 확대 해석하지 않는다.
이 문서에서 **화면/계약 구현 완료**는 DTO → mutation builder, 실제 데이터 loader, playlist selection resolver 또는 명시적 runtime route, 자동 테스트가 모두 존재한다는 뜻이다. **자동 계약 동등성 완료**는 원본 35개 builder의 source hash/inventory 기준선을 고정하고, 원본 구현에서 도출한 오브젝트·mutation·호출 순서·45개 alias·PageN 기대값을 새 구현의 매트릭스와 자동 suite로 검증했다는 뜻이다. 실행 중인 구 구현과 새 구현을 같은 입력으로 직접 호출해 의미를 대조하는 comparator, 종속 asset 준비 또는 실제 PGM 동등성 완료를 뜻하지 않는다. **운영 동등성 검증**은 승인된 asset·scene·회차에만 적용한다. 현재 구현 기준선은 완료됐지만 실제 PGM 증거를 승인되지 않은 나머지 scene으로 확대 해석하지 않는다.
## 현재 판정
@@ -14,7 +14,8 @@
| 자동 테스트 | 완료 | Core 1,116/1,116, Playout 374/374, Infrastructure 125/125가 Debug/Release x64에서 각각 통과했다. 구성별 1,615개, 전체 3,230개이며 실패·skip·경고는 0건이다. Web 235/235와 원본 baseline 35/35도 통과 |
| Visual Studio 2026 빌드 | 완료 | MSBuild `18.7.8.30822`로 Debug x64와 signed Release x64 앱 패키지 빌드 성공 |
| Release x64 MSIX | 완료 | `1.0.2.0` 서명·설치·내용 감사와 package-context 실행 통과. SHA-256 `4A4ED867D16B75C1C82CB55DA5111E0502573F486B8B897889069843D9DF5072`; Web 24개가 source와 일치하고 금지 자산은 0건이며 기본 `DryRun`에서 전체 UI와 실제 DB 종목 검색을 확인. Round H의 승인된 실제 송출 증거는 아래 별도 행과 운영 문서에 보존 |
| 실제 데이터 전체 장면 smoke | 완료 | 34개 도달 가능 builder 전체 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic도 통과했고 실제 Oracle/MariaDB query 55/55가 성공했다. |
| 실제 DB read→DTO→mutation smoke | 완료 | 34개 도달 가능 builder의 data route 통과: 33개 Oracle/MariaDB loader와 `s5025` trusted 외부 CP949 파일. 무alias `s8086` diagnostic도 통과했고 실제 Oracle/MariaDB query 55/55가 성공했다. 종속 asset 또는 PGM 준비 상태는 이 행의 범위가 아니다. |
| 종속 asset preflight | **외부자산 필요 / 검사 보강 적용 중** | 승인 Cuts에서 `s5006``Video\큐브배경.vrv``s6001` 해외지수용 국가별 영상 13개가 없다. 현재 작업 트리에는 45 alias와 builder 소유 자산 23개의 누락·빈 파일·root 탈출·reparse 검사가 반영돼 있지만 최종 전체 회귀·패키지 반영 전에는 완료로 올리지 않는다. |
| 이번 마이그레이션의 실제 Tornado2/PGM | **승인 범위 완료** | 계획 SHA-256 `55C7755C2F874B4B012239117D4AB717BBCE194E7DCC8DA9B0E40D094FF8CA19``MBNWEB-20260711-H`에서 `5001 → 자동 refresh 1회 → 5074 page 1 → page 2 → TAKE OUT`을 PGM과 Network Monitoring으로 함께 확인. 최종 검은 화면, retry 0, `OutcomeUnknown = false` |
Round H의 실제 Network Monitoring 합계는 `SCENE_PREPARE 5001 = 3`, `SCENE_PREPARE 5074 = 2`, `PLAY = 4`, `SCENE_PLAYED = 4`, `FAILURE = 0`, `ERROR = 0`이다. PGM은 5001 데이터, 5074의 page 1과 page 2를 순서대로 렌더링했고 TAKE OUT 뒤 검은 화면이 됐다. 이 증거는 승인된 두 scene에만 유효하다. 과거 실패 회차와 고정 runner는 장애 복구·기본 연결 근거로 보존하되 Round H의 동등성 근거를 대신하거나 확장하지 않는다.
@@ -43,6 +44,7 @@ Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM
- `DB-P`: 실제 Oracle/MariaDB 조회 → typed DTO → mutation preflight가 통과했다.
- `FILE-P`: `s5025`의 trusted 외부 CP949 수동 파일 → DTO → mutation preflight가 통과했다. 실제 파일과 디렉터리는 계속 Git 밖에 둔다.
- `DB-DP`: 원본 MainForm에서 도달하지 않는 `s8086` diagnostic 조회와 mutation preflight가 통과했다. 이 결과로 runtime alias를 만들지는 않는다.
- `ASSET-X`: builder가 요구하는 외부 asset이 승인 Cuts에 없거나 강화된 preflight가 끝나지 않아 실제 PREPARE/PGM 준비 상태가 아니다.
- `TOR-W`: 이번 마이그레이션 runtime으로 해당 scene을 실제 Tornado2/PGM에서 검증하지 않았다. 회차 승인 전에는 실행하지 않는다.
- `TOR-H`: Round H의 승인된 WebView Live 회차에서 PGM 화면과 Network Monitoring을 함께 검증했다.
- `TOR-NA`: active alias가 없어 운영 송출 대상이 아니다.
@@ -95,7 +97,7 @@ Mutation 약어는 실제 COM 메서드를 Web 입력에 노출하지 않는 COM
| `s50860` / `50860` / — | 국내·해외 지수/종목/업종 line 시계열과 path-shape | `S50860SceneMutationBuilder``S50860SceneDataLoader``ComparisonAndYieldLegacyRequestResolver` | `V, Vis, C, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` |
| `s5087` / `5087` / — | 두 국내 종목 candle·수익률 비교와 두 line shape | `S5087SceneMutationBuilder``S5087SceneDataLoader``ComparisonAndYieldLegacyRequestResolver` | `V, Vis, Pos, Shape` | `T-COMP` 통과 | `DB-P` | `TOR-W` |
| `s5088` / `5088` / 12 | Oracle/MariaDB/DataManager 계열의 최대 12행 시세·수익률 목록 | `S5088SceneMutationBuilder``S5088SceneDataLoader` → typed paged 직접 route | `V, Vis, A` | `T-PAGED` 통과 | `DB-P` | `TOR-W` |
| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder``S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과 | `DB-P` | `TOR-W` |
| `s6001` / `6001` / — | 해외지수와 유가·금 단일 plate, 연계 이미지·영상/방향 상태 | `S6001SceneMutationBuilder``S6001SceneDataLoader` → closed target 직접 route | `V, A, Vis, C` | `T-PANEL` 통과; asset manifest fixture는 작업 트리 적용 중 | `DB-P`; 해외지수 국가별 영상 13개 `ASSET-X` | `TOR-W`; 자산 제공 전 PREPARE 금지 |
| `s6067` / `6067` / — | 기관 순매수 grid와 부호 색상 | `S6067SceneMutationBuilder``S6067SceneDataLoader``LegacyGridMarketSceneRequestResolver` | `V, C` | `T-GRID`, `T-FOUND` 통과 | `DB-P` | `TOR-W` |
| `s8001` / `8001`, `8002` / — | 코스피·코스닥 업종 square chart와 cube 색상 | `S8001SceneMutationBuilder``S8001SceneDataLoader``LegacyParameterizedSceneRequestResolver` | `V, C` | `T-PARAM` 통과 | `DB-P` | `TOR-W` |
| `s8003` / `8003` / — | 국내 종목 호가·시세와 매수·매도 잔량 막대 | `S8003SceneMutationBuilder``S8003SceneDataLoader``LegacyGridMarketSceneRequestResolver` | `V, Vis, C, Scale` | `T-GRID` 통과 | `DB-P` | `TOR-W` |
@@ -158,13 +160,13 @@ TAKE IN은 PREPARE 때의 오래된 DTO를 그대로 재생하지 않는다. 원
운영·장애 복구·승인 절차는 [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md)를 따른다.
## 완료 판정과 실제 송출 범위
## 구현 판정과 실제 송출 범위
35개 builder, 45개 active alias, DTO/mutation, 실제 DB 55-query, 5·6·12행 PageN 경계와 마지막 페이지, Debug/Release x64 suite와 packaged `DryRun`완료 기준을 충족했다. Round H는 설치된 Release x64 MSIX의 WebView workflow로 승인된 `5001``5074`에 대해 PREPARE → fresh TAKE IN → 자동 refresh → playlist NEXT → Page NEXT → TAKE OUT을 실제 Tornado2에서 완료했다.
35개 builder, 45개 active alias, DTO/mutation, 실제 DB read 55-query, 5·6·12행 PageN 경계와 마지막 페이지, Debug/Release x64 suite와 packaged `DryRun`화면/계약 구현 기준을 충족했다. 이는 종속 asset이나 35개 전체의 운영 동등성 완료 판정이 아니다. Round H는 설치된 Release x64 MSIX의 WebView workflow로 승인된 `5001``5074`에 대해서만 PREPARE → fresh TAKE IN → 자동 refresh → playlist NEXT → Page NEXT → TAKE OUT을 실제 Tornado2에서 완료했다.
이 완료 판정에는 다음 범위 제한이 있다.
1. 실제 Tornado2/PGM 화면 증거는 `5001``5074` page 1/page 2뿐이다. 다른 builder의 실제 송출은 해당 scene과 자산을 묶은 새 계획·승인이 필요하다.
2. `s5025`는 승인된 trusted 외부 CP949 파일이 필수이며 파일·경로는 Git과 MSIX에 포함하지 않는다.
3. `s5006`의 background video 등 외부 asset이 필요한 scene은 승인된 asset root가 준비돼야 실제 송출할 수 있다.
3. `s5006`의 background video`s6001` 해외지수 국가 영상 13개 등 외부 asset이 필요한 scene은 승인된 asset root와 강화된 preflight가 준비돼야 실제 송출할 수 있다.
4. timeout, `OutcomeUnknown`, refresh fault 또는 callback/연결 장애가 발생한 회차에서는 동일 명령을 반복하지 않고 [`PLAYOUT_OPERATIONS.md`](PLAYOUT_OPERATIONS.md)의 복구 절차를 적용한다.

View File

@@ -0,0 +1,59 @@
# 원본 F2/F3 배경과 신뢰 배경 루트
검수 기준 원본은 읽기 전용
`C:\Users\MD\source\repos\MBN_STOCK_N\MBN_STOCK_N\MainForm.cs`이다.
## 원본 동작
- `T2S_FOLDER_PATH`는 실행 폴더의 `Cuts\`이다.
- F2/`btnback_Click`는 먼저 `Cuts\..\배경\기본.vrv`를 기본값으로 두고,
`배경` 폴더에서 vrv/jpg/png 파일 선택 창을 연다.
- F3/`ckback_CheckedChanged`가 배경을 끄면 파일 값을 비우고, 다시 켜면
항상 `배경\기본.vrv`로 복원한다.
- 변경된 값은 이후 장면 PREPARE의 공통 배경 mutation에 사용된다.
## 새 구현
`SceneDirectory`(Cuts)는 기존 장면과 장면 내부 자산만 계속 담당한다. 운영자
F2/F3 배경은 별도 `LegacyBackgroundDirectory`에서만 해석하며 두 allowlist
root를 합치거나 서로 대신 사용하지 않는다.
```json
{
"sceneDirectory": "D:\\Tornado\\Cuts",
"legacyBackgroundDirectory": "D:\\Tornado\\배경",
"legacySceneBackgroundKind": "None",
"legacySceneBackgroundAssetPath": null
}
```
`legacyBackgroundDirectory`를 생략하면 원본 배치와 동일하게
`SceneDirectory\..\배경`을 계산한다. 설정 파일과 환경 변수
`MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY` 중 환경 변수가 우선한다.
배경 파일 경로는 Web 메시지로 입력받지 않으며 네이티브 F2 picker 결과만 받는다.
F2/F3는 IDLE에서만 변경할 수 있고 변경은 다음 PREPARE부터 적용된다. F2는
원본처럼 picker를 열기 전에 `기본.vrv` 복원을 시도하지만, 그 파일이 신뢰 검증을
통과하지 못하면 재생 가능한 mutation으로 만들지 않는다. F3로 다시 켤 때도
이전 사용자 선택 파일이 아니라 원본과 같은 `기본.vrv`를 새로 검증한다.
## Fail-closed 검증
배경 root와 파일은 사용할 때마다 다음 조건을 모두 만족해야 한다.
- 절대 root가 실제 디렉터리이며 root와 모든 조상 디렉터리가 reparse point가 아님
- 상대 파일이 root 내부에 있고 허용 확장자이며 누락되지 않음
- 열린 파일 handle이 가리키는 최종 canonical path가 검사한 경로와 동일함
- 열린 대상이 directory/reparse point가 아닌 non-empty regular file임
- hard link 수가 정확히 1임
- 검사 중 디렉터리 조상과 파일 handle은 delete/rename 공유를 허용하지 않음
root나 파일이 없거나 검증 결과가 불명확하면 경로를 Web/오류에 노출하지 않고
F2/F3 또는 PREPARE를 거부한다. Cuts root로의 fallback은 없다.
## 현재 자산 확인
2026-07-12에 원본 `bin\Debug\Cuts`의 형제 `bin\Debug\배경`
`배경\기본.vrv`를 확인했으나 둘 다 존재하지 않았다. 따라서 자산이 배치되기
전까지 현재 환경의 기본 배경은 의도대로 fail closed 상태다. 실제 vrv 파일은
Git/MSIX에 포함하지 않는다.

View File

@@ -0,0 +1,245 @@
#Requires -Version 5.1
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Get-RootPrefix {
param(
[Parameter(Mandatory = $true)]
[string] $Root
)
if ($Root.EndsWith([IO.Path]::DirectorySeparatorChar) -or
$Root.EndsWith([IO.Path]::AltDirectorySeparatorChar)) {
return $Root
}
return $Root + [IO.Path]::DirectorySeparatorChar
}
function Test-IsUnderRoot {
param(
[Parameter(Mandatory = $true)]
[string] $Root,
[Parameter(Mandatory = $true)]
[string] $Candidate
)
$prefix = Get-RootPrefix -Root $Root
return $Candidate.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)
}
function Find-ReparseAncestor {
param(
[Parameter(Mandatory = $true)]
[string] $Root,
[Parameter(Mandatory = $true)]
[string] $Candidate
)
$rootItem = Get-Item -LiteralPath $Root -Force
if (($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
return '.'
}
$prefix = Get-RootPrefix -Root $Root
$relative = $Candidate.Substring($prefix.Length)
$current = $Root
foreach ($part in ($relative -split '[\\/]' | Where-Object { $_.Length -ne 0 })) {
$current = Join-Path $current $part
if (-not (Test-Path -LiteralPath $current)) {
break
}
$item = Get-Item -LiteralPath $current -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
return $part
}
}
return $null
}
function Resolve-CoveragePath {
param(
[Parameter(Mandatory = $true)]
[string] $Root,
[Parameter(Mandatory = $true)]
[string] $RelativePath
)
if ([string]::IsNullOrWhiteSpace($RelativePath) -or
[IO.Path]::IsPathRooted($RelativePath)) {
return [pscustomobject][ordered]@{
Status = 'RootEscape'
FullPath = $null
}
}
try {
$candidate = [IO.Path]::GetFullPath((Join-Path $Root $RelativePath))
}
catch {
return [pscustomobject][ordered]@{
Status = 'ManifestInvalid'
FullPath = $null
}
}
if (-not (Test-IsUnderRoot -Root $Root -Candidate $candidate)) {
return [pscustomobject][ordered]@{
Status = 'RootEscape'
FullPath = $null
}
}
$reparse = Find-ReparseAncestor -Root $Root -Candidate $candidate
if (-not [string]::IsNullOrEmpty($reparse)) {
return [pscustomobject][ordered]@{
Status = 'Reparse'
FullPath = $null
}
}
return [pscustomobject][ordered]@{
Status = 'Resolved'
FullPath = $candidate
}
}
function Test-AssetDefinition {
param(
[Parameter(Mandatory = $true)]
[string] $Kind,
[Parameter(Mandatory = $true)]
[string] $RelativePath
)
$allowedExtensions = switch ($Kind) {
'Image' { @('.bmp', '.jpeg', '.jpg', '.png', '.tga') }
'Texture' { @('.bmp', '.jpeg', '.jpg', '.png', '.tga', '.vrv') }
'Video' { @('.vrv') }
default { return $false }
}
$extension = [IO.Path]::GetExtension($RelativePath)
return $allowedExtensions -contains $extension.ToLowerInvariant()
}
function Test-LegacyCutCoverageState {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $CutRoot,
[Parameter(Mandatory = $true)]
[string[]] $Aliases,
[Parameter(Mandatory = $true)]
[object[]] $AssetRequirements,
[Parameter(Mandatory = $true)]
[int] $ReachableBuilders
)
if (-not [IO.Path]::IsPathRooted($CutRoot)) {
throw 'CutRoot must be an absolute path.'
}
$root = [IO.Path]::GetFullPath($CutRoot)
if (-not (Test-Path -LiteralPath $root -PathType Container)) {
throw 'CutRoot does not identify an existing directory.'
}
$aliasFindings = [Collections.Generic.List[object]]::new()
foreach ($alias in $Aliases) {
$relativePath = $alias + '.t2s'
$resolved = Resolve-CoveragePath -Root $root -RelativePath $relativePath
$status = $resolved.Status
if ($status -eq 'Resolved') {
if (-not (Test-Path -LiteralPath $resolved.FullPath -PathType Leaf)) {
$status = 'Missing'
}
else {
$item = Get-Item -LiteralPath $resolved.FullPath -Force
$status = if ($item.Length -le 0) { 'Empty' } else { 'Present' }
}
}
$aliasFindings.Add([pscustomobject][ordered]@{
Alias = $alias
RelativePath = $relativePath
Status = $status
})
}
$assetFindings = [Collections.Generic.List[object]]::new()
foreach ($requirement in $AssetRequirements) {
$builder = [string] $requirement.Builder
$kind = [string] $requirement.Kind
$relativePath = [string] $requirement.Path
$status = 'ManifestInvalid'
if (-not [string]::IsNullOrWhiteSpace($builder) -and
-not [string]::IsNullOrWhiteSpace($relativePath) -and
(Test-AssetDefinition -Kind $kind -RelativePath $relativePath)) {
$resolved = Resolve-CoveragePath -Root $root -RelativePath $relativePath
$status = $resolved.Status
if ($status -eq 'Resolved') {
if (-not (Test-Path -LiteralPath $resolved.FullPath -PathType Leaf)) {
$status = 'Missing'
}
else {
$item = Get-Item -LiteralPath $resolved.FullPath -Force
$status = if ($item.Length -le 0) { 'Empty' } else { 'Present' }
}
}
}
$assetFindings.Add([pscustomobject][ordered]@{
Builder = $builder
Kind = $kind
RelativePath = $relativePath
Status = $status
})
}
$missingAliases = @($aliasFindings | Where-Object Status -eq 'Missing')
$unsafeAliases = @($aliasFindings | Where-Object {
$_.Status -ne 'Present' -and $_.Status -ne 'Missing'
})
$missingAssets = @($assetFindings | Where-Object Status -eq 'Missing')
$unsafeAssets = @($assetFindings | Where-Object {
$_.Status -ne 'Present' -and $_.Status -ne 'Missing'
})
$rootEscapes = @($assetFindings | Where-Object Status -eq 'RootEscape')
$reparseAssets = @($assetFindings | Where-Object Status -eq 'Reparse')
$invalidAssets = @($assetFindings | Where-Object Status -eq 'ManifestInvalid')
$failed = $missingAliases.Count -ne 0 -or
$unsafeAliases.Count -ne 0 -or
$missingAssets.Count -ne 0 -or
$unsafeAssets.Count -ne 0
return [pscustomobject][ordered]@{
Status = if ($failed) { 'Failed' } else { 'Passed' }
ReachableBuilders = $ReachableBuilders
ActiveAliases = $Aliases.Count
Missing = $missingAliases.Count
Unsafe = $unsafeAliases.Count
RequiredAssets = $AssetRequirements.Count
MissingAssets = $missingAssets.Count
UnsafeAssets = $unsafeAssets.Count
RootEscapes = $rootEscapes.Count
ReparseAssets = $reparseAssets.Count
InvalidAssetDefinitions = $invalidAssets.Count
AliasFindings = $aliasFindings.ToArray()
AssetFindings = $assetFindings.ToArray()
}
}
Export-ModuleMember -Function Test-LegacyCutCoverageState

View File

@@ -0,0 +1,168 @@
{
"schemaVersion": 1,
"reachableBuilders": 34,
"activeAliases": [
"5001",
"N5001",
"5006",
"5011",
"5016",
"50160",
"5023",
"5024",
"5025",
"5026",
"5029",
"8018",
"8032",
"5032",
"5037",
"5074",
"5076",
"5077",
"5078",
"5079",
"5080",
"5081",
"5082",
"5083",
"5084",
"5085",
"5086",
"50860",
"5087",
"5088",
"6001",
"6067",
"8001",
"8002",
"8003",
"8035",
"8061",
"8040",
"8046",
"8051",
"8056",
"8067",
"5068",
"5070",
"5072"
],
"assets": [
{
"builder": "s5006",
"kind": "Video",
"path": "Video\\큐브배경.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_미국.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_독일.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_영국.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_프랑스.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_일본.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_중국.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_홍콩.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_대만.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_싱가포르.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_태국.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_필리핀.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_말레이시아.vrv"
},
{
"builder": "s6001",
"kind": "Video",
"path": "Video\\20201008_인도네시아.vrv"
},
{
"builder": "s6001",
"kind": "Image",
"path": "images\\주유기merge.png"
},
{
"builder": "s6001",
"kind": "Image",
"path": "images\\35752913_l.jpg"
},
{
"builder": "shared-price-direction",
"kind": "Texture",
"path": "Images\\그림_빨강.png"
},
{
"builder": "shared-price-direction",
"kind": "Texture",
"path": "Images\\그림_검정.png"
},
{
"builder": "shared-price-direction",
"kind": "Texture",
"path": "Images\\그림_파랑.png"
},
{
"builder": "s5001/s5074",
"kind": "Image",
"path": "Images\\프리마켓.png"
},
{
"builder": "s5001/s5074",
"kind": "Image",
"path": "Images\\애프터마켓.png"
},
{
"builder": "s8018",
"kind": "Image",
"path": "Images\\KRX.png"
},
{
"builder": "s8018",
"kind": "Image",
"path": "Images\\NXT.png"
}
]
}

View File

@@ -9,52 +9,58 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not [IO.Path]::IsPathRooted($CutRoot)) {
throw 'CutRoot must be an absolute path.'
$modulePath = Join-Path $PSScriptRoot 'LegacyCutCoverage.psm1'
$manifestPath = Join-Path $PSScriptRoot 'LegacyCutRequirements.json'
Import-Module -Name $modulePath -Force
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
throw 'The closed legacy cut requirement manifest is missing.'
}
$root = [IO.Path]::GetFullPath($CutRoot)
if (-not (Test-Path -LiteralPath $root -PathType Container)) {
throw 'CutRoot does not identify an existing directory.'
$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($manifest.schemaVersion -ne 1 -or
$manifest.reachableBuilders -le 0 -or
@($manifest.activeAliases).Count -eq 0 -or
@($manifest.assets).Count -eq 0) {
throw 'The closed legacy cut requirement manifest is invalid.'
}
# Active MainForm aliases for the 34 reachable builders. s8086 intentionally has no alias.
$aliases = @(
'5001', 'N5001', '5006', '5011', '5016', '50160',
'5023', '5024', '5025', '5026', '5029',
'8018', '8032', '5032', '5037',
'5074', '5076', '5077', '5078', '5079',
'5080', '5081', '5082', '5083', '5084', '5085',
'5086', '50860', '5087', '5088',
'6001', '6067', '8001', '8002', '8003',
'8035', '8061', '8040', '8046', '8051', '8056',
'8067', '5068', '5070', '5072'
)
$report = Test-LegacyCutCoverageState `
-CutRoot $CutRoot `
-Aliases @($manifest.activeAliases) `
-AssetRequirements @($manifest.assets) `
-ReachableBuilders $manifest.reachableBuilders
$missing = [Collections.Generic.List[string]]::new()
$unsafe = [Collections.Generic.List[string]]::new()
foreach ($alias in $aliases) {
$path = Join-Path $root ($alias + '.t2s')
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
$missing.Add($alias)
continue
}
$item = Get-Item -LiteralPath $path -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or
$item.Length -le 0) {
$unsafe.Add($alias)
}
$failedAliases = @($report.AliasFindings | Where-Object Status -ne 'Present')
$failedAssets = @($report.AssetFindings | Where-Object Status -ne 'Present')
foreach ($finding in $failedAliases) {
Write-Warning ("CutAlias {0} ({1}) => {2}" -f
$finding.Alias,
$finding.RelativePath,
$finding.Status)
}
foreach ($finding in $failedAssets) {
Write-Warning ("BuilderAsset {0} {1} ({2}) => {3}" -f
$finding.Builder,
$finding.Kind,
$finding.RelativePath,
$finding.Status)
}
if ($missing.Count -ne 0 -or $unsafe.Count -ne 0) {
throw "Legacy cut coverage failed. Missing=$($missing.Count), Unsafe=$($unsafe.Count)."
}
[pscustomobject][ordered]@{
Status = 'Passed'
ReachableBuilders = 34
ActiveAliases = $aliases.Count
Missing = $missing.Count
Unsafe = $unsafe.Count
$report
if ($report.Status -ne 'Passed') {
$assetSummary = @($failedAssets | ForEach-Object {
"{0}|{1}|{2}|{3}" -f $_.Builder, $_.Kind, $_.RelativePath, $_.Status
}) -join '; '
$messageFormat = "Legacy cut coverage failed. Missing={0}, Unsafe={1}, " +
"MissingAssets={2}, UnsafeAssets={3}, RootEscapes={4}, " +
"ReparseAssets={5}. AssetFindings=[{6}]"
throw ($messageFormat -f
$report.Missing,
$report.Unsafe,
$report.MissingAssets,
$report.UnsafeAssets,
$report.RootEscapes,
$report.ReparseAssets,
$assetSummary)
}

View File

@@ -45,10 +45,12 @@ $node = Resolve-NodePath -ExplicitPath $NodePath
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
$scripts = @(
(Join-Path $repositoryRoot 'Web\playout-safety.js'),
(Join-Path $repositoryRoot 'Web\market-nav-reorder.js'),
(Join-Path $repositoryRoot 'Web\operator-workflow.js'),
(Join-Path $repositoryRoot 'Web\legacy-fixed-workflow.js'),
(Join-Path $repositoryRoot 'Web\industry-workflow.js'),
(Join-Path $repositoryRoot 'Web\comparison-workflow.js'),
(Join-Path $repositoryRoot 'Web\comparison-import-workflow.js'),
(Join-Path $repositoryRoot 'Web\theme-workflow.js'),
(Join-Path $repositoryRoot 'Web\overseas-workflow.js'),
(Join-Path $repositoryRoot 'Web\expert-workflow.js'),
@@ -57,6 +59,10 @@ $scripts = @(
(Join-Path $repositoryRoot 'Web\manual-lists-workflow.js'),
(Join-Path $repositoryRoot 'Web\manual-financial-workflow.js'),
(Join-Path $repositoryRoot 'Web\named-manual-restore-workflow.js'),
(Join-Path $repositoryRoot 'Web\named-comparison-restore-workflow.js'),
(Join-Path $repositoryRoot 'Web\legacy-foreign-index-candle-workflow.js'),
(Join-Path $repositoryRoot 'Web\named-nxt-theme-restore-workflow.js'),
(Join-Path $repositoryRoot 'Web\legacy-named-row-workflow.js'),
(Join-Path $repositoryRoot 'Web\operator-catalog-workflow.js'),
(Join-Path $repositoryRoot 'Web\manual-lists-ui.js'),
(Join-Path $repositoryRoot 'Web\manual-financial-ui.js'),
@@ -157,12 +163,17 @@ if ($indexMarkup -notmatch 'id="globalMa5"' -or
throw 'The original global 5-day/20-day candle options are not synchronized across every candle workflow.'
}
foreach ($asset in @(
'manual-lists-workflow.js', 'manual-financial-workflow.js', 'named-manual-restore-workflow.js', 'operator-catalog-workflow.js',
'market-nav-reorder.js', 'manual-lists-workflow.js', 'manual-financial-workflow.js', 'named-manual-restore-workflow.js', 'legacy-foreign-index-candle-workflow.js', 'named-nxt-theme-restore-workflow.js', 'legacy-named-row-workflow.js', 'operator-catalog-workflow.js',
'manual-lists-ui.js', 'manual-financial-ui.js', 'operator-catalog-ui.js')) {
if ($indexMarkup -notmatch [regex]::Escape("<script src=`"$asset`"></script>")) {
throw "Operator UI script is not loaded: $asset"
}
}
if ($appScript -notmatch 'MbnMarketNavReorder' -or
$appScript -notmatch [regex]::Escape('marketNavReorder.createController') -or
$appScript -notmatch [regex]::Escape('hasOpenOperatorModal() || isPlaylistSnapshotLocked()')) {
throw 'The original business-tab drag and keyboard reorder contract is not connected.'
}
foreach ($asset in @('manual-lists-ui.css', 'manual-financial-ui.css', 'operator-catalog-ui.css')) {
if ($indexMarkup -notmatch [regex]::Escape("<link rel=`"stylesheet`" href=`"$asset`">") ) {
throw "Operator UI stylesheet is not loaded: $asset"
@@ -186,7 +197,9 @@ if ($appScript -notmatch 'requestBackgroundSelection' -or
$mainWindowScript -notmatch 'case "choose-background"' -or
$mainWindowScript -notmatch 'case "toggle-background"' -or
$playoutMainScript -notmatch 'MutableLegacySceneCueCompositionOptionsSource' -or
$backgroundScript -notmatch 'OperatorBackgroundExtensions') {
$backgroundScript -notmatch 'CreateOperatorSelection' -or
$backgroundScript -notmatch 'CreateLegacyDefault' -or
$backgroundScript -notmatch 'TryGetTrustedBackgroundDirectory') {
throw 'Legacy F2/F3 native background selection integration is incomplete.'
}
if ($appScript -notmatch 'restoreFixedPlaylistEntry' -or
@@ -263,7 +276,7 @@ if ($appScript -notmatch 'MbnNamedPlaylistWorkflow' -or
$appScript -notmatch 'requestNamedPlaylistReplace' -or
$appScript -notmatch 'requestNamedPlaylistDelete' -or
$appScript -notmatch 'namedPlaylistPrepareBlockers' -or
$appScript -notmatch 'restoreRawRows\(load, resolveNamedPlaylistRawRow\)' -or
$appScript -notmatch 'restoreRawRows\([\s\S]*load,[\s\S]*resolveNamedPlaylistRawRow,[\s\S]*legacyNamedRowWorkflow\.matchesCanonicalEntry' -or
$appScript -notmatch 'requestNamedPlaylistPagePlans' -or
$appScript -notmatch 'normalizePagePlanResponse\(payload, pending\.request\)' -or
$appScript -notmatch 'page-result-empty' -or
@@ -310,6 +323,12 @@ if ($mainWindowScript -notmatch 'OnNavigationStarting' -or
throw 'Reload, navigation, and WebView process failure must quarantine in-flight playout correlation.'
}
$cutCoverageFixturePath = Join-Path $PSScriptRoot '..\tests\Scripts\Test-LegacyCutCoverage.Fixture.ps1'
$cutCoverageFixture = & $cutCoverageFixturePath
if ($cutCoverageFixture.Status -ne 'Passed') {
throw 'Legacy cut and builder asset coverage fixtures failed.'
}
& $node --test @testFiles
if ($LASTEXITCODE -ne 0) {
throw "Web playout tests failed with exit code $LASTEXITCODE."
@@ -321,4 +340,5 @@ if ($LASTEXITCODE -ne 0) {
SceneCatalogEntries = $sceneCatalogMatches.Count
ReachableSelectionPresets = $selectionPresetMatches.Count
TestFiles = $testFiles.Count
CutCoverageFixture = $cutCoverageFixture.Status
}

View File

@@ -0,0 +1,931 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace MMoneyCoderSharp.Data;
public enum LegacyComparisonImportFailure
{
SourceUnavailable,
SourceInvalid,
IdentityNotFound,
IdentityAmbiguous,
DatabaseDataInvalid
}
public sealed class LegacyComparisonImportException : Exception
{
public LegacyComparisonImportException(
LegacyComparisonImportFailure failure,
string message,
Exception? innerException = null)
: base(message, innerException)
{
Failure = failure;
}
public LegacyComparisonImportFailure Failure { get; }
}
public enum LegacyComparisonImportTargetKind
{
MarketTarget,
KrxStock,
NxtStock,
WorldStock
}
public sealed record LegacyComparisonImportTarget(
LegacyComparisonImportTargetKind Kind,
string? MarketTarget = null,
string? Market = null,
string? StockName = null,
string? StockCode = null,
string? InputName = null,
string? Symbol = null,
string? Nation = null);
public sealed record LegacyComparisonImportPair(
int RowNumber,
LegacyComparisonImportTarget First,
LegacyComparisonImportTarget Second);
public sealed record LegacyComparisonImportResult(
string SourceSha256,
int SourceRowCount,
DateTimeOffset RetrievedAt,
IReadOnlyList<LegacyComparisonImportPair> Pairs);
public sealed record LegacyComparisonSourceSnapshot(
byte[] Bytes,
string Sha256);
public interface ILegacyComparisonFileSource
{
Task<LegacyComparisonSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default);
}
/// <summary>
/// Reads only the original application's fixed comparison-pair file. The Web
/// request never supplies a path. The file is opened read-only, bounded, and
/// rejected when the fixed path traverses a reparse point.
/// </summary>
public sealed class TrustedLegacyComparisonFileSource : ILegacyComparisonFileSource
{
public const int MaximumFileBytes = 128 * 1024;
public const string FixedRelativePath =
@"MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Data\종목비교.dat";
private readonly string _repositoriesRoot;
private readonly string _sourcePath;
public TrustedLegacyComparisonFileSource(string repositoriesRoot)
{
if (string.IsNullOrWhiteSpace(repositoriesRoot) ||
!Path.IsPathFullyQualified(repositoriesRoot))
{
throw new ArgumentException(
"An absolute repositories root is required.",
nameof(repositoriesRoot));
}
_repositoriesRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(repositoriesRoot));
_sourcePath = Path.GetFullPath(
Path.Combine(_repositoriesRoot, FixedRelativePath));
var prefix = _repositoriesRoot + Path.DirectorySeparatorChar;
if (!_sourcePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
"The fixed comparison source path escapes the repositories root.",
nameof(repositoriesRoot));
}
}
public static TrustedLegacyComparisonFileSource CreateDefault()
{
var profile = Environment.GetFolderPath(
Environment.SpecialFolder.UserProfile,
Environment.SpecialFolderOption.DoNotVerify);
if (string.IsNullOrWhiteSpace(profile))
{
throw new LegacyComparisonImportException(
LegacyComparisonImportFailure.SourceUnavailable,
"The user profile path is unavailable.");
}
return new TrustedLegacyComparisonFileSource(
Path.Combine(profile, "source", "repos"));
}
public string SourcePath => _sourcePath;
public async Task<LegacyComparisonSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var first = await ReadCandidateAsync(cancellationToken).ConfigureAwait(false);
// MainForm.WriteListFile closes and reopens the file for every row.
// Do not block that writer; instead require two identical, quiescent
// snapshots before parsing so a valid-looking prefix is never copied.
await Task.Delay(TimeSpan.FromMilliseconds(150), cancellationToken)
.ConfigureAwait(false);
var second = await ReadCandidateAsync(cancellationToken).ConfigureAwait(false);
if (first.LastWriteTimeUtc != second.LastWriteTimeUtc ||
!first.Bytes.AsSpan().SequenceEqual(second.Bytes))
{
throw SourceUnavailable();
}
return new LegacyComparisonSourceSnapshot(
second.Bytes,
Convert.ToHexString(SHA256.HashData(second.Bytes)));
}
catch (OperationCanceledException)
{
throw;
}
catch (LegacyComparisonImportException)
{
throw;
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or
System.Security.SecurityException or NotSupportedException)
{
throw SourceUnavailable(exception);
}
}
private async Task<SourceCandidate> ReadCandidateAsync(
CancellationToken cancellationToken)
{
ValidateFixedPath();
var before = new FileInfo(_sourcePath);
if (!before.Exists || before.Length is <= 0 or > MaximumFileBytes)
{
throw SourceUnavailable();
}
byte[] bytes;
await using (var stream = new FileStream(
_sourcePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
if (stream.Length != before.Length ||
stream.Length is <= 0 or > MaximumFileBytes)
{
throw SourceUnavailable();
}
EnsureOpenedFileIsTrusted(stream.SafeFileHandle);
bytes = new byte[checked((int)stream.Length)];
await stream.ReadExactlyAsync(bytes, cancellationToken).ConfigureAwait(false);
}
cancellationToken.ThrowIfCancellationRequested();
ValidateFixedPath();
var after = new FileInfo(_sourcePath);
if (!after.Exists || after.Length != bytes.Length ||
after.LastWriteTimeUtc != before.LastWriteTimeUtc)
{
throw SourceUnavailable();
}
return new SourceCandidate(bytes, after.LastWriteTimeUtc);
}
private void ValidateFixedPath()
{
var file = new FileInfo(_sourcePath);
if (file.Exists && (file.Attributes & FileAttributes.ReparsePoint) != 0)
{
throw new LegacyComparisonImportException(
LegacyComparisonImportFailure.SourceUnavailable,
"The fixed comparison source path contains a reparse point.");
}
DirectoryInfo? current = file.Directory;
while (current is not null)
{
if (current.Exists &&
(current.Attributes & FileAttributes.ReparsePoint) != 0)
{
throw new LegacyComparisonImportException(
LegacyComparisonImportFailure.SourceUnavailable,
"The fixed comparison source path contains a reparse point.");
}
var fullName = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(current.FullName));
if (string.Equals(
fullName,
_repositoriesRoot,
StringComparison.OrdinalIgnoreCase))
{
return;
}
current = current.Parent;
}
throw SourceUnavailable();
}
private static LegacyComparisonImportException SourceUnavailable(
Exception? innerException = null) =>
new(
LegacyComparisonImportFailure.SourceUnavailable,
"The fixed legacy comparison source is unavailable.",
innerException);
private void EnsureOpenedFileIsTrusted(SafeFileHandle handle)
{
if (!OperatingSystem.IsWindows() ||
!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes &
(FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
information.NumberOfLinks != 1)
{
throw SourceUnavailable();
}
var length = ((long)information.FileSizeHigh << 32) | information.FileSizeLow;
if (length is <= 0 or > MaximumFileBytes ||
!string.Equals(
GetFinalPath(handle),
Path.GetFullPath(_sourcePath),
StringComparison.OrdinalIgnoreCase))
{
throw SourceUnavailable();
}
}
private static string GetFinalPath(SafeFileHandle handle)
{
var capacity = 512;
while (capacity <= 32_768)
{
var buffer = new StringBuilder(capacity);
var length = GetFinalPathNameByHandleW(
handle,
buffer,
(uint)buffer.Capacity,
0);
if (length == 0)
{
throw SourceUnavailable();
}
if (length < buffer.Capacity)
{
var path = buffer.ToString();
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
{
path = "\\\\" + path[8..];
}
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
{
path = path[4..];
}
return Path.GetFullPath(path);
}
capacity = checked((int)length + 1);
}
throw SourceUnavailable();
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file,
out ByHandleFileInformation information);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle file,
StringBuilder path,
uint pathLength,
uint flags);
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public FileAttributes FileAttributes;
public FileTime CreationTime;
public FileTime LastAccessTime;
public FileTime LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
}
private sealed record SourceCandidate(byte[] Bytes, DateTime LastWriteTimeUtc);
}
public sealed record LegacyComparisonFileTarget(string Name, string Market);
public sealed record LegacyComparisonFileRow(
int RowNumber,
LegacyComparisonFileTarget First,
LegacyComparisonFileTarget Second);
/// <summary>
/// Strict decoder for MainForm.WriteListFile's active UC3 shape: CP949,
/// eight FarPoint columns plus the trailing delimiter (exactly nine fields).
/// </summary>
public static class LegacyComparisonFileParser
{
public const int MaximumRows = 500;
public const int ExactFieldCount = 9;
public const int MaximumTargetNameLength = 200;
private static readonly HashSet<string> Markets = new(StringComparer.Ordinal)
{
string.Empty,
"코스피",
"코스닥",
"코스피_NXT",
"코스닥_NXT"
};
public static IReadOnlyList<LegacyComparisonFileRow> Parse(byte[] bytes)
{
ArgumentNullException.ThrowIfNull(bytes);
if (bytes.Length is <= 0 or > TrustedLegacyComparisonFileSource.MaximumFileBytes)
{
throw InvalidSource();
}
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var cp949 = Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
string text;
try
{
text = cp949.GetString(bytes);
if (!cp949.GetBytes(text).AsSpan().SequenceEqual(bytes))
{
throw InvalidSource();
}
}
catch (DecoderFallbackException exception)
{
throw InvalidSource(exception);
}
catch (EncoderFallbackException exception)
{
throw InvalidSource(exception);
}
var lines = text.Split('\n');
if (lines.Length > 0 && lines[^1].Length == 0)
{
lines = lines[..^1];
}
if (lines.Length is <= 0 or > MaximumRows)
{
throw InvalidSource();
}
var rows = new List<LegacyComparisonFileRow>(lines.Length);
var signatures = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < lines.Length; index++)
{
var line = lines[index];
if (line.EndsWith('\r'))
{
line = line[..^1];
}
if (line.Length == 0 || line.Contains('\r'))
{
throw InvalidSource();
}
var fields = line.Split('^');
if (fields.Length != ExactFieldCount ||
!string.Equals(
fields[0],
(index + 1).ToString(CultureInfo.InvariantCulture),
StringComparison.Ordinal) ||
fields.Skip(4).Any(static value => value.Length != 0) ||
!Markets.Contains(fields[2]) ||
!Markets.Contains(fields[3]))
{
throw InvalidSource();
}
var names = fields[1].Split(',');
if (names.Length != 2 ||
!IsCanonicalTargetName(names[0]) ||
!IsCanonicalTargetName(names[1]))
{
throw InvalidSource();
}
ValidateMarketAndName(names[0], fields[2]);
ValidateMarketAndName(names[1], fields[3]);
var signature = string.Join(
'\u001f',
names[0],
fields[2],
names[1],
fields[3]);
if (!signatures.Add(signature))
{
throw InvalidSource();
}
rows.Add(new LegacyComparisonFileRow(
index + 1,
new LegacyComparisonFileTarget(names[0], fields[2]),
new LegacyComparisonFileTarget(names[1], fields[3])));
}
return rows.AsReadOnly();
}
private static bool IsCanonicalTargetName(string value) =>
value.Length is > 0 and <= MaximumTargetNameLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
!value.Contains('^') &&
!value.Contains(',') &&
IsSafeText(value);
private static void ValidateMarketAndName(string name, string market)
{
var isNxtName = name.EndsWith("(NXT)", StringComparison.Ordinal);
var isNxtMarket = market.EndsWith("_NXT", StringComparison.Ordinal);
if ((isNxtName && !isNxtMarket) || (!isNxtName && isNxtMarket))
{
throw InvalidSource();
}
}
private static bool IsSafeText(string value)
{
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private static LegacyComparisonImportException InvalidSource(
Exception? innerException = null) =>
new(
LegacyComparisonImportFailure.SourceInvalid,
"The legacy comparison source does not match the strict CP949 nine-field format.",
innerException);
}
public sealed class LegacyComparisonPairImportService
{
private const string StockNameColumn = "STOCK_NAME";
private const string StockCodeColumn = "STOCK_CODE";
private const string WorldInputNameColumn = "F_INPUT_NAME";
private const string WorldSymbolColumn = "F_SYMB";
private const string WorldNationColumn = "F_NATC";
private static readonly IReadOnlyDictionary<string, string> MarketTargets =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["코스피 지수"] = "Kospi",
["코스닥 지수"] = "Kosdaq",
["코스피200 지수"] = "Kospi200",
["선물 지수"] = "Futures",
["KRX100지수"] = "Krx100",
["환율-원달러"] = "WonDollar",
["환율-원엔"] = "WonYen",
["환율-원위엔"] = "WonYuan",
["환율-원유로"] = "WonEuro",
["해외지수-다우"] = "Dow",
["해외지수-나스닥"] = "Nasdaq",
["해외지수-S&P"] = "Sp500",
["해외지수-독일"] = "GermanyDax",
["해외지수-영국"] = "UnitedKingdomFtse",
["해외지수-프랑스"] = "FranceCac",
["해외지수-일본"] = "Nikkei",
["해외지수-홍콩"] = "HangSeng",
["해외지수-대만"] = "TaiwanWeighted",
["해외지수-싱가포르"] = "SingaporeStraitsTimes",
["해외지수-태국"] = "ThailandSet",
["해외지수-필리핀"] = "PhilippinesComposite",
["해외지수-말레이시아"] = "MalaysiaKlse",
["해외지수-인도네시아"] = "IndonesiaComposite",
["해외지수-중국"] = "ShanghaiComposite"
};
private static readonly IReadOnlyDictionary<string, StockIdentityProfile> StockProfiles =
new Dictionary<string, StockIdentityProfile>(StringComparer.Ordinal)
{
["코스피"] = new(
DataSourceKind.Oracle,
"LEGACY_COMPARISON_IMPORT_KOSPI",
"kospi",
LegacyComparisonImportTargetKind.KrxStock,
"""
SELECT STOCK_NAME, STOCK_CODE
FROM (
SELECT DISTINCT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE
FROM T_STOCK
WHERE F_MKT_HALT = 'N'
AND F_STOCK_WANNAME = :stock_name
ORDER BY F_STOCK_CODE
)
WHERE ROWNUM <= 2
"""),
["코스닥"] = new(
DataSourceKind.Oracle,
"LEGACY_COMPARISON_IMPORT_KOSDAQ",
"kosdaq",
LegacyComparisonImportTargetKind.KrxStock,
"""
SELECT STOCK_NAME, STOCK_CODE
FROM (
SELECT DISTINCT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE
FROM T_KOSDAQ_STOCK
WHERE F_MKT_HALT = 'N'
AND F_STOCK_WANNAME = :stock_name
ORDER BY F_STOCK_CODE
)
WHERE ROWNUM <= 2
"""),
["코스피_NXT"] = new(
DataSourceKind.MariaDb,
"LEGACY_COMPARISON_IMPORT_NXT_KOSPI",
"kospi",
LegacyComparisonImportTargetKind.NxtStock,
"""
SELECT DISTINCT a.F_STOCK_NAME AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE
FROM N_STOCK a
INNER JOIN N_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
AND a.F_STOCK_NAME = @stock_name
ORDER BY a.F_STOCK_CODE
LIMIT 2
"""),
["코스닥_NXT"] = new(
DataSourceKind.MariaDb,
"LEGACY_COMPARISON_IMPORT_NXT_KOSDAQ",
"kosdaq",
LegacyComparisonImportTargetKind.NxtStock,
"""
SELECT DISTINCT a.F_STOCK_NAME AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE
FROM N_KOSDAQ_STOCK a
INNER JOIN N_KOSDAQ_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
AND a.F_STOCK_NAME = @stock_name
ORDER BY a.F_STOCK_CODE
LIMIT 2
""")
};
private const string WorldIdentitySql = """
SELECT F_INPUT_NAME, F_SYMB, F_NATC
FROM (
SELECT F_INPUT_NAME, F_SYMB, F_NATC
FROM T_WORLD_IX_EQ_MASTER
WHERE F_FDTC = '1'
AND F_NATC IN ('US', 'TW')
AND F_INPUT_NAME = :input_name
ORDER BY F_SYMB, F_NATC
)
WHERE ROWNUM <= 2
""";
private readonly IDataQueryExecutor _executor;
private readonly ILegacyComparisonFileSource _source;
public LegacyComparisonPairImportService(
IDataQueryExecutor executor,
ILegacyComparisonFileSource source)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_source = source ?? throw new ArgumentNullException(nameof(source));
}
public async Task<LegacyComparisonImportResult> ImportAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var snapshot = await _source.ReadAsync(cancellationToken).ConfigureAwait(false);
if (snapshot is null || snapshot.Bytes is null ||
snapshot.Bytes.Length is <= 0 or > TrustedLegacyComparisonFileSource.MaximumFileBytes ||
string.IsNullOrEmpty(snapshot.Sha256) ||
snapshot.Sha256.Length != 64 ||
!snapshot.Sha256.All(static value =>
value is >= '0' and <= '9' or >= 'A' and <= 'F') ||
!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(snapshot.Sha256),
SHA256.HashData(snapshot.Bytes)))
{
throw new LegacyComparisonImportException(
LegacyComparisonImportFailure.SourceInvalid,
"The legacy comparison source digest is invalid.");
}
var rows = LegacyComparisonFileParser.Parse(snapshot.Bytes);
cancellationToken.ThrowIfCancellationRequested();
var cache = new Dictionary<string, LegacyComparisonImportTarget>(StringComparer.Ordinal);
var pairs = new List<LegacyComparisonImportPair>(rows.Count);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
var first = await ResolveAsync(row.First, cache, cancellationToken)
.ConfigureAwait(false);
var second = await ResolveAsync(row.Second, cache, cancellationToken)
.ConfigureAwait(false);
pairs.Add(new LegacyComparisonImportPair(row.RowNumber, first, second));
}
return new LegacyComparisonImportResult(
snapshot.Sha256,
rows.Count,
DateTimeOffset.Now,
pairs.AsReadOnly());
}
private async Task<LegacyComparisonImportTarget> ResolveAsync(
LegacyComparisonFileTarget raw,
IDictionary<string, LegacyComparisonImportTarget> cache,
CancellationToken cancellationToken)
{
var key = raw.Market + "\u001f" + raw.Name;
if (cache.TryGetValue(key, out var existing))
{
return existing;
}
LegacyComparisonImportTarget target;
if (raw.Market.Length == 0)
{
var worldStock = await TryResolveWorldStockAsync(raw.Name, cancellationToken)
.ConfigureAwait(false);
if (MarketTargets.TryGetValue(raw.Name, out var marketTarget))
{
// The legacy file persists no source-kind column. A world stock
// whose input name equals one of the fixed selector labels makes
// that row inherently ambiguous, so never guess the fixed target.
if (worldStock is not null)
{
throw IdentityAmbiguous();
}
target = new LegacyComparisonImportTarget(
LegacyComparisonImportTargetKind.MarketTarget,
MarketTarget: marketTarget);
}
else
{
target = worldStock ?? throw IdentityNotFound();
}
}
else if (StockProfiles.TryGetValue(raw.Market, out var profile))
{
target = await ResolveDomesticStockAsync(raw.Name, profile, cancellationToken)
.ConfigureAwait(false);
}
else
{
throw new LegacyComparisonImportException(
LegacyComparisonImportFailure.SourceInvalid,
"The legacy comparison source contains an unsupported market label.");
}
cache.Add(key, target);
return target;
}
private async Task<LegacyComparisonImportTarget> ResolveDomesticStockAsync(
string sourceName,
StockIdentityProfile profile,
CancellationToken cancellationToken)
{
var lookupName = profile.Kind == LegacyComparisonImportTargetKind.NxtStock
? sourceName[..^"(NXT)".Length]
: sourceName;
var marker = profile.Source == DataSourceKind.Oracle ? ':' : '@';
var spec = new DataQuerySpec(
profile.Sql,
[new DataQueryParameter("stock_name", lookupName, DbType.String)]);
spec.ValidateFor(profile.Source);
if (!profile.Sql.Contains(marker + "stock_name", StringComparison.Ordinal))
{
throw new InvalidOperationException("The stock identity query marker is invalid.");
}
var table = await _executor.ExecuteAsync(
profile.Source,
profile.QueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
ValidateStockTable(table, lookupName);
if (table.Rows.Count == 0)
{
throw IdentityNotFound();
}
if (table.Rows.Count != 1)
{
throw IdentityAmbiguous();
}
var stockName = (string)table.Rows[0][0];
var stockCode = (string)table.Rows[0][1];
return new LegacyComparisonImportTarget(
profile.Kind,
Market: profile.Market,
StockName: stockName,
StockCode: stockCode);
}
private async Task<LegacyComparisonImportTarget?> TryResolveWorldStockAsync(
string inputName,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
WorldIdentitySql,
[new DataQueryParameter("input_name", inputName, DbType.String)]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
"LEGACY_COMPARISON_IMPORT_WORLD_STOCK",
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
ValidateWorldTable(table, inputName);
if (table.Rows.Count == 0)
{
return null;
}
if (table.Rows.Count != 1)
{
throw IdentityAmbiguous();
}
return new LegacyComparisonImportTarget(
LegacyComparisonImportTargetKind.WorldStock,
InputName: (string)table.Rows[0][0],
Symbol: (string)table.Rows[0][1],
Nation: (string)table.Rows[0][2]);
}
private static void ValidateStockTable(DataTable? table, string expectedName)
{
if (table is null || table.Columns.Count != 2 || table.Rows.Count > 2 ||
!HasExactStringColumn(table.Columns[0], StockNameColumn) ||
!HasExactStringColumn(table.Columns[1], StockCodeColumn))
{
throw InvalidDatabaseData();
}
var codes = new HashSet<string>(StringComparer.Ordinal);
foreach (DataRow row in table.Rows)
{
if (row[0] is not string name || row[1] is not string code ||
!string.Equals(name, expectedName, StringComparison.Ordinal) ||
!IsCanonicalDatabaseValue(name, LegacyComparisonFileParser.MaximumTargetNameLength) ||
!IsCanonicalAsciiIdentity(code, 32) ||
!codes.Add(code))
{
throw InvalidDatabaseData();
}
}
}
private static void ValidateWorldTable(DataTable? table, string expectedName)
{
if (table is null || table.Columns.Count != 3 || table.Rows.Count > 2 ||
!HasExactStringColumn(table.Columns[0], WorldInputNameColumn) ||
!HasExactStringColumn(table.Columns[1], WorldSymbolColumn) ||
!HasExactStringColumn(table.Columns[2], WorldNationColumn))
{
throw InvalidDatabaseData();
}
var identities = new HashSet<string>(StringComparer.Ordinal);
foreach (DataRow row in table.Rows)
{
if (row[0] is not string inputName || row[1] is not string symbol ||
row[2] is not string nation ||
!string.Equals(inputName, expectedName, StringComparison.Ordinal) ||
!IsCanonicalDatabaseValue(inputName, LegacyComparisonFileParser.MaximumTargetNameLength) ||
!IsCanonicalWorldSymbol(symbol) ||
nation is not ("US" or "TW") ||
!identities.Add(symbol + "\u001f" + nation))
{
throw InvalidDatabaseData();
}
}
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsCanonicalDatabaseValue(string value, int maximumLength) =>
value.Length is > 0 && value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
IsSafeText(value);
private static bool IsCanonicalAsciiIdentity(string value, int maximumLength) =>
value.Length is > 0 && value.Length <= maximumLength &&
value.All(static character =>
char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-');
private static bool IsCanonicalWorldSymbol(string value) =>
value.Length is > 0 and <= 64 &&
value.All(static character =>
char.IsAsciiLetterOrDigit(character) || character is '@' or '.' or '_' or ':' or '-');
private static bool IsSafeText(string value)
{
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private static LegacyComparisonImportException IdentityNotFound() =>
new(
LegacyComparisonImportFailure.IdentityNotFound,
"A legacy comparison identity is not present in the current master data.");
private static LegacyComparisonImportException IdentityAmbiguous() =>
new(
LegacyComparisonImportFailure.IdentityAmbiguous,
"A legacy comparison identity is ambiguous in the current master data.");
private static LegacyComparisonImportException InvalidDatabaseData() =>
new(
LegacyComparisonImportFailure.DatabaseDataInvalid,
"A comparison identity query returned an invalid result.");
private sealed record StockIdentityProfile(
DataSourceKind Source,
string QueryName,
string Market,
LegacyComparisonImportTargetKind Kind,
string Sql);
}

View File

@@ -0,0 +1,300 @@
#nullable enable
using System.Data;
namespace MMoneyCoderSharp.Data;
public enum LegacyForeignIndexCandleRestoreFailure
{
InvalidRow,
IdentityNotFound,
IdentityAmbiguous,
DatabaseDataInvalid
}
/// <summary>
/// One untrusted legacy playlist row selected for the foreign-index candle
/// restoration pass. All persisted fields retain their exact text. IsEnabled
/// is carried so the caller can preserve disabled rows; it is not an identity
/// discriminator.
/// </summary>
public sealed record LegacyForeignIndexCandleRestoreRow(
int ItemIndex,
bool IsEnabled,
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string PageText,
string DataCode);
/// <summary>
/// A closed, database-verified selection that may be converted into an s8010
/// playlist entry. None of these values are copied from an untrusted result.
/// </summary>
public sealed record LegacyForeignIndexCandleRestoreIdentity(
string TargetKey,
string InputName,
string Symbol,
string NationCode,
string ForeignDomesticCode,
string SceneCode,
string ValueType,
int PeriodDays,
string CutCode);
public sealed record LegacyForeignIndexCandleRestoreOutcome(
int ItemIndex,
LegacyForeignIndexCandleRestoreIdentity? Identity,
LegacyForeignIndexCandleRestoreFailure? Failure)
{
public bool IsResolved => Identity is not null && Failure is null;
}
public sealed record LegacyForeignIndexCandleRestoreResult(
DateTimeOffset RetrievedAt,
IReadOnlyList<LegacyForeignIndexCandleRestoreOutcome> Rows);
/// <summary>
/// Reconstructs the three closed UC5 foreign-index candle targets. The legacy
/// playlist did not persist symbol, nation or F_FDTC, so the expected identity
/// is checked against Oracle before a trusted selection is issued. Queries are
/// exact, parameterized and read-only; failures are never retried here.
/// </summary>
public sealed class LegacyForeignIndexCandleRestoreService
{
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
public const string QueryName = "LEGACY_FOREIGN_INDEX_CANDLE_RESTORE";
private const string InputNameColumn = "F_INPUT_NAME";
private const string SymbolColumn = "F_SYMB";
private const string NationColumn = "F_NATC";
private const string ForeignDomesticColumn = "F_FDTC";
private const string NationCode = "US";
private const string ForeignDomesticCode = "0";
private const string SceneCode = "s8010";
private const string ValueType = "PRICE";
private const string ExpectedGroupCode = "해외지수";
private const string ExpectedGraphicType = "캔들그래프";
private const string ExpectedPageText = "1/1";
private const string IdentitySql = """
SELECT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
FROM (
SELECT F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
FROM T_WORLD_IX_EQ_MASTER
WHERE F_INPUT_NAME = :input_name
AND F_SYMB = :symbol
AND F_NATC = 'US'
AND F_FDTC = '0'
ORDER BY F_INPUT_NAME, F_SYMB, F_NATC, F_FDTC
)
WHERE ROWNUM <= 2
""";
private static readonly IReadOnlyDictionary<string, TargetProfile> Targets =
new Dictionary<string, TargetProfile>(StringComparer.Ordinal)
{
["다우존스"] = new("Dow", "다우존스", "DJI@DJI"),
["나스닥"] = new("Nasdaq", "나스닥", "NAS@IXIC"),
["S&P500"] = new("Sp500", "S&P500", "SPI@SPX")
};
private static readonly IReadOnlyDictionary<string, PeriodProfile> Periods =
new Dictionary<string, PeriodProfile>(StringComparer.Ordinal)
{
["5일"] = new(5, "8061"),
["20일"] = new(20, "8040"),
["60일"] = new(60, "8046"),
["120일"] = new(120, "8051"),
["240일"] = new(240, "8056")
};
private readonly IDataQueryExecutor _executor;
public LegacyForeignIndexCandleRestoreService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
/// <summary>
/// A narrow classifier for routing only exact closed-table candidates to
/// the native verifier. ResolveAsync independently validates every row.
/// </summary>
public static bool IsCandidate(LegacyForeignIndexCandleRestoreRow? row) =>
row is not null &&
row.ItemIndex is >= 0 and < MaximumRows &&
string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) &&
row.Subject is not null && Targets.ContainsKey(row.Subject) &&
string.Equals(row.GraphicType, ExpectedGraphicType, StringComparison.Ordinal) &&
row.Subtype is not null && Periods.ContainsKey(row.Subtype) &&
string.Equals(row.PageText, ExpectedPageText, StringComparison.Ordinal) &&
string.Equals(row.DataCode, string.Empty, StringComparison.Ordinal);
public async Task<LegacyForeignIndexCandleRestoreResult> ResolveAsync(
IReadOnlyList<LegacyForeignIndexCandleRestoreRow> rows,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rows);
if (rows.Count is < 1 or > MaximumRows)
{
throw new ArgumentOutOfRangeException(
nameof(rows),
$"A foreign-index candle restore must contain 1 through {MaximumRows} rows.");
}
var indexes = new HashSet<int>();
foreach (var row in rows)
{
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
!indexes.Add(row.ItemIndex))
{
throw new ArgumentException(
"Foreign-index candle restore rows must have unique bounded item indexes.",
nameof(rows));
}
}
cancellationToken.ThrowIfCancellationRequested();
var verifiedTargets = new Dictionary<string, TargetVerification>(StringComparer.Ordinal);
var outcomes = new List<LegacyForeignIndexCandleRestoreOutcome>(rows.Count);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
outcomes.Add(await ResolveRowAsync(row, verifiedTargets, cancellationToken)
.ConfigureAwait(false));
}
return new LegacyForeignIndexCandleRestoreResult(
DateTimeOffset.Now,
outcomes.AsReadOnly());
}
private async Task<LegacyForeignIndexCandleRestoreOutcome> ResolveRowAsync(
LegacyForeignIndexCandleRestoreRow row,
IDictionary<string, TargetVerification> verifiedTargets,
CancellationToken cancellationToken)
{
if (!string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) ||
row.Subject is null ||
!Targets.TryGetValue(row.Subject, out var target) ||
!string.Equals(row.GraphicType, ExpectedGraphicType, StringComparison.Ordinal) ||
row.Subtype is null ||
!Periods.TryGetValue(row.Subtype, out var period) ||
!string.Equals(row.PageText, ExpectedPageText, StringComparison.Ordinal) ||
!string.Equals(row.DataCode, string.Empty, StringComparison.Ordinal))
{
return Failed(row.ItemIndex, LegacyForeignIndexCandleRestoreFailure.InvalidRow);
}
if (!verifiedTargets.TryGetValue(target.TargetKey, out var verification))
{
verification = await VerifyTargetAsync(target, cancellationToken)
.ConfigureAwait(false);
verifiedTargets.Add(target.TargetKey, verification);
}
if (verification.Failure.HasValue)
{
return Failed(row.ItemIndex, verification.Failure.Value);
}
return new LegacyForeignIndexCandleRestoreOutcome(
row.ItemIndex,
new LegacyForeignIndexCandleRestoreIdentity(
target.TargetKey,
target.InputName,
target.Symbol,
NationCode,
ForeignDomesticCode,
SceneCode,
ValueType,
period.Days,
period.CutCode),
Failure: null);
}
private async Task<TargetVerification> VerifyTargetAsync(
TargetProfile target,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
IdentitySql,
[
new DataQueryParameter("input_name", target.InputName, DbType.String),
new DataQueryParameter("symbol", target.Symbol, DbType.String)
]);
spec.ValidateFor(DataSourceKind.Oracle);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
QueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return ValidateIdentityTable(table, target);
}
private static TargetVerification ValidateIdentityTable(
DataTable? table,
TargetProfile target)
{
if (table is null || table.Columns.Count != 4 || table.Rows.Count > 2 ||
!HasExactStringColumn(table.Columns[0], InputNameColumn) ||
!HasExactStringColumn(table.Columns[1], SymbolColumn) ||
!HasExactStringColumn(table.Columns[2], NationColumn) ||
!HasExactStringColumn(table.Columns[3], ForeignDomesticColumn))
{
return InvalidDatabaseData();
}
foreach (DataRow row in table.Rows)
{
if (row[0] is not string inputName ||
row[1] is not string symbol ||
row[2] is not string nation ||
row[3] is not string foreignDomestic ||
!string.Equals(inputName, target.InputName, StringComparison.Ordinal) ||
!string.Equals(symbol, target.Symbol, StringComparison.Ordinal) ||
!string.Equals(nation, NationCode, StringComparison.Ordinal) ||
!string.Equals(foreignDomestic, ForeignDomesticCode, StringComparison.Ordinal))
{
return InvalidDatabaseData();
}
}
return table.Rows.Count switch
{
0 => new TargetVerification(
LegacyForeignIndexCandleRestoreFailure.IdentityNotFound),
1 => new TargetVerification(Failure: null),
2 => new TargetVerification(
LegacyForeignIndexCandleRestoreFailure.IdentityAmbiguous),
_ => InvalidDatabaseData()
};
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static LegacyForeignIndexCandleRestoreOutcome Failed(
int itemIndex,
LegacyForeignIndexCandleRestoreFailure failure) =>
new(itemIndex, Identity: null, failure);
private static TargetVerification InvalidDatabaseData() =>
new(LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid);
private sealed record TargetProfile(
string TargetKey,
string InputName,
string Symbol);
private sealed record PeriodProfile(int Days, string CutCode);
private sealed record TargetVerification(
LegacyForeignIndexCandleRestoreFailure? Failure);
}

View File

@@ -0,0 +1,260 @@
#nullable enable
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MMoneyCoderSharp.Data;
public sealed class LegacyManualOperatorDataValidationException : Exception
{
public LegacyManualOperatorDataValidationException()
: base("The legacy manual operator data is invalid.")
{
}
}
public sealed record LegacyManualViItem(string Code, string Name);
public sealed record LegacyManualOperatorSourceSnapshot(
IReadOnlyList<S5025TrustedManualRow> IndividualRows,
IReadOnlyList<S5025TrustedManualRow> ForeignRows,
IReadOnlyList<S5025TrustedManualRow> InstitutionRows,
IReadOnlyList<LegacyManualViItem> ViItems,
string SourceSha256);
/// <summary>
/// Closed source contract for the four original FSell/VI files. The caller can
/// request the one supported snapshot, but cannot provide a path or filename.
/// </summary>
public interface ILegacyManualOperatorDataSource
{
Task<LegacyManualOperatorSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default);
}
/// <summary>
/// Parses and emits only the original CP949 manual-data shapes. Keeping this
/// contract in Core makes the destination transaction independent from raw
/// legacy bytes and gives every imported row an explicit type.
/// </summary>
public static class LegacyManualOperatorDataCodec
{
public const int NetSellRowCount = 5;
public const int MaximumNetSellCellLength = 256;
public const int MaximumViItemCount = 100;
public const int MaximumViCodeLength = 64;
public const int MaximumViNameLength = 200;
public const int MaximumViSerializedBytes = 4_000;
private static readonly Encoding Cp949 = CreateCp949Encoding();
public static IReadOnlyList<S5025TrustedManualRow> ParseNetSell(
ReadOnlySpan<byte> bytes)
{
var text = DecodeStrict(bytes, allowEmpty: false);
var lines = new List<string>(NetSellRowCount + 1);
using (var reader = new StringReader(text))
{
while (reader.ReadLine() is { } line)
{
lines.Add(line.Trim());
}
}
// FSell wrote five rows followed by one empty terminal record. The
// migrated reader deliberately preserves that exact record count.
if (lines.Count != NetSellRowCount + 1 || lines[^1].Length != 0)
{
throw InvalidData();
}
var rows = new S5025TrustedManualRow[NetSellRowCount];
for (var index = 0; index < NetSellRowCount; index++)
{
var columns = lines[index].Split('^');
if (columns.Length != 4 || columns.Any(value =>
value.Length > MaximumNetSellCellLength ||
value.Any(char.IsControl)))
{
throw InvalidData();
}
rows[index] = new S5025TrustedManualRow(
columns[0],
columns[1],
columns[2],
columns[3]);
}
return Array.AsReadOnly(rows);
}
public static IReadOnlyList<LegacyManualViItem> ParseVi(ReadOnlySpan<byte> bytes)
{
if (bytes.Length > MaximumViSerializedBytes)
{
throw InvalidData();
}
var text = DecodeStrict(bytes, allowEmpty: true);
var items = new List<LegacyManualViItem>();
using var reader = new StringReader(text);
while (reader.ReadLine() is { } rawLine)
{
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
var columns = line.Split('^');
if (columns.Length != 9 || columns.Skip(2).Any(value => value.Length != 0))
{
throw InvalidData();
}
var item = new LegacyManualViItem(columns[0], columns[1]);
ValidateViItem(item);
items.Add(item);
if (items.Count > MaximumViItemCount)
{
throw InvalidData();
}
}
// The canonical representation is the value consumed by the migrated
// store, so the 4,000-byte legacy payload bound is checked again after
// normalization. Duplicates and row order are intentionally preserved.
_ = SerializeVi(items);
return Array.AsReadOnly(items.ToArray());
}
public static byte[] SerializeNetSell(IReadOnlyList<S5025TrustedManualRow> rows)
{
if (rows is null || rows.Count != NetSellRowCount || rows.Any(row => row is null))
{
throw InvalidData();
}
foreach (var row in rows)
{
ValidateNetSellCell(row.LeftName);
ValidateNetSellCell(row.LeftAmount);
ValidateNetSellCell(row.RightName);
ValidateNetSellCell(row.RightAmount);
}
var text = string.Join("\r\n", rows.Select(row => string.Join(
'^',
row.LeftName,
row.LeftAmount,
row.RightName,
row.RightAmount))) + "\r\n\r\n";
return EncodeStrict(text);
}
public static byte[] SerializeVi(IReadOnlyList<LegacyManualViItem> items)
{
if (items is null || items.Count > MaximumViItemCount || items.Any(item => item is null))
{
throw InvalidData();
}
foreach (var item in items)
{
ValidateViItem(item);
}
var bytes = EncodeStrict(string.Concat(items.Select(item =>
$"{item.Code}^{item.Name}^^^^^^^\r\n")));
if (bytes.Length > MaximumViSerializedBytes)
{
throw InvalidData();
}
return bytes;
}
private static void ValidateNetSellCell(string? value)
{
if (value is null ||
value.Length > MaximumNetSellCellLength ||
value.Contains('^') ||
value.Any(char.IsControl))
{
throw InvalidData();
}
}
private static void ValidateViItem(LegacyManualViItem? item)
{
if (item is null ||
!IsSafeViValue(item.Code, MaximumViCodeLength) ||
!IsSafeViValue(item.Name, MaximumViNameLength) ||
item.Name.Contains(','))
{
throw InvalidData();
}
}
private static bool IsSafeViValue(string? value, int maximumLength) =>
value is not null &&
value.Length is > 0 &&
value.Length <= maximumLength &&
string.Equals(value, value.Trim(), StringComparison.Ordinal) &&
!value.Contains('^') &&
!value.Any(char.IsControl);
private static string DecodeStrict(ReadOnlySpan<byte> bytes, bool allowEmpty)
{
if ((!allowEmpty && bytes.Length == 0) || HasUnicodeBom(bytes))
{
throw InvalidData();
}
try
{
var copy = bytes.ToArray();
var text = Cp949.GetString(copy);
if (!Cp949.GetBytes(text).AsSpan().SequenceEqual(copy))
{
throw InvalidData();
}
return text;
}
catch (Exception exception) when (exception is DecoderFallbackException or EncoderFallbackException)
{
throw InvalidData();
}
}
private static byte[] EncodeStrict(string text)
{
try
{
return Cp949.GetBytes(text);
}
catch (EncoderFallbackException)
{
throw InvalidData();
}
}
private static bool HasUnicodeBom(ReadOnlySpan<byte> bytes) =>
bytes.StartsWith(new byte[] { 0xEF, 0xBB, 0xBF }) ||
bytes.StartsWith(new byte[] { 0xFF, 0xFE }) ||
bytes.StartsWith(new byte[] { 0xFE, 0xFF }) ||
bytes.StartsWith(new byte[] { 0x00, 0x00, 0xFE, 0xFF });
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private static LegacyManualOperatorDataValidationException InvalidData() => new();
}

View File

@@ -0,0 +1,586 @@
#nullable enable
using System.Data;
using System.Globalization;
namespace MMoneyCoderSharp.Data;
public enum LegacyNamedComparisonRestoreFailure
{
InvalidRow,
MissingMarketIdentity,
IdentityNotFound,
IdentityAmbiguous,
UnsupportedAction,
DatabaseDataInvalid
}
/// <summary>
/// One original PLAY_LIST comparison row. The values are the original seven
/// caret-delimited fields after the persistence layer has parsed them.
/// </summary>
public sealed record LegacyNamedComparisonRestoreRow(
int ItemIndex,
bool IsEnabled,
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string PageText,
string DataCode);
public sealed record LegacyNamedComparisonRestoreOutcome(
int ItemIndex,
string? ActionId,
LegacyComparisonImportTarget? First,
LegacyComparisonImportTarget? Second,
LegacyNamedComparisonRestoreFailure? Failure)
{
public bool IsResolved =>
Failure is null &&
ActionId is not null &&
First is not null &&
Second is not null;
}
public sealed record LegacyNamedComparisonRestoreResult(
DateTimeOffset RetrievedAt,
IReadOnlyList<LegacyNamedComparisonRestoreOutcome> Rows);
/// <summary>
/// Reconstructs only identities that the original PLAY_LIST row actually
/// persisted. In particular, a legacy two-column stock row contains only the
/// generic group "종목" and therefore cannot distinguish KOSPI, KOSDAQ, NXT or
/// world-stock masters. Those rows fail closed even when a name happens to be
/// unique in today's databases.
/// </summary>
public sealed class LegacyNamedComparisonRestoreService
{
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
private const string StockNameColumn = "STOCK_NAME";
private const string StockCodeColumn = "STOCK_CODE";
private static readonly IReadOnlyDictionary<string, string> MarketTargets =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["코스피 지수"] = "Kospi",
["코스닥 지수"] = "Kosdaq",
["코스피200 지수"] = "Kospi200",
["선물 지수"] = "Futures",
["KRX100지수"] = "Krx100",
["환율-원달러"] = "WonDollar",
["환율-원엔"] = "WonYen",
["환율-원위엔"] = "WonYuan",
["환율-원유로"] = "WonEuro",
["해외지수-다우"] = "Dow",
["해외지수-나스닥"] = "Nasdaq",
["해외지수-S&P"] = "Sp500",
["해외지수-독일"] = "GermanyDax",
["해외지수-영국"] = "UnitedKingdomFtse",
["해외지수-프랑스"] = "FranceCac",
["해외지수-일본"] = "Nikkei",
["해외지수-홍콩"] = "HangSeng",
["해외지수-대만"] = "TaiwanWeighted",
["해외지수-싱가포르"] = "SingaporeStraitsTimes",
["해외지수-태국"] = "ThailandSet",
["해외지수-필리핀"] = "PhilippinesComposite",
["해외지수-말레이시아"] = "MalaysiaKlse",
["해외지수-인도네시아"] = "IndonesiaComposite",
["해외지수-중국"] = "ShanghaiComposite"
};
private static readonly IReadOnlyDictionary<string, StockIdentityProfile> StockProfiles =
new Dictionary<string, StockIdentityProfile>(StringComparer.Ordinal)
{
["코스피"] = new(
DataSourceKind.Oracle,
"LEGACY_NAMED_COMPARISON_KOSPI",
"kospi",
LegacyComparisonImportTargetKind.KrxStock,
"""
SELECT STOCK_NAME, STOCK_CODE
FROM (
SELECT DISTINCT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE
FROM T_STOCK
WHERE F_MKT_HALT = 'N'
AND F_STOCK_WANNAME = :stock_name
ORDER BY F_STOCK_CODE
)
WHERE ROWNUM <= 2
"""),
["코스닥"] = new(
DataSourceKind.Oracle,
"LEGACY_NAMED_COMPARISON_KOSDAQ",
"kosdaq",
LegacyComparisonImportTargetKind.KrxStock,
"""
SELECT STOCK_NAME, STOCK_CODE
FROM (
SELECT DISTINCT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE
FROM T_KOSDAQ_STOCK
WHERE F_MKT_HALT = 'N'
AND F_STOCK_WANNAME = :stock_name
ORDER BY F_STOCK_CODE
)
WHERE ROWNUM <= 2
"""),
["코스피_NXT"] = new(
DataSourceKind.MariaDb,
"LEGACY_NAMED_COMPARISON_NXT_KOSPI",
"kospi",
LegacyComparisonImportTargetKind.NxtStock,
"""
SELECT DISTINCT a.F_STOCK_NAME AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE
FROM N_STOCK a
INNER JOIN N_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
AND a.F_STOCK_NAME = @stock_name
ORDER BY a.F_STOCK_CODE
LIMIT 2
"""),
["코스닥_NXT"] = new(
DataSourceKind.MariaDb,
"LEGACY_NAMED_COMPARISON_NXT_KOSDAQ",
"kosdaq",
LegacyComparisonImportTargetKind.NxtStock,
"""
SELECT DISTINCT a.F_STOCK_NAME AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE
FROM N_KOSDAQ_STOCK a
INNER JOIN N_KOSDAQ_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
AND a.F_STOCK_NAME = @stock_name
ORDER BY a.F_STOCK_CODE
LIMIT 2
""")
};
private static readonly IReadOnlyDictionary<string, string> ReturnActionIds =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["5일"] = "return-5d",
["1개월"] = "return-1m",
["3개월"] = "return-3m",
["6개월"] = "return-6m",
["12개월"] = "return-12m"
};
private static readonly HashSet<string> ExpectedMarketTargets =
new(StringComparer.Ordinal)
{
"Kospi", "Kosdaq", "Kospi200", "Krx100"
};
private readonly IDataQueryExecutor _executor;
public LegacyNamedComparisonRestoreService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
/// <summary>
/// A narrow, non-authoritative classifier used to decide which untrusted
/// named rows should be submitted for the native exact-identity pass.
/// ResolveAsync still validates every field independently.
/// </summary>
public static bool IsCandidate(LegacyNamedComparisonRestoreRow? row)
{
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
!IsCanonicalField(row.GroupCode, 256, allowEmpty: true) ||
!IsCanonicalField(row.Subject, 4_000, allowEmpty: false) ||
!IsCanonicalField(row.GraphicType, 256, allowEmpty: false) ||
!IsCanonicalField(row.Subtype, 256, allowEmpty: true) ||
!IsCanonicalField(row.DataCode, 1_024, allowEmpty: true) ||
!string.Equals(row.PageText, "1/1", StringComparison.Ordinal) ||
row.DataCode.Length != 0)
{
return false;
}
var names = row.Subject.Split(',');
if (names.Length != 2 || names.Any(static value =>
!IsCanonicalField(
value,
LegacyComparisonFileParser.MaximumTargetNameLength,
allowEmpty: false)))
{
return false;
}
return row.GraphicType switch
{
"2열판" => row.Subtype is "" or "예상체결",
"종목별 비교분석_캔들 그래프" or
"종목별 비교분석_라인 그래프" => row.Subtype.Length == 0,
"종목별 수익률 비교" => ReturnActionIds.ContainsKey(row.Subtype),
_ => false
};
}
public async Task<LegacyNamedComparisonRestoreResult> ResolveAsync(
IReadOnlyList<LegacyNamedComparisonRestoreRow> rows,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rows);
if (rows.Count is < 1 or > MaximumRows)
{
throw new ArgumentOutOfRangeException(
nameof(rows),
$"A named comparison restore must contain 1 through {MaximumRows} rows.");
}
var itemIndexes = new HashSet<int>();
foreach (var row in rows)
{
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
!itemIndexes.Add(row.ItemIndex))
{
throw new ArgumentException(
"Named comparison restore rows must have unique bounded item indexes.",
nameof(rows));
}
}
cancellationToken.ThrowIfCancellationRequested();
var cache = new Dictionary<string, IdentityResolution>(StringComparer.Ordinal);
var outcomes = new List<LegacyNamedComparisonRestoreOutcome>(rows.Count);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
outcomes.Add(await ResolveRowAsync(row, cache, cancellationToken)
.ConfigureAwait(false));
}
return new LegacyNamedComparisonRestoreResult(
DateTimeOffset.Now,
outcomes.AsReadOnly());
}
private async Task<LegacyNamedComparisonRestoreOutcome> ResolveRowAsync(
LegacyNamedComparisonRestoreRow row,
IDictionary<string, IdentityResolution> cache,
CancellationToken cancellationToken)
{
if (!TryClassify(row, out var actionId, out var targetNames, out var marketLabels,
out var failure))
{
return Failed(row.ItemIndex, failure);
}
if (marketLabels is null)
{
var targets = new LegacyComparisonImportTarget[2];
for (var index = 0; index < targets.Length; index++)
{
if (!MarketTargets.TryGetValue(targetNames[index], out var target))
{
return Failed(
row.ItemIndex,
LegacyNamedComparisonRestoreFailure.MissingMarketIdentity);
}
targets[index] = new LegacyComparisonImportTarget(
LegacyComparisonImportTargetKind.MarketTarget,
MarketTarget: target);
}
if (!IsFixedPairActionSupported(actionId, targets))
{
return Failed(
row.ItemIndex,
LegacyNamedComparisonRestoreFailure.UnsupportedAction);
}
return Resolved(row.ItemIndex, actionId, targets[0], targets[1]);
}
// UC1 rejects every NXT comparison graph/return before MainForm builds
// a scene, so an NXT master lookup cannot make this historical action
// playable and would only add unnecessary MariaDB work.
if (marketLabels.Any(static value =>
value.EndsWith("_NXT", StringComparison.Ordinal)))
{
return Failed(
row.ItemIndex,
LegacyNamedComparisonRestoreFailure.UnsupportedAction);
}
var resolved = new LegacyComparisonImportTarget[2];
for (var index = 0; index < resolved.Length; index++)
{
var resolution = await ResolveStockAsync(
targetNames[index],
marketLabels[index],
cache,
cancellationToken)
.ConfigureAwait(false);
if (resolution.Target is null)
{
return Failed(row.ItemIndex, resolution.Failure!.Value);
}
resolved[index] = resolution.Target;
}
return Resolved(row.ItemIndex, actionId, resolved[0], resolved[1]);
}
private static bool TryClassify(
LegacyNamedComparisonRestoreRow row,
out string actionId,
out string[] targetNames,
out string[]? marketLabels,
out LegacyNamedComparisonRestoreFailure failure)
{
actionId = string.Empty;
targetNames = [];
marketLabels = null;
failure = LegacyNamedComparisonRestoreFailure.InvalidRow;
if (!IsCanonicalField(row.GroupCode, 256, allowEmpty: true) ||
!IsCanonicalField(row.Subject, 4_000, allowEmpty: false) ||
!IsCanonicalField(row.GraphicType, 256, allowEmpty: false) ||
!IsCanonicalField(row.Subtype, 256, allowEmpty: true) ||
!IsCanonicalField(row.DataCode, 1_024, allowEmpty: true) ||
!string.Equals(row.PageText, "1/1", StringComparison.Ordinal) ||
row.DataCode.Length != 0)
{
return false;
}
var names = row.Subject.Split(',');
if (names.Length != 2 ||
names.Any(static value =>
!IsCanonicalField(value, LegacyComparisonFileParser.MaximumTargetNameLength, allowEmpty: false)))
{
return false;
}
targetNames = names;
if (string.Equals(row.GraphicType, "2열판", StringComparison.Ordinal))
{
actionId = row.Subtype switch
{
"" => "two-column-current",
"예상체결" => "two-column-expected",
_ => string.Empty
};
if (actionId.Length == 0 ||
row.GroupCode is not ("지수" or "종목"))
{
return false;
}
if (row.GroupCode == "종목")
{
failure = LegacyNamedComparisonRestoreFailure.MissingMarketIdentity;
return false;
}
return true;
}
actionId = row.GraphicType switch
{
"종목별 비교분석_캔들 그래프" when row.Subtype.Length == 0 =>
"comparison-candle",
"종목별 비교분석_라인 그래프" when row.Subtype.Length == 0 =>
"comparison-line",
"종목별 수익률 비교" when ReturnActionIds.TryGetValue(row.Subtype, out var value) =>
value,
_ => string.Empty
};
if (actionId.Length == 0)
{
return false;
}
var markets = row.GroupCode.Split(',');
if (markets.Length != 2 || markets.Any(static value => !StockProfiles.ContainsKey(value)))
{
failure = LegacyNamedComparisonRestoreFailure.MissingMarketIdentity;
return false;
}
marketLabels = markets;
return true;
}
private async Task<IdentityResolution> ResolveStockAsync(
string sourceName,
string marketLabel,
IDictionary<string, IdentityResolution> cache,
CancellationToken cancellationToken)
{
var cacheKey = marketLabel + "\u001f" + sourceName;
if (cache.TryGetValue(cacheKey, out var existing))
{
return existing;
}
var profile = StockProfiles[marketLabel];
var nxtSuffix = "(NXT)";
var isNxtName = sourceName.EndsWith(nxtSuffix, StringComparison.Ordinal);
var isNxtProfile = profile.Kind == LegacyComparisonImportTargetKind.NxtStock;
if (isNxtName != isNxtProfile ||
isNxtProfile && sourceName.Length == nxtSuffix.Length)
{
var invalid = new IdentityResolution(
null,
LegacyNamedComparisonRestoreFailure.InvalidRow);
cache.Add(cacheKey, invalid);
return invalid;
}
var lookupName = isNxtProfile
? sourceName[..^nxtSuffix.Length]
: sourceName;
var spec = new DataQuerySpec(
profile.Sql,
[new DataQueryParameter("stock_name", lookupName, DbType.String)]);
spec.ValidateFor(profile.Source);
var table = await _executor.ExecuteAsync(
profile.Source,
profile.QueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var resolution = ValidateStockTable(table, lookupName, profile);
cache.Add(cacheKey, resolution);
return resolution;
}
private static IdentityResolution ValidateStockTable(
DataTable? table,
string expectedName,
StockIdentityProfile profile)
{
if (table is null || table.Columns.Count != 2 || table.Rows.Count > 2 ||
!HasExactStringColumn(table.Columns[0], StockNameColumn) ||
!HasExactStringColumn(table.Columns[1], StockCodeColumn))
{
return InvalidDatabaseData();
}
var codes = new HashSet<string>(StringComparer.Ordinal);
foreach (DataRow row in table.Rows)
{
if (row[0] is not string name || row[1] is not string code ||
!string.Equals(name, expectedName, StringComparison.Ordinal) ||
!IsCanonicalField(name, LegacyComparisonFileParser.MaximumTargetNameLength, allowEmpty: false) ||
!IsCanonicalAsciiIdentity(code, 32) ||
!codes.Add(code))
{
return InvalidDatabaseData();
}
}
if (table.Rows.Count == 0)
{
return new IdentityResolution(
null,
LegacyNamedComparisonRestoreFailure.IdentityNotFound);
}
if (table.Rows.Count != 1)
{
return new IdentityResolution(
null,
LegacyNamedComparisonRestoreFailure.IdentityAmbiguous);
}
return new IdentityResolution(
new LegacyComparisonImportTarget(
profile.Kind,
Market: profile.Market,
StockName: (string)table.Rows[0][0],
StockCode: (string)table.Rows[0][1]),
null);
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsFixedPairActionSupported(
string actionId,
IReadOnlyList<LegacyComparisonImportTarget> targets)
{
var values = targets.Select(static target => target.MarketTarget!).ToArray();
var futuresCount = values.Count(static value => value == "Futures");
if (futuresCount > 1)
{
return false;
}
if (actionId == "two-column-current" || futuresCount == 1)
{
return true;
}
return actionId == "two-column-expected" &&
values.All(ExpectedMarketTargets.Contains);
}
private static bool IsCanonicalAsciiIdentity(string value, int maximumLength) =>
value.Length is > 0 && value.Length <= maximumLength &&
value.All(static character =>
char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-');
private static bool IsCanonicalField(
string? value,
int maximumLength,
bool allowEmpty)
{
if (value is null || value.Length > maximumLength ||
(!allowEmpty && value.Length == 0) ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
value.Contains('^'))
{
return false;
}
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private static LegacyNamedComparisonRestoreOutcome Resolved(
int itemIndex,
string actionId,
LegacyComparisonImportTarget first,
LegacyComparisonImportTarget second) =>
new(itemIndex, actionId, first, second, null);
private static LegacyNamedComparisonRestoreOutcome Failed(
int itemIndex,
LegacyNamedComparisonRestoreFailure failure) =>
new(itemIndex, null, null, null, failure);
private static IdentityResolution InvalidDatabaseData() =>
new(null, LegacyNamedComparisonRestoreFailure.DatabaseDataInvalid);
private sealed record IdentityResolution(
LegacyComparisonImportTarget? Target,
LegacyNamedComparisonRestoreFailure? Failure);
private sealed record StockIdentityProfile(
DataSourceKind Source,
string QueryName,
string Market,
LegacyComparisonImportTargetKind Kind,
string Sql);
}

View File

@@ -0,0 +1,508 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text.RegularExpressions;
namespace MMoneyCoderSharp.Data;
public enum LegacyNamedNxtThemeRestoreFailure
{
InvalidRow,
UnsupportedAction,
IdentityNotFound,
IdentityAmbiguous,
PreviewEmpty,
LiveItemsEmpty,
DatabaseDataInvalid
}
/// <summary>
/// One untrusted seven-field PLAY_LIST row routed to the NXT-theme verifier.
/// Every original field is retained so a successful restore can preserve the
/// old row while using the current MariaDB identity for playout.
/// </summary>
public sealed record LegacyNamedNxtThemeRestoreRow(
int ItemIndex,
bool IsEnabled,
string GroupCode,
string Subject,
string GraphicType,
string Subtype,
string PageText,
string DataCode);
/// <summary>
/// A closed current identity. StoredThemeCode is evidence only; PREPARE must
/// use CurrentThemeCode. The original application performed this same mapping
/// by exact title immediately before it constructed s5074/s5077/s5088.
/// </summary>
public sealed record LegacyNamedNxtThemeRestoreIdentity(
string ActionId,
string BuilderKey,
string CutCode,
int PageSize,
string Sort,
ThemeSession Session,
string ThemeTitle,
string StoredThemeCode,
string CurrentThemeCode,
int PreviewItemCount,
int LiveItemCount,
bool PreviewIsTruncated)
{
public bool CodeWasRemapped =>
!string.Equals(StoredThemeCode, CurrentThemeCode, StringComparison.Ordinal);
}
public sealed record LegacyNamedNxtThemeRestoreOutcome(
int ItemIndex,
LegacyNamedNxtThemeRestoreIdentity? Identity,
LegacyNamedNxtThemeRestoreFailure? Failure,
string? ObservedCurrentThemeCode = null)
{
public bool IsResolved => Identity is not null && Failure is null;
}
public sealed record LegacyNamedNxtThemeRestoreResult(
DateTimeOffset RetrievedAt,
IReadOnlyList<LegacyNamedNxtThemeRestoreOutcome> Rows);
/// <summary>
/// Rehydrates legacy `테마_NXT` rows through exact, bound MariaDB reads.
/// Title is the only identity the old scene code actually used. A title is
/// accepted only when it names one current NXT SB_LIST row, that row's code is
/// unique, its preview validates through LegacyThemeSelectionService, and at
/// least one non-stopped item has a non-zero live price.
/// </summary>
public sealed partial class LegacyNamedNxtThemeRestoreService
{
public const int MaximumRows = LegacyNamedPlaylistPersistenceService.MaximumItems;
public const string IdentityQueryName = "LEGACY_NAMED_NXT_THEME_IDENTITY";
private const string ExpectedGroupCode = "테마_NXT";
private const string NxtSuffix = "(NXT)";
private const int MaximumTitleLength = 128;
private static readonly IReadOnlyDictionary<string, ActionProfile> Actions =
new Dictionary<string, ActionProfile>(StringComparer.Ordinal)
{
["5단 표그래프"] = new("five-row-current", "s5074", "5074", 5),
["6종목 현재가"] = new("six-row-current", "s5077", "5077", 6),
["12종목 현재가"] = new("twelve-row-current", "s5088", "5088", 12)
};
private static readonly IReadOnlyDictionary<string, string> CurrentSorts =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["테마-현재가(입력순)"] = "INPUT_ORDER",
["테마-현재가(상승률순)"] = "GAIN_DESC",
["테마-현재가(하락률순)"] = "GAIN_ASC",
// s5074/s5077/s5088 all fall through to their final loss-rate branch.
["테마-현재가"] = "GAIN_ASC"
};
private const string IdentitySql = """
SELECT s.SB_TITLE THEME_TITLE,
s.SB_CODE THEME_CODE,
s.SB_MARKET THEME_MARKET,
CAST((SELECT COUNT(*)
FROM SB_LIST d
WHERE d.SB_MARKET = 'NXT'
AND BINARY d.SB_CODE = BINARY s.SB_CODE) AS CHAR) CODE_IDENTITY_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
WHERE i.SB_M_CODE = s.SB_CODE
AND q.F_STOP_GUBUN = 'N') AS CHAR) PREVIEW_ITEM_COUNT,
CAST((SELECT COUNT(*)
FROM SB_ITEM i
JOIN v_all_stock q
ON SUBSTRING(i.SB_I_CODE, 2, 12) = q.F_STOCK_CODE
WHERE i.SB_M_CODE = s.SB_CODE
AND q.F_STOP_GUBUN = 'N'
AND q.F_CURR_PRICE != 0) AS CHAR) LIVE_ITEM_COUNT
FROM SB_LIST s
WHERE s.SB_MARKET = 'NXT'
AND BINARY s.SB_TITLE = BINARY @theme_title
ORDER BY BINARY s.SB_CODE
LIMIT 2
""";
private static readonly string[] IdentityColumns =
[
"THEME_TITLE",
"THEME_CODE",
"THEME_MARKET",
"CODE_IDENTITY_COUNT",
"PREVIEW_ITEM_COUNT",
"LIVE_ITEM_COUNT"
];
private readonly IDataQueryExecutor _executor;
private readonly LegacyThemeSelectionService _themeSelectionService;
private readonly TimeProvider _timeProvider;
public LegacyNamedNxtThemeRestoreService(IDataQueryExecutor executor)
: this(executor, TimeProvider.System)
{
}
internal LegacyNamedNxtThemeRestoreService(
IDataQueryExecutor executor,
TimeProvider timeProvider)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
_themeSelectionService = new LegacyThemeSelectionService(_executor);
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
/// <summary>
/// Routes every exact `테마_NXT` group row, including malformed rows, so
/// the native result can fail that row explicitly instead of guessing.
/// </summary>
public static bool IsCandidate(LegacyNamedNxtThemeRestoreRow? row) =>
row is not null &&
row.ItemIndex is >= 0 and < MaximumRows &&
string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal);
public async Task<LegacyNamedNxtThemeRestoreResult> ResolveAsync(
IReadOnlyList<LegacyNamedNxtThemeRestoreRow> rows,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(rows);
if (rows.Count is < 1 or > MaximumRows)
{
throw new ArgumentOutOfRangeException(
nameof(rows),
$"An NXT-theme restore must contain 1 through {MaximumRows} rows.");
}
var indexes = new HashSet<int>();
foreach (var row in rows)
{
if (row is null || row.ItemIndex is < 0 or >= MaximumRows ||
!indexes.Add(row.ItemIndex))
{
throw new ArgumentException(
"NXT-theme restore rows must have unique bounded item indexes.",
nameof(rows));
}
}
var session = SessionForLocalHour(_timeProvider.GetLocalNow().Hour);
var verifications = new Dictionary<string, ThemeVerification>(StringComparer.Ordinal);
var outcomes = new List<LegacyNamedNxtThemeRestoreOutcome>(rows.Count);
foreach (var row in rows)
{
cancellationToken.ThrowIfCancellationRequested();
outcomes.Add(await ResolveRowAsync(row, session, verifications, cancellationToken)
.ConfigureAwait(false));
}
return new LegacyNamedNxtThemeRestoreResult(
_timeProvider.GetLocalNow(),
outcomes.AsReadOnly());
}
public static ThemeSession SessionForLocalHour(int hour) => hour switch
{
>= 0 and <= 12 => ThemeSession.PreMarket,
>= 13 and <= 23 => ThemeSession.AfterMarket,
_ => throw new ArgumentOutOfRangeException(nameof(hour))
};
private async Task<LegacyNamedNxtThemeRestoreOutcome> ResolveRowAsync(
LegacyNamedNxtThemeRestoreRow row,
ThemeSession session,
IDictionary<string, ThemeVerification> verifications,
CancellationToken cancellationToken)
{
var classification = Classify(row);
if (classification.Failure.HasValue)
{
return Failed(row.ItemIndex, classification.Failure.Value);
}
var title = classification.Title!;
if (!verifications.TryGetValue(title, out var verification))
{
verification = await VerifyThemeAsync(title, session, cancellationToken)
.ConfigureAwait(false);
verifications.Add(title, verification);
}
if (verification.Failure.HasValue)
{
return Failed(
row.ItemIndex,
verification.Failure.Value,
verification.CurrentThemeCode);
}
var profile = classification.Action!;
return new LegacyNamedNxtThemeRestoreOutcome(
row.ItemIndex,
new LegacyNamedNxtThemeRestoreIdentity(
profile.ActionId,
profile.BuilderKey,
profile.CutCode,
profile.PageSize,
classification.Sort!,
session,
title,
row.DataCode,
verification.CurrentThemeCode!,
verification.PreviewItemCount,
verification.LiveItemCount,
verification.PreviewIsTruncated),
Failure: null,
ObservedCurrentThemeCode: verification.CurrentThemeCode);
}
private static RowClassification Classify(LegacyNamedNxtThemeRestoreRow row)
{
if (!string.Equals(row.GroupCode, ExpectedGroupCode, StringComparison.Ordinal) ||
!IsSafeCanonicalText(row.Subject, MaximumTitleLength + NxtSuffix.Length) ||
!row.Subject.EndsWith(NxtSuffix, StringComparison.Ordinal) ||
row.Subject.Length == NxtSuffix.Length ||
!StoredThemeCodePattern().IsMatch(row.DataCode ?? string.Empty) ||
!IsValidStoredPage(row.PageText))
{
return new RowClassification(Failure: LegacyNamedNxtThemeRestoreFailure.InvalidRow);
}
var title = row.Subject[..^NxtSuffix.Length];
if (!IsSafeCanonicalText(title, MaximumTitleLength) ||
title.Contains(NxtSuffix, StringComparison.Ordinal))
{
return new RowClassification(Failure: LegacyNamedNxtThemeRestoreFailure.InvalidRow);
}
if (!Actions.TryGetValue(row.GraphicType ?? string.Empty, out var action) ||
!CurrentSorts.TryGetValue(row.Subtype ?? string.Empty, out var sort))
{
return new RowClassification(Failure: LegacyNamedNxtThemeRestoreFailure.UnsupportedAction);
}
return new RowClassification(title, action, sort, Failure: null);
}
private async Task<ThemeVerification> VerifyThemeAsync(
string title,
ThemeSession session,
CancellationToken cancellationToken)
{
var spec = new DataQuerySpec(
IdentitySql,
[new DataQueryParameter("theme_title", title, DbType.String)]);
spec.ValidateFor(DataSourceKind.MariaDb);
var table = await _executor.ExecuteAsync(
DataSourceKind.MariaDb,
IdentityQueryName,
spec,
cancellationToken)
.ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
var identity = ValidateIdentityTable(table, title);
if (identity.Failure.HasValue)
{
return identity;
}
ThemePreviewResult preview;
try
{
preview = await _themeSelectionService.PreviewAsync(
new ThemeSelectionIdentity(
ThemeMarket.Nxt,
session,
identity.CurrentThemeCode!,
title),
LegacyThemeSelectionService.MaximumPreviewItems,
cancellationToken)
.ConfigureAwait(false);
}
catch (ThemeSelectionDataException)
{
return InvalidDatabaseData();
}
if (identity.PreviewItemCount == 0 || preview.Items.Count == 0)
{
return identity with
{
Failure = LegacyNamedNxtThemeRestoreFailure.PreviewEmpty
};
}
var expectedVisibleCount = Math.Min(
identity.PreviewItemCount,
LegacyThemeSelectionService.MaximumPreviewItems);
if (preview.Items.Count != expectedVisibleCount ||
preview.IsTruncated !=
(identity.PreviewItemCount > LegacyThemeSelectionService.MaximumPreviewItems) ||
identity.LiveItemCount > identity.PreviewItemCount)
{
return InvalidDatabaseData();
}
if (identity.LiveItemCount == 0)
{
return identity with
{
Failure = LegacyNamedNxtThemeRestoreFailure.LiveItemsEmpty
};
}
return identity with { PreviewIsTruncated = preview.IsTruncated };
}
private static ThemeVerification ValidateIdentityTable(DataTable? table, string title)
{
if (table is null || table.Columns.Count != IdentityColumns.Length ||
table.Rows.Count > 2)
{
return InvalidDatabaseData();
}
for (var index = 0; index < IdentityColumns.Length; index++)
{
if (!string.Equals(
table.Columns[index].ColumnName,
IdentityColumns[index],
StringComparison.Ordinal) ||
table.Columns[index].DataType != typeof(string))
{
return InvalidDatabaseData();
}
}
if (table.Rows.Count == 0)
{
return new ThemeVerification(
Failure: LegacyNamedNxtThemeRestoreFailure.IdentityNotFound);
}
if (table.Rows.Count > 1)
{
return new ThemeVerification(
Failure: LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous);
}
var row = table.Rows[0];
if (row[0] is not string actualTitle ||
row[1] is not string code ||
row[2] is not string market ||
!string.Equals(actualTitle, title, StringComparison.Ordinal) ||
!string.Equals(market, "NXT", StringComparison.Ordinal) ||
!StoredThemeCodePattern().IsMatch(code) ||
!TryReadCount(row[3], out var codeIdentityCount) ||
!TryReadCount(row[4], out var previewItemCount) ||
!TryReadCount(row[5], out var liveItemCount))
{
return InvalidDatabaseData();
}
if (codeIdentityCount != 1)
{
return new ThemeVerification(
Failure: LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous);
}
return new ThemeVerification(
CurrentThemeCode: code,
PreviewItemCount: previewItemCount,
LiveItemCount: liveItemCount,
PreviewIsTruncated: false,
Failure: null);
}
private static bool TryReadCount(object value, out int result)
{
result = 0;
return value is string text &&
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out result) &&
result is >= 0 and <= 1_000_000 &&
string.Equals(
text,
result.ToString(CultureInfo.InvariantCulture),
StringComparison.Ordinal);
}
private static bool IsValidStoredPage(string value)
{
var match = StoredPagePattern().Match(value ?? string.Empty);
if (!match.Success ||
!int.TryParse(match.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var current) ||
!int.TryParse(match.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var total))
{
return false;
}
return current is >= 1 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount &&
total is >= 0 and <= LegacyNamedPlaylistPersistenceService.MaximumStoredPageCount &&
(total == 0 ? current == 1 : current <= total);
}
private static bool IsSafeCanonicalText(string? value, int maximumLength)
{
if (string.IsNullOrEmpty(value) || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal) ||
value.Contains('^'))
{
return false;
}
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' ||
category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or
UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private static LegacyNamedNxtThemeRestoreOutcome Failed(
int itemIndex,
LegacyNamedNxtThemeRestoreFailure failure,
string? observedCurrentThemeCode = null) =>
new(itemIndex, Identity: null, failure, observedCurrentThemeCode);
private static ThemeVerification InvalidDatabaseData() =>
new(Failure: LegacyNamedNxtThemeRestoreFailure.DatabaseDataInvalid);
private sealed record ActionProfile(
string ActionId,
string BuilderKey,
string CutCode,
int PageSize);
private sealed record RowClassification(
string? Title = null,
ActionProfile? Action = null,
string? Sort = null,
LegacyNamedNxtThemeRestoreFailure? Failure = null);
private sealed record ThemeVerification(
string? CurrentThemeCode = null,
int PreviewItemCount = 0,
int LiveItemCount = 0,
bool PreviewIsTruncated = false,
LegacyNamedNxtThemeRestoreFailure? Failure = null);
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
private static partial Regex StoredThemeCodePattern();
[GeneratedRegex("^([1-9][0-9]{0,3})/([0-9]{1,4})$", RegexOptions.CultureInvariant)]
private static partial Regex StoredPagePattern();
}

View File

@@ -268,6 +268,7 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis
private const int MaximumTitleLength = 128;
private const int MaximumSelectionFieldLength = 256;
private const int MaximumListTextLength = 4_000;
private const int MaximumSubjectFieldLength = MaximumListTextLength;
internal const string ListQueryName = "NAMED_PLAYLIST_LIST";
internal const string NextCodeQueryName = "NAMED_PLAYLIST_NEXT_CODE";
@@ -756,11 +757,11 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis
}
var selection = new LegacySceneSelection(
ValidateLoadedField(fields[1], queryName),
ValidateLoadedField(fields[2], queryName),
ValidateLoadedField(fields[3], queryName),
ValidateLoadedField(fields[4], queryName),
ValidateLoadedField(fields[6], queryName));
ValidateLoadedField(fields[1], MaximumSelectionFieldLength, queryName),
ValidateLoadedField(fields[2], MaximumSubjectFieldLength, queryName),
ValidateLoadedField(fields[3], MaximumSelectionFieldLength, queryName),
ValidateLoadedField(fields[4], MaximumSelectionFieldLength, queryName),
ValidateLoadedField(fields[6], MaximumSelectionFieldLength, queryName));
var page = ParsePage(fields[5], queryName);
return new NamedPlaylistStoredItem(itemIndex, fields[0] == "1", selection, page);
}
@@ -823,7 +824,7 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis
parameterName),
ValidateText(
selection.Subject,
MaximumSelectionFieldLength,
MaximumSubjectFieldLength,
allowEmpty: true,
allowCaret: false,
parameterName),
@@ -856,9 +857,12 @@ public sealed class LegacyNamedPlaylistPersistenceService : INamedPlaylistPersis
return validated;
}
private static string ValidateLoadedField(string value, string queryName)
private static string ValidateLoadedField(
string value,
int maximumLength,
string queryName)
{
if (value.Length > MaximumSelectionFieldLength || !IsSafeText(value))
if (value.Length > maximumLength || !IsSafeText(value))
{
throw InvalidData(queryName, "selection field");
}

View File

@@ -0,0 +1,434 @@
#nullable enable
using System.Data;
using System.Globalization;
using System.Text;
namespace MMoneyCoderSharp.Data;
public sealed record StockMasterIdentity(
StockMarket Market,
DataSourceKind Source,
string Name,
string Code);
public interface IStockMasterIdentityValidationService
{
Task<IReadOnlyList<StockMasterIdentity>> ValidateThemeItemsAsync(
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<StockMasterIdentity>> ValidateExpertRecommendationsAsync(
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
CancellationToken cancellationToken = default);
}
public sealed class StockMasterIdentityDataException : Exception
{
public StockMasterIdentityDataException(string message)
: base(message)
{
}
}
/// <summary>
/// Resolves the exact code/name/market identity used by the legacy jongmokCode
/// lookup immediately before ThemeA or EList mutation dispatch. All values are
/// bound, result schemas are closed, and zero, duplicate, or cross-market
/// matches fail closed.
/// </summary>
public sealed class LegacyStockMasterIdentityValidationService
: IStockMasterIdentityValidationService
{
private const int MaximumStockNameLength = 200;
private const int MaximumStockCodeLength = 32;
private const string StockNameColumn = "STOCK_NAME";
private const string StockCodeColumn = "STOCK_CODE";
private readonly IDataQueryExecutor _executor;
public LegacyStockMasterIdentityValidationService(IDataQueryExecutor executor)
{
_executor = executor ?? throw new ArgumentNullException(nameof(executor));
}
public Task<IReadOnlyList<StockMasterIdentity>> ValidateThemeItemsAsync(
IReadOnlyList<ThemeCatalogItem> items,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(items);
if (items.Count > LegacyThemeCatalogPersistenceService.MaximumItems)
{
throw InvalidIdentity();
}
var candidates = new StockMasterCandidate[items.Count];
for (var index = 0; index < items.Count; index++)
{
var item = items[index] ?? throw InvalidIdentity();
if (item.InputIndex != index ||
!TryMapThemeCode(item.ItemCode, out var market, out var code) ||
!IsSafeCanonicalText(item.ItemName, MaximumStockNameLength))
{
throw InvalidIdentity();
}
candidates[index] = new StockMasterCandidate(
index,
market,
code,
item.ItemName);
}
return ResolveAsync(candidates, useNxtDisplayName: false, cancellationToken);
}
public Task<IReadOnlyList<StockMasterIdentity>> ValidateExpertRecommendationsAsync(
IReadOnlyList<ExpertCatalogRecommendation> recommendations,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(recommendations);
if (recommendations.Count > LegacyExpertCatalogPersistenceService.MaximumRecommendations)
{
throw InvalidIdentity();
}
var candidates = new StockMasterCandidate[recommendations.Count];
for (var index = 0; index < recommendations.Count; index++)
{
var recommendation = recommendations[index] ?? throw InvalidIdentity();
if (recommendation.PlayIndex != index ||
!IsAsciiAlphaNumeric(recommendation.StockCode, MaximumStockCodeLength) ||
!IsSafeCanonicalText(recommendation.StockName, MaximumStockNameLength))
{
throw InvalidIdentity();
}
// EList stores no market column. Resolve the exact pair against all
// four legacy masters and require exactly one typed identity.
candidates[index] = new StockMasterCandidate(
index,
ExpectedMarket: null,
recommendation.StockCode,
recommendation.StockName);
}
return ResolveAsync(candidates, useNxtDisplayName: true, cancellationToken);
}
private async Task<IReadOnlyList<StockMasterIdentity>> ResolveAsync(
IReadOnlyList<StockMasterCandidate> candidates,
bool useNxtDisplayName,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (candidates.Count == 0)
{
return Array.Empty<StockMasterIdentity>();
}
var candidateKeys = new HashSet<StockMasterKey>();
foreach (var candidate in candidates)
{
if (!candidateKeys.Add(new StockMasterKey(candidate.Code, candidate.Name)))
{
throw InvalidIdentity();
}
}
var matches = candidates.ToDictionary(
static candidate => candidate.Index,
static _ => new List<StockMasterIdentity>());
foreach (var profile in StockMasterValidationProfiles.All)
{
cancellationToken.ThrowIfCancellationRequested();
var applicable = candidates
.Where(candidate => candidate.ExpectedMarket is null ||
candidate.ExpectedMarket == profile.Market)
.ToArray();
if (applicable.Length == 0)
{
continue;
}
var query = profile.CreateSpec(applicable
.Select(static candidate =>
new StockMasterLookup(candidate.Code, candidate.Name))
.ToArray(),
useNxtDisplayName);
var table = await _executor.ExecuteAsync(
profile.Source,
profile.QueryName,
query,
cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
AppendValidatedRows(table, profile, applicable, matches);
}
var resolved = new StockMasterIdentity[candidates.Count];
foreach (var candidate in candidates)
{
var candidateMatches = matches[candidate.Index];
if (candidateMatches.Count != 1)
{
throw InvalidIdentity();
}
resolved[candidate.Index] = candidateMatches[0];
}
return Array.AsReadOnly(resolved);
}
private static void AppendValidatedRows(
DataTable? table,
StockMasterValidationProfile profile,
IReadOnlyList<StockMasterCandidate> candidates,
IDictionary<int, List<StockMasterIdentity>> matches)
{
if (table is null || table.Columns.Count != 2 ||
!HasExactStringColumn(table.Columns[0], StockNameColumn) ||
!HasExactStringColumn(table.Columns[1], StockCodeColumn))
{
throw InvalidIdentity();
}
var candidatesByKey = candidates.ToDictionary(
static candidate => new StockMasterKey(candidate.Code, candidate.Name));
foreach (DataRow row in table.Rows)
{
if (row[0] is not string rawName || row[1] is not string rawCode)
{
throw InvalidIdentity();
}
var name = rawName.Trim();
var code = rawCode.Trim();
var key = new StockMasterKey(code, name);
if (!IsSafeCanonicalText(name, MaximumStockNameLength) ||
!IsAsciiAlphaNumeric(code, MaximumStockCodeLength) ||
!candidatesByKey.TryGetValue(key, out var candidate))
{
throw InvalidIdentity();
}
matches[candidate.Index].Add(new StockMasterIdentity(
profile.Market,
profile.Source,
name,
code));
}
}
private static bool TryMapThemeCode(
string? itemCode,
out StockMarket market,
out string code)
{
market = default;
code = string.Empty;
if (string.IsNullOrEmpty(itemCode) || itemCode.Length is < 2 or > 13)
{
return false;
}
market = itemCode[0] switch
{
'P' => StockMarket.Kospi,
'D' => StockMarket.Kosdaq,
'X' => StockMarket.NxtKospi,
'T' => StockMarket.NxtKosdaq,
_ => (StockMarket)(-1)
};
code = itemCode[1..];
return Enum.IsDefined(market) &&
IsAsciiAlphaNumeric(code, MaximumStockCodeLength);
}
private static bool HasExactStringColumn(DataColumn column, string name) =>
string.Equals(column.ColumnName, name, StringComparison.Ordinal) &&
column.DataType == typeof(string);
private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) =>
!string.IsNullOrEmpty(value) && value.Length <= maximumLength &&
value.All(char.IsAsciiLetterOrDigit);
private static bool IsSafeCanonicalText(string? value, int maximumLength)
{
if (string.IsNullOrEmpty(value) || value.Length > maximumLength ||
!string.Equals(value, value.Trim(), StringComparison.Ordinal))
{
return false;
}
foreach (var character in value)
{
var category = CharUnicodeInfo.GetUnicodeCategory(character);
if (char.IsControl(character) || char.IsSurrogate(character) ||
character == '\uFFFD' || category is UnicodeCategory.Format or
UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
{
return false;
}
}
return true;
}
private static StockMasterIdentityDataException InvalidIdentity() =>
new("The stock master did not return exactly one current typed identity for every catalog item.");
private sealed record StockMasterCandidate(
int Index,
StockMarket? ExpectedMarket,
string Code,
string Name);
private readonly record struct StockMasterKey(string Code, string Name);
}
internal sealed record StockMasterValidationProfile(
StockMarket Market,
DataSourceKind Source,
string QueryName,
string SelectAndWhereSql,
string CodeExpression,
string NameExpression,
string OrderBySql,
string? DisplaySelectAndWhereSql = null,
string? DisplayNameExpression = null)
{
internal DataQuerySpec CreateSpec(
IReadOnlyList<StockMasterLookup> candidates,
bool useNxtDisplayName)
{
ArgumentNullException.ThrowIfNull(candidates);
if (candidates.Count == 0)
{
throw new ArgumentException("At least one stock identity is required.", nameof(candidates));
}
var marker = Source == DataSourceKind.Oracle ? ':' : '@';
var selectSql = useNxtDisplayName && DisplaySelectAndWhereSql is not null
? DisplaySelectAndWhereSql
: SelectAndWhereSql;
var nameExpression = useNxtDisplayName && DisplayNameExpression is not null
? DisplayNameExpression
: NameExpression;
var sql = new StringBuilder(selectSql);
sql.AppendLine();
sql.AppendLine(" AND (");
var parameters = new List<DataQueryParameter>(candidates.Count * 2);
for (var index = 0; index < candidates.Count; index++)
{
if (index > 0)
{
sql.AppendLine(" OR");
}
var codeParameter = $"stock_code_{index}";
var nameParameter = $"stock_name_{index}";
sql.Append(" (");
sql.Append(CodeExpression);
sql.Append(" = ");
sql.Append(marker);
sql.Append(codeParameter);
sql.Append(" AND ");
sql.Append(nameExpression);
sql.Append(" = ");
sql.Append(marker);
sql.Append(nameParameter);
sql.AppendLine(")");
parameters.Add(new DataQueryParameter(
codeParameter,
candidates[index].Code,
DbType.String));
parameters.Add(new DataQueryParameter(
nameParameter,
candidates[index].Name,
DbType.String));
}
sql.AppendLine(" )");
sql.Append(OrderBySql);
var spec = new DataQuerySpec(sql.ToString(), parameters);
spec.ValidateFor(Source);
return spec;
}
}
internal sealed record StockMasterLookup(string Code, string Name);
internal static class StockMasterValidationProfiles
{
internal static IReadOnlyList<StockMasterValidationProfile> All { get; } =
[
new(
StockMarket.Kospi,
DataSourceKind.Oracle,
"STOCK_MASTER_VERIFY_KOSPI",
"""
SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE
FROM T_STOCK
WHERE F_MKT_HALT = 'N'
""",
"F_STOCK_CODE",
"F_STOCK_WANNAME",
"ORDER BY F_STOCK_WANNAME, F_STOCK_CODE"),
new(
StockMarket.Kosdaq,
DataSourceKind.Oracle,
"STOCK_MASTER_VERIFY_KOSDAQ",
"""
SELECT F_STOCK_WANNAME AS STOCK_NAME, F_STOCK_CODE AS STOCK_CODE
FROM T_KOSDAQ_STOCK
WHERE F_MKT_HALT = 'N'
""",
"F_STOCK_CODE",
"F_STOCK_WANNAME",
"ORDER BY F_STOCK_WANNAME, F_STOCK_CODE"),
new(
StockMarket.NxtKospi,
DataSourceKind.MariaDb,
"STOCK_MASTER_VERIFY_NXT_KOSPI",
"""
SELECT a.F_STOCK_NAME AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE
FROM N_STOCK a
INNER JOIN N_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
""",
"a.F_STOCK_CODE",
"a.F_STOCK_NAME",
"ORDER BY a.F_STOCK_NAME, a.F_STOCK_CODE",
DisplaySelectAndWhereSql: """
SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME,
a.F_STOCK_CODE AS STOCK_CODE
FROM N_STOCK a
INNER JOIN N_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
""",
DisplayNameExpression: "CONCAT(a.F_STOCK_NAME, '(NXT)')"),
new(
StockMarket.NxtKosdaq,
DataSourceKind.MariaDb,
"STOCK_MASTER_VERIFY_NXT_KOSDAQ",
"""
SELECT a.F_STOCK_NAME AS STOCK_NAME, a.F_STOCK_CODE AS STOCK_CODE
FROM N_KOSDAQ_STOCK a
INNER JOIN N_KOSDAQ_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
""",
"a.F_STOCK_CODE",
"a.F_STOCK_NAME",
"ORDER BY a.F_STOCK_NAME, a.F_STOCK_CODE",
DisplaySelectAndWhereSql: """
SELECT CONCAT(a.F_STOCK_NAME, '(NXT)') AS STOCK_NAME,
a.F_STOCK_CODE AS STOCK_CODE
FROM N_KOSDAQ_STOCK a
INNER JOIN N_KOSDAQ_ONLINE b ON b.F_STOCK_CODE = a.F_STOCK_CODE
WHERE a.F_STOP_GUBUN = 'N'
""",
DisplayNameExpression: "CONCAT(a.F_STOCK_NAME, '(NXT)')")
];
}

View File

@@ -57,9 +57,17 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
private const string NextKrxCodeSql = """
SELECT CASE
WHEN NVL(MAX(TO_NUMBER(TRIM(SB_CODE))), 0) < 99999999
WHEN NVL(MAX(
CASE
WHEN REGEXP_LIKE(TRIM(SB_CODE), '^[0-9]{3,8}$', 'c')
THEN TO_NUMBER(TRIM(SB_CODE))
END), 0) < 99999999
THEN LPAD(
TO_CHAR(NVL(MAX(TO_NUMBER(TRIM(SB_CODE))), 0) + 1),
TO_CHAR(NVL(MAX(
CASE
WHEN REGEXP_LIKE(TRIM(SB_CODE), '^[0-9]{3,8}$', 'c')
THEN TO_NUMBER(TRIM(SB_CODE))
END), 0) + 1),
8,
'0')
ELSE 'EXHAUSTED'
@@ -69,15 +77,17 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
private const string NextNxtCodeSql = """
SELECT CASE
WHEN SUM(
WHEN COALESCE(MAX(
CASE
WHEN TRIM(SB_CODE) REGEXP '^[0-9]{8}$' THEN 0
ELSE 1
END) > 0
THEN 'INVALID'
WHEN COALESCE(MAX(CAST(TRIM(SB_CODE) AS UNSIGNED)), 0) < 99999999
WHEN TRIM(SB_CODE) REGEXP BINARY '^[0-9]{3,8}$'
THEN CAST(TRIM(SB_CODE) AS UNSIGNED)
END), 0) < 99999999
THEN LPAD(
CAST(COALESCE(MAX(CAST(TRIM(SB_CODE) AS UNSIGNED)), 0) + 1 AS CHAR),
CAST(COALESCE(MAX(
CASE
WHEN TRIM(SB_CODE) REGEXP BINARY '^[0-9]{3,8}$'
THEN CAST(TRIM(SB_CODE) AS UNSIGNED)
END), 0) + 1 AS CHAR),
8,
'0')
ELSE 'EXHAUSTED'
@@ -268,7 +278,7 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
throw new ArgumentOutOfRangeException(parameterName, "The theme market is invalid.");
}
var code = ValidateThemeCode(definition.ThemeCode, parameterName);
var code = ValidateNewThemeCode(definition.ThemeCode, parameterName);
var title = ValidateThemeTitle(definition.ThemeTitle, definition.Market, parameterName);
var items = ValidateItems(definition.Items, definition.Market, parameterName);
return new ThemeCatalogDefinition(definition.Market, code, title, items);
@@ -289,7 +299,7 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
return identity with
{
ThemeCode = ValidateThemeCode(identity.ThemeCode, parameterName),
ThemeCode = ValidateExistingThemeCode(identity.ThemeCode, parameterName),
ThemeTitle = ValidateThemeTitle(identity.ThemeTitle, identity.Market, parameterName)
};
}
@@ -350,7 +360,7 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
return Array.AsReadOnly(canonical);
}
private static string ValidateThemeCode(string value, string parameterName)
private static string ValidateNewThemeCode(string value, string parameterName)
{
var code = ValidateCanonicalText(value, 8, parameterName);
if (!ThemeCodePattern().IsMatch(code))
@@ -361,6 +371,19 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
return code;
}
private static string ValidateExistingThemeCode(string value, string parameterName)
{
var code = ValidateCanonicalText(value, 8, parameterName);
if (!ExistingThemeCodePattern().IsMatch(code))
{
throw new ArgumentException(
"An existing theme code must contain three through eight digits.",
parameterName);
}
return code;
}
private static string ValidateThemeTitle(
string value,
ThemeMarket market,
@@ -415,6 +438,9 @@ public sealed partial class LegacyThemeCatalogPersistenceService : IThemeCatalog
[GeneratedRegex("^[0-9]{8}$", RegexOptions.CultureInvariant)]
private static partial Regex ThemeCodePattern();
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
private static partial Regex ExistingThemeCodePattern();
[GeneratedRegex("^[PD][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)]
private static partial Regex KrxItemCodePattern();

View File

@@ -479,7 +479,9 @@ public sealed partial class LegacyThemeSelectionService : IThemeSelectionService
private static ThemeSelectionDataException InvalidData(string queryName, string detail) =>
new($"Theme selection data from {queryName} contains an invalid {detail}.");
[GeneratedRegex("^[0-9]{8}$", RegexOptions.CultureInvariant)]
// ThemeA writes new identifiers with D8, while the deployed SB_LIST still
// contains legacy three- and four-digit identifiers that remain valid read identities.
[GeneratedRegex("^[0-9]{3,8}$", RegexOptions.CultureInvariant)]
private static partial Regex ThemeCodePattern();
[GeneratedRegex("^[PD][A-Za-z0-9]{1,12}$", RegexOptions.CultureInvariant)]
@@ -535,13 +537,25 @@ internal static class ThemeQueries
"""
SELECT THEME_TITLE, THEME_CODE, THEME_MARKET
FROM (
SELECT SB_TITLE THEME_TITLE,
SB_CODE THEME_CODE,
SB_MARKET THEME_MARKET
FROM SB_LIST
WHERE SB_MARKET = 'KRX'
AND UPPER(SB_TITLE) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(SB_TITLE), SB_CODE
SELECT s.SB_TITLE THEME_TITLE,
s.SB_CODE THEME_CODE,
s.SB_MARKET THEME_MARKET
FROM SB_LIST s
WHERE s.SB_MARKET = 'KRX'
AND s.SB_TITLE IS NOT NULL
AND s.SB_TITLE = TRIM(s.SB_TITLE)
AND LENGTH(s.SB_TITLE) BETWEEN 1 AND 128
AND REGEXP_LIKE(s.SB_CODE, '^[0-9]{3,8}$', 'c')
AND (SELECT COUNT(*)
FROM SB_LIST d
WHERE d.SB_MARKET = s.SB_MARKET
AND d.SB_TITLE = s.SB_TITLE) = 1
AND (SELECT COUNT(*)
FROM SB_LIST d
WHERE d.SB_MARKET = s.SB_MARKET
AND d.SB_CODE = s.SB_CODE) = 1
AND UPPER(s.SB_TITLE) LIKE '%' || :search_term || '%' ESCAPE '!'
ORDER BY UPPER(s.SB_TITLE), s.SB_CODE
)
WHERE ROWNUM <= :row_limit
""",
@@ -578,13 +592,25 @@ internal static class ThemeQueries
"THEME_SEARCH_NXT",
"THEME_PREVIEW_NXT",
"""
SELECT SB_TITLE THEME_TITLE,
SB_CODE THEME_CODE,
SB_MARKET THEME_MARKET
FROM SB_LIST
WHERE SB_MARKET = 'NXT'
AND UPPER(SB_TITLE) LIKE CONCAT('%', @search_term, '%') ESCAPE '!'
ORDER BY UPPER(SB_TITLE), SB_CODE
SELECT s.SB_TITLE THEME_TITLE,
s.SB_CODE THEME_CODE,
s.SB_MARKET THEME_MARKET
FROM SB_LIST s
WHERE s.SB_MARKET = 'NXT'
AND s.SB_TITLE IS NOT NULL
AND BINARY s.SB_TITLE = BINARY TRIM(s.SB_TITLE)
AND CHAR_LENGTH(s.SB_TITLE) BETWEEN 1 AND 128
AND s.SB_CODE REGEXP BINARY '^[0-9]{3,8}$'
AND (SELECT COUNT(*)
FROM SB_LIST d
WHERE d.SB_MARKET = s.SB_MARKET
AND BINARY d.SB_TITLE = BINARY s.SB_TITLE) = 1
AND (SELECT COUNT(*)
FROM SB_LIST d
WHERE d.SB_MARKET = s.SB_MARKET
AND BINARY d.SB_CODE = BINARY s.SB_CODE) = 1
AND UPPER(s.SB_TITLE) LIKE CONCAT('%', @search_term, '%') ESCAPE '!'
ORDER BY UPPER(s.SB_TITLE), s.SB_CODE
LIMIT @row_limit
""",
"""

View File

@@ -177,15 +177,28 @@ public sealed record PlayoutUseBackground(
bool IsEnabled,
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
/// <summary>
/// Selects the closed native root used to resolve a playout asset. Web content never
/// supplies this value. Scene builders keep using <see cref="SceneDirectory"/>, while
/// the MainForm-compatible F2/F3 background uses its own sibling background root.
/// </summary>
public enum PlayoutAssetRoot
{
SceneDirectory,
OperatorBackgroundDirectory
}
public sealed record PlayoutSetBackgroundTexture(
string AssetPath,
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction,
PlayoutAssetRoot AssetRoot = PlayoutAssetRoot.SceneDirectory) : PlayoutMutation;
public sealed record PlayoutSetBackgroundVideo(
string AssetPath,
int LoopCount,
bool LoopInfinite,
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction) : PlayoutMutation;
PlayoutMutationTiming Timing = PlayoutMutationTiming.BeforeTransaction,
PlayoutAssetRoot AssetRoot = PlayoutAssetRoot.SceneDirectory) : PlayoutMutation;
/// <summary>
/// Identifies a scene and the neutral object changes needed to prepare it.

View File

@@ -58,17 +58,17 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
{
"5026" => new LegacyS5026SceneLoadRequest(
await ResolvePairAsync(
subject,
selection,
ComparisonGraphPeriod.FiveDays,
cancellationToken).ConfigureAwait(false)),
"5029" => new LegacyS5029SceneLoadRequest(
await ResolvePairAsync(
subject,
selection,
ParseComparisonPeriod(selection.Subtype),
cancellationToken).ConfigureAwait(false)),
"5087" => new LegacyS5087SceneLoadRequest(
await ResolvePairAsync(
subject,
selection,
ComparisonGraphPeriod.FiveDays,
cancellationToken).ConfigureAwait(false)),
"5086" => new LegacyS5086SceneLoadRequest(
@@ -88,18 +88,78 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
}
private async Task<ComparisonPairSceneLoadRequest> ResolvePairAsync(
string subject,
LegacySceneSelection selection,
ComparisonGraphPeriod period,
CancellationToken cancellationToken)
{
var stockNames = SplitPair(subject);
var first = await ResolveStockAsync(stockNames[0], cancellationToken)
.ConfigureAwait(false);
var second = await ResolveStockAsync(stockNames[1], cancellationToken)
.ConfigureAwait(false);
var stockNames = SplitPair(Required(selection.Subject));
var exactIdentities = ParseExactPairIdentities(selection.DataCode);
var first = await ResolveExactStockAsync(
stockNames[0], exactIdentities[0], cancellationToken).ConfigureAwait(false);
var second = await ResolveExactStockAsync(
stockNames[1], exactIdentities[1], cancellationToken).ConfigureAwait(false);
return new ComparisonPairSceneLoadRequest(period, first, second);
}
private async Task<ComparisonStockSelection> ResolveExactStockAsync(
string rawName,
ExactStockIdentity identity,
CancellationToken cancellationToken)
{
var stockName = Required(rawName);
var tableName = identity.Market == ComparisonEquityMarket.Kospi
? "T_STOCK"
: "T_KOSDAQ_STOCK";
var queryName = identity.Market == ComparisonEquityMarket.Kospi
? "SCENE_COMPARISON_RESOLVE_EXACT_KOSPI_STOCK"
: "SCENE_COMPARISON_RESOLVE_EXACT_KOSDAQ_STOCK";
var spec = new DataQuerySpec(
$"""
SELECT STOCK_NAME, STOCK_CODE
FROM (
SELECT F_STOCK_WANNAME STOCK_NAME, F_STOCK_CODE STOCK_CODE
FROM {tableName}
WHERE F_MKT_HALT = 'N'
AND F_STOCK_WANNAME = :stockName
AND F_STOCK_CODE = :stockCode
ORDER BY F_STOCK_CODE
)
WHERE ROWNUM <= 2
""",
[
new DataQueryParameter("stockName", stockName, DbType.String),
new DataQueryParameter("stockCode", identity.StockCode, DbType.String)
]);
var table = await _executor.ExecuteAsync(
DataSourceKind.Oracle,
queryName,
spec,
cancellationToken).ConfigureAwait(false);
if (table is null || table.Columns.Count != 2 || table.Rows.Count > 2 ||
!string.Equals(table.Columns[0].ColumnName, "STOCK_NAME", StringComparison.Ordinal) ||
table.Columns[0].DataType != typeof(string) ||
!string.Equals(table.Columns[1].ColumnName, "STOCK_CODE", StringComparison.Ordinal) ||
table.Columns[1].DataType != typeof(string))
{
throw InvalidResult();
}
if (table.Rows.Count != 1)
{
throw InvalidSelection();
}
if (table.Rows[0][0] is not string actualName ||
table.Rows[0][1] is not string actualCode ||
!string.Equals(actualName, stockName, StringComparison.Ordinal) ||
!string.Equals(actualCode, identity.StockCode, StringComparison.Ordinal))
{
throw InvalidResult();
}
return new ComparisonStockSelection(identity.Market, actualName, actualCode);
}
private async Task<YieldSceneLoadRequest> ResolveYieldAsync(
LegacySceneSelection selection,
string subject,
@@ -151,8 +211,8 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
new YieldGraphInstrument(industry, subject));
}
var stock = await ResolveStockAsync(subject, cancellationToken).ConfigureAwait(false);
var stockKind = stock.Market switch
var stockMarket = await ResolveStockMarketAsync(subject, cancellationToken).ConfigureAwait(false);
var stockKind = stockMarket switch
{
ComparisonEquityMarket.Kospi => YieldGraphInstrumentKind.KospiStock,
ComparisonEquityMarket.Kosdaq => YieldGraphInstrumentKind.KosdaqStock,
@@ -160,10 +220,10 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
};
return new YieldSceneLoadRequest(
period,
new YieldGraphInstrument(stockKind, stock.StockName));
new YieldGraphInstrument(stockKind, subject));
}
private async Task<ComparisonStockSelection> ResolveStockAsync(
private async Task<ComparisonEquityMarket> ResolveStockMarketAsync(
string rawName,
CancellationToken cancellationToken)
{
@@ -187,9 +247,7 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
market = ComparisonEquityMarket.Kosdaq;
}
return market.HasValue
? new ComparisonStockSelection(market.Value, stockName)
: throw InvalidSelection();
return market ?? throw InvalidSelection();
}
private async Task<bool> StockExistsAsync(
@@ -351,6 +409,48 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
return values;
}
private static ExactStockIdentity[] ParseExactPairIdentities(string? value)
{
if (string.IsNullOrEmpty(value))
{
throw InvalidSelection();
}
var parts = value.Split('|', StringSplitOptions.None);
if (parts.Length != 2)
{
throw InvalidSelection();
}
return [ParseExactStockIdentity(parts[0]), ParseExactStockIdentity(parts[1])];
}
private static ExactStockIdentity ParseExactStockIdentity(string value)
{
var separator = value.IndexOf(':');
if (separator <= 0 || separator != value.LastIndexOf(':') ||
separator == value.Length - 1)
{
throw InvalidSelection();
}
var market = value[..separator] switch
{
"KOSPI" => ComparisonEquityMarket.Kospi,
"KOSDAQ" => ComparisonEquityMarket.Kosdaq,
_ => throw InvalidSelection()
};
var stockCode = value[(separator + 1)..];
if (stockCode.Length is < 1 or > 32 ||
stockCode.Any(static character =>
!(char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-')))
{
throw InvalidSelection();
}
return new ExactStockIdentity(market, stockCode);
}
private static string Required(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -368,4 +468,8 @@ public sealed class ComparisonAndYieldLegacyRequestResolver
new("A comparison stock-master lookup returned an invalid result.");
private sealed record StockMasterLookup(string TableName, DataQuerySpec Spec);
private sealed record ExactStockIdentity(
ComparisonEquityMarket Market,
string StockCode);
}

View File

@@ -8,7 +8,8 @@ namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
public sealed record ComparisonStockSelection(
ComparisonEquityMarket Market,
string StockName);
string StockName,
string StockCode);
public sealed record ComparisonPairSceneLoadRequest(
ComparisonGraphPeriod Period,
@@ -201,7 +202,7 @@ internal sealed class ComparisonAndYieldSceneLoaderCore
tablePrefix + "_SERIES_" + (index + 1).ToString(CultureInfo.InvariantCulture),
spec,
cancellationToken).ConfigureAwait(false);
result[index] = MapCandleSeries(table, target, selection.Market);
result[index] = MapCandleSeries(table, target, selection);
}
return result;
@@ -230,7 +231,7 @@ internal sealed class ComparisonAndYieldSceneLoaderCore
result[index] = MapComparisonYieldSeries(
table,
target,
selection.Market,
selection,
rowLimit);
}
@@ -276,11 +277,18 @@ internal sealed class ComparisonAndYieldSceneLoaderCore
private static ComparisonCandleSeriesData MapCandleSeries(
DataTable? table,
ComparisonSeriesTarget target,
ComparisonEquityMarket market)
ComparisonStockSelection selection)
{
ComparisonAndYieldTableReader.RequireShape(table, 5, CandleColumns);
var rows = table!.Rows.Cast<DataRow>().ToArray();
var quote = ComparisonAndYieldTableReader.ComparisonQuote(rows[0], target, market);
var quote = ComparisonAndYieldTableReader.ComparisonQuote(
rows[0],
target,
selection.Market);
if (!string.Equals(quote.StockName, selection.StockName, StringComparison.Ordinal))
{
throw ComparisonAndYieldTableReader.InvalidResult();
}
var candles = new ComparisonCandlePoint[rows.Length];
DateOnly? previousDate = null;
for (var index = 0; index < rows.Length; index++)
@@ -312,7 +320,7 @@ internal sealed class ComparisonAndYieldSceneLoaderCore
private static ComparisonYieldSeriesData MapComparisonYieldSeries(
DataTable? table,
ComparisonSeriesTarget target,
ComparisonEquityMarket market,
ComparisonStockSelection selection,
int rowLimit)
{
ComparisonAndYieldTableReader.RequireShape(
@@ -320,7 +328,14 @@ internal sealed class ComparisonAndYieldSceneLoaderCore
rowLimit,
ComparisonYieldColumns);
var rows = table!.Rows.Cast<DataRow>().ToArray();
var quote = ComparisonAndYieldTableReader.ComparisonQuote(rows[0], target, market);
var quote = ComparisonAndYieldTableReader.ComparisonQuote(
rows[0],
target,
selection.Market);
if (!string.Equals(quote.StockName, selection.StockName, StringComparison.Ordinal))
{
throw ComparisonAndYieldTableReader.InvalidResult();
}
var points = new ComparisonYieldPoint[rows.Length];
DateOnly? previousDate = null;
for (var index = 0; index < rows.Length; index++)
@@ -481,9 +496,18 @@ internal static class ComparisonAndYieldLoadGuard
throw InvalidRequest();
}
var stockCode = value.StockCode;
if (stockCode is null || stockCode.Length is < 1 or > 32 ||
stockCode.Any(static character =>
!(char.IsAsciiLetterOrDigit(character) || character is '.' or '_' or '-')))
{
throw InvalidRequest();
}
return value with
{
StockName = LegacySceneBuilderGuard.Text(value.StockName, "StockName")
StockName = LegacySceneBuilderGuard.Text(value.StockName, "StockName"),
StockCode = stockCode
};
}
}
@@ -713,7 +737,7 @@ internal static class ComparisonAndYieldQueryFactory
var (online, stock, history) = EquityTables(selection.Market);
var sql = $"""
WITH SELECTED_STOCK AS (
SELECT :stockName STOCK_NAME FROM DUAL
SELECT :stockName STOCK_NAME, :stockCode STOCK_CODE FROM DUAL
), QUOTE_ROW AS (
SELECT DISTINCT b.f_stock_wanname STOCK_NAME,
a.f_curr_price CURRENT_PRICE,
@@ -723,6 +747,7 @@ internal static class ComparisonAndYieldQueryFactory
FROM {online} a
JOIN {stock} b ON a.f_stock_code = b.f_stock_code
JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
AND b.f_stock_code = s.STOCK_CODE
WHERE b.f_mkt_halt = 'N'
AND a.f_curr_price <> 0
), OPEN_DAYS AS (
@@ -740,6 +765,7 @@ internal static class ComparisonAndYieldQueryFactory
FROM {history} h
JOIN {stock} b ON h.f_stock_code = b.f_stock_code
JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
AND b.f_stock_code = s.STOCK_CODE
WHERE b.f_mkt_halt = 'N'
)
SELECT q.STOCK_NAME, q.CURRENT_PRICE, q.NET_CHANGE, q.RATE, q.DIRECTION,
@@ -753,6 +779,7 @@ internal static class ComparisonAndYieldQueryFactory
sql,
[
new DataQueryParameter("stockName", selection.StockName, DbType.String),
new DataQueryParameter("stockCode", selection.StockCode, DbType.String),
new DataQueryParameter("rowLimit", 5, DbType.Int32)
]);
}
@@ -764,7 +791,7 @@ internal static class ComparisonAndYieldQueryFactory
var (online, stock, history) = EquityTables(selection.Market);
var sql = $"""
WITH SELECTED_STOCK AS (
SELECT :stockName STOCK_NAME FROM DUAL
SELECT :stockName STOCK_NAME, :stockCode STOCK_CODE FROM DUAL
), CURRENT_DAY AS (
SELECT MAX(SUBSTR(f_data_time, 1, 8)) DATA_DAY
FROM t_index
@@ -778,6 +805,7 @@ internal static class ComparisonAndYieldQueryFactory
FROM {online} a
JOIN {stock} b ON a.f_stock_code = b.f_stock_code
JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
AND b.f_stock_code = s.STOCK_CODE
WHERE b.f_mkt_halt = 'N'
AND a.f_curr_price <> 0
), STOCK_HISTORY AS (
@@ -785,6 +813,7 @@ internal static class ComparisonAndYieldQueryFactory
FROM {history} h
JOIN {stock} b ON h.f_stock_code = b.f_stock_code
JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
AND b.f_stock_code = s.STOCK_CODE
WHERE b.f_mkt_halt = 'N'
), RAW_POINTS AS (
SELECT d.open_day DATA_DAY, h.VALUE
@@ -797,6 +826,7 @@ internal static class ComparisonAndYieldQueryFactory
FROM {online} a
JOIN {stock} b ON a.f_stock_code = b.f_stock_code
JOIN SELECTED_STOCK s ON b.f_stock_wanname = s.STOCK_NAME
AND b.f_stock_code = s.STOCK_CODE
WHERE b.f_mkt_halt = 'N'
AND a.f_curr_price <> 0
), BOUNDED_POINTS AS (
@@ -816,6 +846,7 @@ internal static class ComparisonAndYieldQueryFactory
sql,
[
new DataQueryParameter("stockName", selection.StockName, DbType.String),
new DataQueryParameter("stockCode", selection.StockCode, DbType.String),
new DataQueryParameter("rowLimit", rowLimit, DbType.Int32)
]);
}

View File

@@ -77,12 +77,14 @@ public sealed record NxtPagedQuoteLoadRequest(
int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);
public sealed record ThemePagedQuoteLoadRequest(
string ThemeCode,
string ThemeName,
PagedQuoteThemeSort Sort,
PagedQuoteThemeValueKind ValueKind,
int PageNumberOneBased = 1) : PagedQuoteLoadRequest(PageNumberOneBased);
public sealed record NxtThemePagedQuoteLoadRequest(
string ThemeCode,
string ThemeName,
PagedQuoteThemeSort Sort,
NxtMarketSession Session,
@@ -455,6 +457,7 @@ internal static class PagedQuoteQueryFactory
ThemePagedQuoteLoadRequest request,
int maximumRows)
{
var code = RequiredThemeCode(request.ThemeCode);
var title = RequiredSelection(request.ThemeName);
ValidateThemeSort(request.Sort);
if (!Enum.IsDefined(request.ValueKind))
@@ -467,6 +470,7 @@ internal static class PagedQuoteQueryFactory
new DataQuerySpec(
BuildThemeQuery(request.Sort, request.ValueKind),
[
new DataQueryParameter("themeCode", code, DbType.String),
new DataQueryParameter("themeTitle", title, DbType.String),
new DataQueryParameter("maxRows", maximumRows, DbType.Int32)
]),
@@ -483,10 +487,20 @@ internal static class PagedQuoteQueryFactory
NxtThemePagedQuoteLoadRequest request,
int maximumRows)
{
var title = RequiredSelection(request.ThemeName)
.Replace("(NXT)", string.Empty, StringComparison.Ordinal)
.Trim();
title = RequiredSelection(title);
var code = RequiredThemeCode(request.ThemeCode);
var displayTitle = RequiredSelection(request.ThemeName);
const string displaySuffix = "(NXT)";
if (!displayTitle.EndsWith(displaySuffix, StringComparison.Ordinal) ||
displayTitle.Length == displaySuffix.Length)
{
throw InvalidRequest();
}
var title = RequiredSelection(displayTitle[..^displaySuffix.Length]);
if (title.Contains(displaySuffix, StringComparison.Ordinal))
{
throw InvalidRequest();
}
ValidateThemeSort(request.Sort);
ValidateNxtSession(request.Session);
return new PagedQuoteQueryPlan(
@@ -494,6 +508,7 @@ internal static class PagedQuoteQueryFactory
new DataQuerySpec(
BuildNxtThemeQuery(request.Sort),
[
new DataQueryParameter("themeCode", code, DbType.String),
new DataQueryParameter("themeTitle", title, DbType.String),
new DataQueryParameter("maxRows", maximumRows, DbType.Int32)
]),
@@ -580,6 +595,17 @@ internal static class PagedQuoteQueryFactory
}
}
private static string RequiredThemeCode(string value)
{
if (value is null || value.Length is < 3 or > 8 ||
value.Any(character => !char.IsAsciiDigit(character)))
{
throw InvalidRequest();
}
return value;
}
private static void ValidateNxtSession(NxtMarketSession session)
{
if (session is not (NxtMarketSession.PreMarket or NxtMarketSession.AfterMarket))
@@ -997,7 +1023,8 @@ internal static class PagedQuoteQueryFactory
SELECT i.SB_I_CODE, i.SB_I_INDEX
FROM SB_LIST l
JOIN SB_ITEM i ON l.SB_CODE = i.SB_M_CODE
WHERE l.SB_TITLE = :themeTitle
WHERE l.SB_CODE = :themeCode
AND l.SB_TITLE = :themeTitle
AND l.SB_MARKET = 'KRX'
), QUOTES AS (
{quoteRows}
@@ -1035,7 +1062,8 @@ internal static class PagedQuoteQueryFactory
JOIN SB_ITEM i ON l.SB_CODE = i.SB_M_CODE
JOIN v_all_stock b
ON SUBSTRING(i.SB_I_CODE, 2, 12) = b.f_stock_code
WHERE l.SB_TITLE = @themeTitle
WHERE l.SB_CODE = @themeCode
AND l.SB_TITLE = @themeTitle
AND l.SB_MARKET = 'NXT'
AND b.f_stop_gubun = 'N'
AND b.f_curr_price != 0

View File

@@ -18,7 +18,8 @@ public sealed record LegacySceneCueCompositionOptions(
LegacySceneBackgroundKind BackgroundKind,
string? BackgroundAssetPath = null,
int BackgroundVideoLoopCount = 2004,
bool BackgroundVideoLoopInfinite = true)
bool BackgroundVideoLoopInfinite = true,
PlayoutAssetRoot BackgroundAssetRoot = PlayoutAssetRoot.SceneDirectory)
{
public static LegacySceneCueCompositionOptions Default { get; } = new(
// MainForm initializes ComboDi.SelectedIndex to 6 and passes that value
@@ -255,6 +256,7 @@ public sealed class RegisteredLegacySceneCueProvider :
ArgumentNullException.ThrowIfNull(options);
if (options.FadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.BackgroundKind) ||
!Enum.IsDefined(options.BackgroundAssetRoot) ||
(options.BackgroundKind == LegacySceneBackgroundKind.Video &&
options.BackgroundVideoLoopCount is < 0 or > 10_000))
{
@@ -296,14 +298,17 @@ public sealed class RegisteredLegacySceneCueProvider :
mutations.Add(new PlayoutUseBackground(false));
break;
case LegacySceneBackgroundKind.Texture:
mutations.Add(new PlayoutSetBackgroundTexture(options.BackgroundAssetPath!));
mutations.Add(new PlayoutSetBackgroundTexture(
options.BackgroundAssetPath!,
AssetRoot: options.BackgroundAssetRoot));
mutations.Add(new PlayoutUseBackground(true));
break;
case LegacySceneBackgroundKind.Video:
mutations.Add(new PlayoutSetBackgroundVideo(
options.BackgroundAssetPath!,
options.BackgroundVideoLoopCount,
options.BackgroundVideoLoopInfinite));
options.BackgroundVideoLoopInfinite,
AssetRoot: options.BackgroundAssetRoot));
mutations.Add(new PlayoutUseBackground(true));
break;
default:

View File

@@ -0,0 +1,203 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
/// <summary>
/// Converts closed runtime states into closed audit records and suppresses identical
/// consecutive states independently for Oracle, MariaDB, and Tornado.
/// </summary>
public sealed class NativeOperatorLogTransitionTracker
{
private readonly object _sync = new();
private NativeOperatorDatabaseState? _oracleState;
private NativeOperatorDatabaseState? _mariaDbState;
private NativeOperatorTornadoState? _tornadoState;
public bool TryObserveDatabaseState(
DataSourceKind source,
DatabaseHealthState state,
out NativeOperatorLogRecord record)
{
record = default;
if (!TryMapDatabaseState(state, out var mapped))
{
return false;
}
lock (_sync)
{
switch (source)
{
case DataSourceKind.Oracle when _oracleState != mapped:
_oracleState = mapped;
record = NativeOperatorLogRecord.ForOracleStateChanged(mapped);
return true;
case DataSourceKind.MariaDb when _mariaDbState != mapped:
_mariaDbState = mapped;
record = NativeOperatorLogRecord.ForMariaDbStateChanged(mapped);
return true;
default:
return false;
}
}
}
public bool TryObserveTornadoState(
PlayoutConnectionState state,
out NativeOperatorLogRecord record)
{
record = default;
if (!TryMapTornadoState(state, out var mapped))
{
return false;
}
lock (_sync)
{
if (_tornadoState == mapped)
{
return false;
}
_tornadoState = mapped;
record = NativeOperatorLogRecord.ForTornadoStateChanged(mapped);
return true;
}
}
internal static bool TryMapDatabaseState(
DatabaseHealthState state,
out NativeOperatorDatabaseState mapped)
{
mapped = state switch
{
DatabaseHealthState.NotConfigured => NativeOperatorDatabaseState.NotConfigured,
DatabaseHealthState.Healthy => NativeOperatorDatabaseState.Healthy,
DatabaseHealthState.Unhealthy => NativeOperatorDatabaseState.Unhealthy,
DatabaseHealthState.TimedOut => NativeOperatorDatabaseState.TimedOut,
_ => default
};
return mapped != default;
}
internal static bool TryMapTornadoState(
PlayoutConnectionState state,
out NativeOperatorTornadoState mapped)
{
mapped = state switch
{
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,
_ => default
};
return mapped != default;
}
}
/// <summary>
/// Tracks the single serialized operator command without retaining request IDs or cue data.
/// Exactly one terminal record can be emitted for each accepted command.
/// </summary>
public sealed class NativeOperatorCommandLogTracker
{
private readonly object _sync = new();
private NativeOperatorPlayoutCommand? _currentCommand;
private bool _terminalRecorded;
public bool TryBegin(
NativeOperatorPlayoutCommand command,
out NativeOperatorLogRecord record)
{
record = default;
if (!Enum.IsDefined(command))
{
return false;
}
lock (_sync)
{
_currentCommand = command;
_terminalRecorded = false;
record = NativeOperatorLogRecord.ForPlayoutCommandRequested(command);
return true;
}
}
public bool TryComplete(
NativeOperatorPlayoutCommand command,
NativeOperatorPlayoutCompletion completion,
out NativeOperatorLogRecord record)
{
record = default;
if (!Enum.IsDefined(command) || !Enum.IsDefined(completion))
{
return false;
}
lock (_sync)
{
if (_currentCommand != command || _terminalRecorded)
{
return false;
}
_terminalRecorded = true;
record = NativeOperatorLogRecord.ForPlayoutCommandCompleted(command, completion);
return true;
}
}
public bool TryMarkOutcomeUnknown(
NativeOperatorPlayoutCommand command,
out NativeOperatorLogRecord record)
{
record = default;
if (!Enum.IsDefined(command))
{
return false;
}
lock (_sync)
{
return TryMarkOutcomeUnknownLocked(command, out record);
}
}
public bool TryMarkCurrentOutcomeUnknown(out NativeOperatorLogRecord record)
{
lock (_sync)
{
if (_currentCommand is not { } command)
{
record = default;
return false;
}
return TryMarkOutcomeUnknownLocked(command, out record);
}
}
private bool TryMarkOutcomeUnknownLocked(
NativeOperatorPlayoutCommand command,
out NativeOperatorLogRecord record)
{
record = default;
if (_currentCommand != command || _terminalRecorded)
{
return false;
}
_terminalRecorded = true;
record = NativeOperatorLogRecord.ForOutcomeUnknown(command);
return true;
}
}

View File

@@ -0,0 +1,553 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
namespace MBN_STOCK_WEBVIEW.Infrastructure.Diagnostics;
public enum NativeOperatorLogEvent
{
Startup = 1,
Shutdown = 2,
OracleStateChanged = 3,
MariaDbStateChanged = 4,
TornadoStateChanged = 5,
PlayoutCommandRequested = 6,
PlayoutCommandCompleted = 7,
OutcomeUnknown = 8
}
public enum NativeOperatorDatabaseState
{
NotConfigured = 1,
Healthy = 2,
Unhealthy = 3,
TimedOut = 4
}
public enum NativeOperatorTornadoState
{
Disabled = 1,
DryRunReady = 2,
ProcessMissing = 3,
Disconnected = 4,
Connecting = 5,
Connected = 6,
Reconnecting = 7,
Disconnecting = 8,
Faulted = 9,
OutcomeUnknown = 10,
Disposed = 11
}
public enum NativeOperatorPlayoutCommand
{
Connect = 1,
Disconnect = 2,
Prepare = 3,
TakeIn = 4,
Next = 5,
TakeOut = 6,
UpdateOnAir = 7
}
public enum NativeOperatorPlayoutCompletion
{
Succeeded = 1,
Rejected = 2,
Cancelled = 3,
Unavailable = 4,
Failed = 5
}
/// <summary>
/// A closed operator-audit record. It deliberately has no string, path, SQL, scene,
/// exception, or arbitrary numeric field, so untrusted/free-form data cannot enter the log.
/// </summary>
public readonly record struct NativeOperatorLogRecord
{
private NativeOperatorLogRecord(
NativeOperatorLogEvent logEvent,
NativeOperatorDatabaseState? databaseState = null,
NativeOperatorTornadoState? tornadoState = null,
NativeOperatorPlayoutCommand? command = null,
NativeOperatorPlayoutCompletion? completion = null)
{
Event = logEvent;
DatabaseState = databaseState;
TornadoState = tornadoState;
Command = command;
Completion = completion;
}
public NativeOperatorLogEvent Event { get; }
public NativeOperatorDatabaseState? DatabaseState { get; }
public NativeOperatorTornadoState? TornadoState { get; }
public NativeOperatorPlayoutCommand? Command { get; }
public NativeOperatorPlayoutCompletion? Completion { get; }
public static NativeOperatorLogRecord ForStartup() =>
new(NativeOperatorLogEvent.Startup);
public static NativeOperatorLogRecord ForShutdown() =>
new(NativeOperatorLogEvent.Shutdown);
public static NativeOperatorLogRecord ForOracleStateChanged(
NativeOperatorDatabaseState state) =>
new(NativeOperatorLogEvent.OracleStateChanged, databaseState: state);
public static NativeOperatorLogRecord ForMariaDbStateChanged(
NativeOperatorDatabaseState state) =>
new(NativeOperatorLogEvent.MariaDbStateChanged, databaseState: state);
public static NativeOperatorLogRecord ForTornadoStateChanged(
NativeOperatorTornadoState state) =>
new(NativeOperatorLogEvent.TornadoStateChanged, tornadoState: state);
public static NativeOperatorLogRecord ForPlayoutCommandRequested(
NativeOperatorPlayoutCommand command) =>
new(NativeOperatorLogEvent.PlayoutCommandRequested, command: command);
public static NativeOperatorLogRecord ForPlayoutCommandCompleted(
NativeOperatorPlayoutCommand command,
NativeOperatorPlayoutCompletion completion) =>
new(
NativeOperatorLogEvent.PlayoutCommandCompleted,
command: command,
completion: completion);
public static NativeOperatorLogRecord ForOutcomeUnknown(
NativeOperatorPlayoutCommand command) =>
new(NativeOperatorLogEvent.OutcomeUnknown, command: command);
}
public interface INativeOperatorLogWriter
{
/// <summary>
/// Attempts one bounded append. No logging failure is allowed to escape to broadcast logic.
/// </summary>
bool TryWrite(NativeOperatorLogRecord record);
}
/// <summary>
/// Writes closed native operator events beneath
/// %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Logs\Log_MMdd.log.
/// </summary>
public sealed class NativeOperatorLogWriter : INativeOperatorLogWriter
{
internal const int DefaultMaximumFileBytes = 4 * 1024 * 1024;
internal const int DefaultRetentionDays = 31;
internal const int MaximumEncodedLineBytes = 512;
private const string ApplicationDirectoryName = "MBN_STOCK_WEBVIEW";
private const string LogDirectoryName = "Logs";
private static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
private static readonly ConcurrentDictionary<string, object> PathGates =
new(StringComparer.OrdinalIgnoreCase);
private readonly string _localApplicationDataRoot;
private readonly TimeProvider _timeProvider;
private readonly int _maximumFileBytes;
private readonly int _retentionDays;
public NativeOperatorLogWriter()
: this(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
TimeProvider.System,
DefaultMaximumFileBytes,
DefaultRetentionDays)
{
}
internal NativeOperatorLogWriter(
string localApplicationDataRoot,
TimeProvider timeProvider,
int maximumFileBytes = DefaultMaximumFileBytes,
int retentionDays = DefaultRetentionDays)
{
_localApplicationDataRoot = localApplicationDataRoot;
_timeProvider = timeProvider;
_maximumFileBytes = maximumFileBytes;
_retentionDays = retentionDays;
}
public bool TryWrite(NativeOperatorLogRecord record)
{
try
{
if (_timeProvider is null ||
_maximumFileBytes <= 0 ||
_retentionDays <= 0 ||
!TryFormatPayload(record, out var payload) ||
!TryResolvePaths(out var localRoot, out var applicationDirectory, out var logDirectory))
{
return false;
}
var gate = PathGates.GetOrAdd(logDirectory, static _ => new object());
lock (gate)
{
var localNow = _timeProvider.GetLocalNow();
if (!TryPrepareDirectories(localRoot, applicationDirectory, logDirectory))
{
return false;
}
using var directoryLease = global::MBN_STOCK_WEBVIEW.Infrastructure
.TrustedManualDirectory.AcquirePrivateWritableLease(logDirectory);
if (!TryApplyRetention(logDirectory, localNow.UtcDateTime))
{
return false;
}
var fileName = $"Log_{localNow:MMdd}.log";
var logPath = Path.GetFullPath(Path.Combine(logDirectory, fileName));
if (!IsImmediateChild(logDirectory, logPath) ||
!string.Equals(Path.GetFileName(logPath), fileName, StringComparison.Ordinal))
{
return false;
}
var line = $"[{localNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fffzzz", CultureInfo.InvariantCulture)}] {payload}{Environment.NewLine}";
var encodedLine = Utf8.GetBytes(line);
if (encodedLine.Length == 0 || encodedLine.Length > MaximumEncodedLineBytes)
{
return false;
}
return TryAppend(logDirectory, logPath, encodedLine);
}
}
catch
{
// Audit logging is best effort and must never interrupt playout or database work.
return false;
}
}
private bool TryResolvePaths(
out string localRoot,
out string applicationDirectory,
out string logDirectory)
{
localRoot = string.Empty;
applicationDirectory = string.Empty;
logDirectory = string.Empty;
if (string.IsNullOrWhiteSpace(_localApplicationDataRoot) ||
!Path.IsPathFullyQualified(_localApplicationDataRoot))
{
return false;
}
localRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_localApplicationDataRoot));
applicationDirectory = Path.GetFullPath(Path.Combine(localRoot, ApplicationDirectoryName));
logDirectory = Path.GetFullPath(Path.Combine(applicationDirectory, LogDirectoryName));
return IsImmediateChild(localRoot, applicationDirectory) &&
IsImmediateChild(applicationDirectory, logDirectory);
}
private static bool TryPrepareDirectories(
string localRoot,
string applicationDirectory,
string logDirectory)
{
if (!Directory.Exists(localRoot) ||
!TryGetSafeDirectoryAttributes(localRoot))
{
return false;
}
var prepared = global::MBN_STOCK_WEBVIEW.Infrastructure.TrustedManualDirectory
.PreparePrivateWritableDirectory(logDirectory);
if (!string.Equals(
Path.TrimEndingDirectorySeparator(prepared),
Path.TrimEndingDirectorySeparator(logDirectory),
StringComparison.OrdinalIgnoreCase))
{
return false;
}
// The lease acquired by the caller pins the chain after this validation.
return TryGetSafeDirectoryAttributes(localRoot) &&
TryGetSafeDirectoryAttributes(applicationDirectory) &&
TryGetSafeDirectoryAttributes(logDirectory);
}
private bool TryApplyRetention(string logDirectory, DateTime utcNow)
{
var cutoff = utcNow.AddDays(-_retentionDays);
foreach (var entry in Directory.EnumerateFileSystemEntries(
logDirectory,
"Log_????.log",
SearchOption.TopDirectoryOnly))
{
var fullPath = Path.GetFullPath(entry);
var fileName = Path.GetFileName(fullPath);
if (!IsImmediateChild(logDirectory, fullPath) ||
!IsLogFileName(fileName))
{
return false;
}
var attributes = File.GetAttributes(fullPath);
if (!IsSafeFileAttributes(attributes))
{
return false;
}
if (File.GetLastWriteTimeUtc(fullPath) < cutoff)
{
File.Delete(fullPath);
}
}
return TryGetSafeDirectoryAttributes(logDirectory);
}
private bool TryAppend(string logDirectory, string logPath, byte[] encodedLine)
{
if (File.Exists(logPath) && !TryGetSafeFileAttributes(logPath))
{
return false;
}
using var stream = new FileStream(
logPath,
FileMode.OpenOrCreate,
FileAccess.Write,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan);
if (!string.Equals(Path.GetFullPath(stream.Name), logPath, StringComparison.OrdinalIgnoreCase) ||
!TryGetSafeDirectoryAttributes(logDirectory) ||
!TryGetSafeFileAttributes(logPath) ||
stream.Length > _maximumFileBytes - encodedLine.Length)
{
return false;
}
global::MBN_STOCK_WEBVIEW.Infrastructure.TrustedDirectoryLease
.EnsureRegularFileHandle(
stream.SafeFileHandle,
logPath,
_maximumFileBytes,
allowEmpty: true);
stream.Seek(0, SeekOrigin.End);
stream.Write(encodedLine, 0, encodedLine.Length);
stream.Flush(flushToDisk: false);
return true;
}
private static bool TryFormatPayload(
NativeOperatorLogRecord record,
out string payload)
{
payload = string.Empty;
switch (record.Event)
{
case NativeOperatorLogEvent.Startup
when HasNoDetails(record):
payload = "event=Startup";
return true;
case NativeOperatorLogEvent.Shutdown
when HasNoDetails(record):
payload = "event=Shutdown";
return true;
case NativeOperatorLogEvent.OracleStateChanged
when record.DatabaseState is { } oracleState && HasOnlyDatabaseState(record):
return TryDatabaseStateToken(oracleState, out payload, "OracleStateChanged");
case NativeOperatorLogEvent.MariaDbStateChanged
when record.DatabaseState is { } mariaDbState && HasOnlyDatabaseState(record):
return TryDatabaseStateToken(mariaDbState, out payload, "MariaDbStateChanged");
case NativeOperatorLogEvent.TornadoStateChanged
when record.TornadoState is { } tornadoState && HasOnlyTornadoState(record):
return TryTornadoStateToken(tornadoState, out payload);
case NativeOperatorLogEvent.PlayoutCommandRequested
when record.Command is { } requestedCommand && HasOnlyCommand(record):
return TryCommandToken(requestedCommand, out payload, "PlayoutCommandRequested");
case NativeOperatorLogEvent.PlayoutCommandCompleted
when record.Command is { } completedCommand &&
record.Completion is { } completion &&
HasOnlyCommandAndCompletion(record):
return TryCompletionToken(completedCommand, completion, out payload);
case NativeOperatorLogEvent.OutcomeUnknown
when record.Command is { } unknownCommand && HasOnlyCommand(record):
return TryCommandToken(unknownCommand, out payload, "OutcomeUnknown");
default:
return false;
}
}
private static bool HasNoDetails(NativeOperatorLogRecord record) =>
record.DatabaseState is null &&
record.TornadoState is null &&
record.Command is null &&
record.Completion is null;
private static bool HasOnlyDatabaseState(NativeOperatorLogRecord record) =>
record.DatabaseState is not null &&
record.TornadoState is null &&
record.Command is null &&
record.Completion is null;
private static bool HasOnlyTornadoState(NativeOperatorLogRecord record) =>
record.DatabaseState is null &&
record.TornadoState is not null &&
record.Command is null &&
record.Completion is null;
private static bool HasOnlyCommand(NativeOperatorLogRecord record) =>
record.DatabaseState is null &&
record.TornadoState is null &&
record.Command is not null &&
record.Completion is null;
private static bool HasOnlyCommandAndCompletion(NativeOperatorLogRecord record) =>
record.DatabaseState is null &&
record.TornadoState is null &&
record.Command is not null &&
record.Completion is not null;
private static bool TryDatabaseStateToken(
NativeOperatorDatabaseState state,
out string payload,
string eventName)
{
var stateToken = state switch
{
NativeOperatorDatabaseState.NotConfigured => "NotConfigured",
NativeOperatorDatabaseState.Healthy => "Healthy",
NativeOperatorDatabaseState.Unhealthy => "Unhealthy",
NativeOperatorDatabaseState.TimedOut => "TimedOut",
_ => null
};
payload = stateToken is null ? string.Empty : $"event={eventName} state={stateToken}";
return stateToken is not null;
}
private static bool TryTornadoStateToken(
NativeOperatorTornadoState state,
out string payload)
{
var stateToken = state switch
{
NativeOperatorTornadoState.Disabled => "Disabled",
NativeOperatorTornadoState.DryRunReady => "DryRunReady",
NativeOperatorTornadoState.ProcessMissing => "ProcessMissing",
NativeOperatorTornadoState.Disconnected => "Disconnected",
NativeOperatorTornadoState.Connecting => "Connecting",
NativeOperatorTornadoState.Connected => "Connected",
NativeOperatorTornadoState.Reconnecting => "Reconnecting",
NativeOperatorTornadoState.Disconnecting => "Disconnecting",
NativeOperatorTornadoState.Faulted => "Faulted",
NativeOperatorTornadoState.OutcomeUnknown => "OutcomeUnknown",
NativeOperatorTornadoState.Disposed => "Disposed",
_ => null
};
payload = stateToken is null
? string.Empty
: $"event=TornadoStateChanged state={stateToken}";
return stateToken is not null;
}
private static bool TryCommandToken(
NativeOperatorPlayoutCommand command,
out string payload,
string eventName)
{
var commandToken = CommandToken(command);
payload = commandToken is null
? string.Empty
: $"event={eventName} command={commandToken}";
return commandToken is not null;
}
private static bool TryCompletionToken(
NativeOperatorPlayoutCommand command,
NativeOperatorPlayoutCompletion completion,
out string payload)
{
var commandToken = CommandToken(command);
var completionToken = completion switch
{
NativeOperatorPlayoutCompletion.Succeeded => "Succeeded",
NativeOperatorPlayoutCompletion.Rejected => "Rejected",
NativeOperatorPlayoutCompletion.Cancelled => "Cancelled",
NativeOperatorPlayoutCompletion.Unavailable => "Unavailable",
NativeOperatorPlayoutCompletion.Failed => "Failed",
_ => null
};
payload = commandToken is null || completionToken is null
? string.Empty
: $"event=PlayoutCommandCompleted command={commandToken} result={completionToken}";
return commandToken is not null && completionToken is not null;
}
private static string? CommandToken(NativeOperatorPlayoutCommand command) =>
command switch
{
NativeOperatorPlayoutCommand.Connect => "Connect",
NativeOperatorPlayoutCommand.Disconnect => "Disconnect",
NativeOperatorPlayoutCommand.Prepare => "Prepare",
NativeOperatorPlayoutCommand.TakeIn => "TakeIn",
NativeOperatorPlayoutCommand.Next => "Next",
NativeOperatorPlayoutCommand.TakeOut => "TakeOut",
NativeOperatorPlayoutCommand.UpdateOnAir => "UpdateOnAir",
_ => null
};
private static bool TryGetSafeDirectoryAttributes(string path)
{
try
{
return Directory.Exists(path) &&
IsSafeDirectoryAttributes(File.GetAttributes(path));
}
catch
{
return false;
}
}
private static bool TryGetSafeFileAttributes(string path)
{
try
{
return File.Exists(path) &&
IsSafeFileAttributes(File.GetAttributes(path));
}
catch
{
return false;
}
}
internal static bool IsSafeDirectoryAttributes(FileAttributes attributes) =>
attributes.HasFlag(FileAttributes.Directory) &&
!attributes.HasFlag(FileAttributes.ReparsePoint) &&
!attributes.HasFlag(FileAttributes.Device);
internal static bool IsSafeFileAttributes(FileAttributes attributes) =>
!attributes.HasFlag(FileAttributes.Directory) &&
!attributes.HasFlag(FileAttributes.ReparsePoint) &&
!attributes.HasFlag(FileAttributes.Device);
private static bool IsImmediateChild(string parent, string candidate)
{
var expectedParent = Path.TrimEndingDirectorySeparator(Path.GetFullPath(parent));
var actualParent = Path.GetDirectoryName(Path.GetFullPath(candidate));
return string.Equals(expectedParent, actualParent, StringComparison.OrdinalIgnoreCase);
}
private static bool IsLogFileName(string fileName) =>
fileName.Length == 12 &&
fileName.StartsWith("Log_", StringComparison.Ordinal) &&
fileName.EndsWith(".log", StringComparison.Ordinal) &&
fileName.AsSpan(4, 4).IndexOfAnyExceptInRange('0', '9') < 0;
}

View File

@@ -247,8 +247,14 @@ public sealed class OperatorCatalogMutationExecutor : IOperatorCatalogMutationEx
OperatorCatalogMutationKind.CreateTheme or
OperatorCatalogMutationKind.ReplaceTheme or
OperatorCatalogMutationKind.DeleteTheme;
var identityLength = themeMutation ? 8 : 4;
if (transaction.IdentityCode.Length != identityLength ||
var validIdentityLength = transaction.Kind switch
{
OperatorCatalogMutationKind.CreateTheme => transaction.IdentityCode.Length == 8,
OperatorCatalogMutationKind.ReplaceTheme or OperatorCatalogMutationKind.DeleteTheme =>
transaction.IdentityCode.Length is >= 3 and <= 8,
_ => transaction.IdentityCode.Length == 4
};
if (!validIdentityLength ||
transaction.IdentityCode.Any(static character => !char.IsAsciiDigit(character)) ||
!themeMutation && transaction.Source != DataSourceKind.Oracle)
{

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ public sealed class PlayoutOptions
/// <summary>
/// Trusted MainForm-level background selection. The asset is always a path relative
/// to SceneDirectory and is never accepted from Web content.
/// to LegacyBackgroundDirectory and is never accepted from Web content.
/// </summary>
public LegacySceneBackgroundKind LegacySceneBackgroundKind { get; set; } =
LegacySceneBackgroundKind.None;
@@ -37,6 +37,14 @@ public sealed class PlayoutOptions
public bool LegacySceneBackgroundVideoLoopInfinite { get; set; } = true;
/// <summary>
/// External absolute root containing operator-selectable MainForm backgrounds.
/// When omitted, the original layout is retained by deriving a sibling "배경"
/// directory from SceneDirectory (Cuts\..\배경). This root is never merged with
/// the trusted scene/Cuts root.
/// </summary>
public string? LegacyBackgroundDirectory { get; set; }
/// <summary>
/// External absolute root containing vendor .t2s scenes. Required in Test and Live modes.
/// </summary>

View File

@@ -98,6 +98,9 @@ public static class PlayoutOptionsLoader
ApplyBool(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE",
value => options.LegacySceneBackgroundVideoLoopInfinite = value);
ApplyString(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY",
value => options.LegacyBackgroundDirectory = value);
ApplyString("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", value => options.SceneDirectory = value);
ApplyString(
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",

View File

@@ -1,3 +1,4 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
@@ -8,6 +9,8 @@ namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
/// </summary>
public static class PlayoutSceneCompositionFactory
{
public const string LegacyDefaultBackgroundFileName = "기본.vrv";
private static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
[
".png", ".jpg", ".jpeg", ".bmp", ".tga", ".dds",
@@ -17,12 +20,7 @@ public static class PlayoutSceneCompositionFactory
public static LegacySceneCueCompositionOptions Create(PlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (options.LegacySceneFadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
{
throw Invalid();
}
ValidateCommon(options.LegacySceneFadeDuration, options);
if (options.LegacySceneBackgroundKind == LegacySceneBackgroundKind.None)
{
@@ -36,74 +34,180 @@ public static class PlayoutSceneCompositionFactory
LegacySceneBackgroundKind.None);
}
var rootText = options.SceneDirectory?.Trim();
var relativeText = options.LegacySceneBackgroundAssetPath?.Trim();
if (string.IsNullOrEmpty(rootText) || !Path.IsPathFullyQualified(rootText) ||
string.IsNullOrEmpty(relativeText) || Path.IsPathRooted(relativeText) ||
relativeText.Contains("..", StringComparison.Ordinal) ||
relativeText.Contains(':') || relativeText.Any(char.IsControl) ||
!AllowedExtensions.Contains(Path.GetExtension(relativeText)))
{
throw Invalid();
}
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootText));
if (!Directory.Exists(root) || IsReparsePoint(root))
{
throw Invalid();
}
var candidate = Path.GetFullPath(Path.Combine(root, relativeText));
var rootPrefix = root + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
{
throw Invalid();
}
var normalizedRelative = Path.GetRelativePath(root, candidate);
return new LegacySceneCueCompositionOptions(
options.LegacySceneFadeDuration,
options.LegacySceneBackgroundKind,
normalizedRelative,
options.LegacySceneBackgroundVideoLoopCount,
options.LegacySceneBackgroundVideoLoopInfinite);
}
private static bool HasReparsePointBetween(string root, string file)
{
if (IsReparsePoint(file))
{
return true;
}
var directory = Path.GetDirectoryName(file);
while (!string.IsNullOrEmpty(directory) &&
!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase))
{
if (IsReparsePoint(directory))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
private static bool IsReparsePoint(string path)
{
try
{
return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0;
var root = RequireTrustedBackgroundDirectory(options);
var candidate = TrustedPlayoutAssetPath.ResolveRelativeFile(
root,
options.LegacySceneBackgroundAssetPath ?? string.Empty,
AllowedExtensions);
return CreateResult(
options,
options.LegacySceneFadeDuration,
options.LegacySceneBackgroundKind,
Path.GetRelativePath(root, candidate));
}
catch (TrustedPlayoutAssetException)
{
throw Invalid();
}
}
/// <summary>
/// Validates one F2 picker result against the separate background root. An
/// absolute path is accepted only at this native boundary and is converted back
/// to a closed relative path before it reaches the scene builder.
/// </summary>
public static LegacySceneCueCompositionOptions CreateOperatorSelection(
PlayoutOptions options,
int fadeDuration,
string selectedAbsolutePath)
{
ArgumentNullException.ThrowIfNull(options);
ValidateCommon(fadeDuration, options);
try
{
var root = RequireTrustedBackgroundDirectory(options);
var candidate = TrustedPlayoutAssetPath.ResolveAbsoluteFile(
root,
selectedAbsolutePath,
AllowedExtensions);
var extension = Path.GetExtension(candidate);
var kind = string.Equals(extension, ".vrv", StringComparison.OrdinalIgnoreCase)
? LegacySceneBackgroundKind.Video
: LegacySceneBackgroundKind.Texture;
return CreateResult(
options,
fadeDuration,
kind,
Path.GetRelativePath(root, candidate));
}
catch (TrustedPlayoutAssetException)
{
throw Invalid();
}
}
/// <summary>Restores the original F3-on default, 배경\기본.vrv.</summary>
public static LegacySceneCueCompositionOptions CreateLegacyDefault(
PlayoutOptions options,
int fadeDuration)
{
ArgumentNullException.ThrowIfNull(options);
ValidateCommon(fadeDuration, options);
try
{
var root = RequireTrustedBackgroundDirectory(options);
var candidate = TrustedPlayoutAssetPath.ResolveRelativeFile(
root,
LegacyDefaultBackgroundFileName,
AllowedExtensions);
return CreateResult(
options,
fadeDuration,
LegacySceneBackgroundKind.Video,
Path.GetRelativePath(root, candidate));
}
catch (TrustedPlayoutAssetException)
{
throw Invalid();
}
}
public static bool TryGetTrustedBackgroundDirectory(
PlayoutOptions options,
out string directory)
{
ArgumentNullException.ThrowIfNull(options);
directory = string.Empty;
try
{
directory = TrustedPlayoutAssetPath.ValidateRoot(
RequireConfiguredBackgroundDirectory(options));
return true;
}
catch
{
directory = string.Empty;
return false;
}
}
internal static bool TryGetConfiguredBackgroundDirectory(
PlayoutOptions options,
out string directory)
{
ArgumentNullException.ThrowIfNull(options);
directory = string.Empty;
try
{
directory = RequireConfiguredBackgroundDirectory(options);
return true;
}
catch
{
directory = string.Empty;
return false;
}
}
private static string RequireTrustedBackgroundDirectory(PlayoutOptions options) =>
TrustedPlayoutAssetPath.ValidateRoot(RequireConfiguredBackgroundDirectory(options));
private static string RequireConfiguredBackgroundDirectory(PlayoutOptions options)
{
var configured = options.LegacyBackgroundDirectory?.Trim();
if (string.IsNullOrEmpty(configured))
{
var sceneDirectory = options.SceneDirectory?.Trim();
if (string.IsNullOrEmpty(sceneDirectory) ||
!Path.IsPathFullyQualified(sceneDirectory))
{
throw new TrustedPlayoutAssetException();
}
var cuts = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sceneDirectory));
var parent = Path.GetDirectoryName(cuts);
if (string.IsNullOrEmpty(parent))
{
throw new TrustedPlayoutAssetException();
}
configured = Path.Combine(parent, "배경");
}
if (!Path.IsPathFullyQualified(configured) || configured.Any(char.IsControl))
{
throw new TrustedPlayoutAssetException();
}
return Path.TrimEndingDirectorySeparator(Path.GetFullPath(configured));
}
private static LegacySceneCueCompositionOptions CreateResult(
PlayoutOptions options,
int fadeDuration,
LegacySceneBackgroundKind kind,
string relativePath) => new(
fadeDuration,
kind,
relativePath,
options.LegacySceneBackgroundVideoLoopCount,
options.LegacySceneBackgroundVideoLoopInfinite,
PlayoutAssetRoot.OperatorBackgroundDirectory);
private static void ValidateCommon(int fadeDuration, PlayoutOptions options)
{
if (fadeDuration is < 0 or > 60 ||
!Enum.IsDefined(options.LegacySceneBackgroundKind) ||
options.LegacySceneBackgroundVideoLoopCount is < 0 or > 10_000)
{
throw Invalid();
}
}
private static PlayoutConfigurationException Invalid() => new(
"공통 장면 배경 설정이 올바르지 않거나 허용된 scene 폴더의 자산을 찾을 수 없습니다.");
"신뢰 배경 루트 안의 일반 배경 파일을 확인할 수 없습니다.");
}

View File

@@ -0,0 +1,400 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace MBN_STOCK_WEBVIEW.Playout.Configuration;
/// <summary>
/// Resolves a read-only vendor asset from one already-trusted root. Windows handles
/// are used for the directory ancestry and the file itself so that path checks do not
/// silently follow a reparse point or accept a hard-linked file.
/// </summary>
internal static class TrustedPlayoutAssetPath
{
public static string ValidateRoot(string root)
{
if (string.IsNullOrWhiteSpace(root) || root.IndexOf('\0') >= 0 ||
!Path.IsPathFullyQualified(root))
{
throw Invalid();
}
var fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
if (!Directory.Exists(fullRoot))
{
throw Invalid();
}
using var lease = TrustedReadOnlyDirectoryLease.Acquire(fullRoot);
return fullRoot;
}
public static string ResolveRelativeFile(
string root,
string relativePath,
IReadOnlySet<string> allowedExtensions)
{
ArgumentNullException.ThrowIfNull(allowedExtensions);
relativePath = relativePath?.Trim() ?? string.Empty;
if (relativePath.Length == 0 || relativePath.Length > 512 ||
Path.IsPathRooted(relativePath) ||
relativePath.Contains("..", StringComparison.Ordinal) ||
relativePath.Contains(':') || relativePath.Any(char.IsControl) ||
!allowedExtensions.Contains(Path.GetExtension(relativePath)))
{
throw Invalid();
}
var fullRoot = ValidateRoot(root);
var candidate = Path.GetFullPath(Path.Combine(fullRoot, relativePath));
return ResolveCanonicalFile(fullRoot, candidate);
}
public static string ResolveAbsoluteFile(
string root,
string absolutePath,
IReadOnlySet<string> allowedExtensions)
{
ArgumentNullException.ThrowIfNull(allowedExtensions);
if (string.IsNullOrWhiteSpace(absolutePath) || absolutePath.Length > 32_767 ||
absolutePath.Any(char.IsControl) || !Path.IsPathFullyQualified(absolutePath) ||
!allowedExtensions.Contains(Path.GetExtension(absolutePath)))
{
throw Invalid();
}
var fullRoot = ValidateRoot(root);
var candidate = Path.GetFullPath(absolutePath);
return ResolveCanonicalFile(fullRoot, candidate);
}
internal static void EnsureOpenedRegularFile(
SafeFileHandle handle,
string expectedPath)
{
if (!OperatingSystem.IsWindows())
{
var attributes = File.GetAttributes(expectedPath);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw Invalid();
}
return;
}
if (handle.IsInvalid ||
!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes &
(FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0 ||
information.NumberOfLinks != 1 ||
(((long)information.FileSizeHigh << 32) | information.FileSizeLow) <= 0 ||
!string.Equals(
GetFinalPath(handle),
Path.GetFullPath(expectedPath),
StringComparison.OrdinalIgnoreCase))
{
throw Invalid();
}
}
private static string ResolveCanonicalFile(string fullRoot, string candidate)
{
var rootPrefix = fullRoot + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate))
{
throw Invalid();
}
try
{
// The lease excludes FILE_SHARE_DELETE for every ancestor while the file
// handle is checked. The file handle itself is also bound to the canonical
// final path and must have exactly one link.
using var lease = TrustedReadOnlyDirectoryLease.Acquire(
Path.GetDirectoryName(candidate) ?? throw Invalid());
using var stream = new FileStream(
candidate,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 1,
FileOptions.RandomAccess);
EnsureOpenedRegularFile(stream.SafeFileHandle, candidate);
return candidate;
}
catch (TrustedPlayoutAssetException)
{
throw;
}
catch
{
throw Invalid();
}
}
private static string GetFinalPath(SafeFileHandle handle)
{
var capacity = 512;
while (capacity <= 32_768)
{
var buffer = new StringBuilder(capacity);
var length = GetFinalPathNameByHandleW(
handle,
buffer,
(uint)buffer.Capacity,
FileNameNormalized);
if (length == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (length < buffer.Capacity)
{
var path = buffer.ToString();
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
{
path = "\\\\" + path[8..];
}
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
{
path = path[4..];
}
return Path.GetFullPath(path);
}
capacity = checked((int)length + 1);
}
throw Invalid();
}
private static TrustedPlayoutAssetException Invalid() => new();
private const uint FileNameNormalized = 0;
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file,
out ByHandleFileInformation information);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle file,
StringBuilder path,
uint pathLength,
uint flags);
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public FileAttributes FileAttributes;
public FileTime CreationTime;
public FileTime LastAccessTime;
public FileTime LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
}
}
internal sealed class TrustedReadOnlyDirectoryLease : IDisposable
{
private const uint GenericRead = 0x80000000;
private const uint OpenExisting = 3;
private const uint FileFlagBackupSemantics = 0x02000000;
private const uint FileFlagOpenReparsePoint = 0x00200000;
private const uint FileNameNormalized = 0;
private readonly IReadOnlyList<SafeFileHandle> _handles;
private TrustedReadOnlyDirectoryLease(IReadOnlyList<SafeFileHandle> handles)
{
_handles = handles;
}
public static TrustedReadOnlyDirectoryLease Acquire(string directory)
{
if (!OperatingSystem.IsWindows())
{
var attributes = File.GetAttributes(directory);
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) !=
FileAttributes.Directory)
{
throw new TrustedPlayoutAssetException();
}
return new TrustedReadOnlyDirectoryLease(Array.Empty<SafeFileHandle>());
}
var ancestors = new List<string>();
for (var current = new DirectoryInfo(directory); current is not null; current = current.Parent)
{
ancestors.Add(current.FullName);
}
ancestors.Reverse();
var handles = new List<SafeFileHandle>(ancestors.Count);
try
{
foreach (var ancestor in ancestors)
{
var handle = CreateFileW(
ancestor,
GenericRead,
FileShare.Read | FileShare.Write,
IntPtr.Zero,
OpenExisting,
FileFlagBackupSemantics | FileFlagOpenReparsePoint,
IntPtr.Zero);
if (handle.IsInvalid)
{
handle.Dispose();
throw new Win32Exception(Marshal.GetLastWin32Error());
}
try
{
EnsureDirectDirectory(handle, ancestor);
}
catch
{
handle.Dispose();
throw;
}
handles.Add(handle);
}
return new TrustedReadOnlyDirectoryLease(handles.AsReadOnly());
}
catch
{
foreach (var handle in handles)
{
handle.Dispose();
}
throw new TrustedPlayoutAssetException();
}
}
public void Dispose()
{
foreach (var handle in _handles.Reverse())
{
handle.Dispose();
}
}
private static void EnsureDirectDirectory(SafeFileHandle handle, string expectedPath)
{
if (!GetFileInformationByHandle(handle, out var information) ||
(information.FileAttributes & FileAttributes.Directory) == 0 ||
(information.FileAttributes & FileAttributes.ReparsePoint) != 0 ||
!string.Equals(
GetFinalPath(handle),
Path.GetFullPath(expectedPath),
StringComparison.OrdinalIgnoreCase))
{
throw new TrustedPlayoutAssetException();
}
}
private static string GetFinalPath(SafeFileHandle handle)
{
var capacity = 512;
while (capacity <= 32_768)
{
var buffer = new StringBuilder(capacity);
var length = GetFinalPathNameByHandleW(
handle,
buffer,
(uint)buffer.Capacity,
FileNameNormalized);
if (length == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
if (length < buffer.Capacity)
{
var path = buffer.ToString();
if (path.StartsWith("\\\\?\\UNC\\", StringComparison.OrdinalIgnoreCase))
{
path = "\\\\" + path[8..];
}
else if (path.StartsWith("\\\\?\\", StringComparison.Ordinal))
{
path = path[4..];
}
return Path.GetFullPath(path);
}
capacity = checked((int)length + 1);
}
throw new TrustedPlayoutAssetException();
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string fileName,
uint desiredAccess,
FileShare shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetFileInformationByHandle(
SafeFileHandle file,
out ByHandleFileInformation information);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle file,
StringBuilder path,
uint pathLength,
uint flags);
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public FileAttributes FileAttributes;
public FileTime CreationTime;
public FileTime LastAccessTime;
public FileTime LastWriteTime;
public uint VolumeSerialNumber;
public uint FileSizeHigh;
public uint FileSizeLow;
public uint NumberOfLinks;
public uint FileIndexHigh;
public uint FileIndexLow;
}
[StructLayout(LayoutKind.Sequential)]
private struct FileTime
{
public uint Low;
public uint High;
}
}
internal sealed class TrustedPlayoutAssetException : Exception;

View File

@@ -15,6 +15,7 @@ internal sealed record ValidatedPlayoutOptions(
int? OutputChannel,
int LayoutIndex,
string? SceneDirectory,
string? OperatorBackgroundDirectory,
Regex? TestProcessWindowTitleRegex,
IReadOnlySet<string> TestSceneAllowlist,
bool TrustedLiveOutputEnabled,
@@ -59,6 +60,13 @@ internal sealed record ValidatedPlayoutOptions(
sceneDirectory = ValidateSceneDirectory(options.SceneDirectory);
}
var operatorBackgroundDirectory =
PlayoutSceneCompositionFactory.TryGetConfiguredBackgroundDirectory(
options,
out var configuredBackgroundDirectory)
? configuredBackgroundDirectory
: null;
RequireRange(options.QueueCapacity, 1, 4_096, nameof(options.QueueCapacity));
RequireRange(
options.ConnectTimeoutMilliseconds,
@@ -156,6 +164,7 @@ internal sealed record ValidatedPlayoutOptions(
options.OutputChannel,
options.LayoutIndex,
sceneDirectory,
operatorBackgroundDirectory,
titleRegex,
allowlist,
options.TrustedLiveOutputEnabled,
@@ -356,9 +365,14 @@ internal sealed record ValidatedPlayoutOptions(
return background;
case PlayoutSetBackgroundTexture texture:
ValidateMutationTiming(texture.Timing);
return texture with { AssetPath = ResolveAssetPath(texture.AssetPath) };
ValidateAssetRoot(texture.AssetRoot);
return texture with
{
AssetPath = ResolveAssetPath(texture.AssetPath, texture.AssetRoot)
};
case PlayoutSetBackgroundVideo video:
ValidateMutationTiming(video.Timing);
ValidateAssetRoot(video.AssetRoot);
// The legacy MainForm intentionally uses 2004 for the common studio
// background. Keep the input bounded while retaining that vendor value.
if (video.LoopCount is < 0 or > 10_000)
@@ -366,16 +380,28 @@ internal sealed record ValidatedPlayoutOptions(
throw new PlayoutRequestException("장면 배경 영상 반복 값이 올바르지 않습니다.");
}
return video with { AssetPath = ResolveAssetPath(video.AssetPath) };
return video with
{
AssetPath = ResolveAssetPath(video.AssetPath, video.AssetRoot)
};
default:
throw new PlayoutRequestException("지원하지 않는 장면 변경 항목입니다.");
}
}
private string ResolveAssetPath(string? relativePath)
=> ResolveAssetPath(relativePath, PlayoutAssetRoot.SceneDirectory);
private string ResolveAssetPath(string? relativePath, PlayoutAssetRoot assetRoot)
{
relativePath = relativePath?.Trim();
if (SceneDirectory is null || string.IsNullOrEmpty(relativePath) ||
var root = assetRoot switch
{
PlayoutAssetRoot.SceneDirectory => SceneDirectory,
PlayoutAssetRoot.OperatorBackgroundDirectory => OperatorBackgroundDirectory,
_ => null
};
if (root is null || string.IsNullOrEmpty(relativePath) ||
Path.IsPathRooted(relativePath) || relativePath.Contains("..", StringComparison.Ordinal) ||
relativePath.Any(char.IsControl))
{
@@ -393,12 +419,28 @@ internal sealed record ValidatedPlayoutOptions(
throw new PlayoutRequestException("지원하지 않는 장면 자산 형식입니다.");
}
var candidate = Path.GetFullPath(Path.Combine(SceneDirectory, relativePath));
var rootPrefix = SceneDirectory.EndsWith(Path.DirectorySeparatorChar)
? SceneDirectory
: SceneDirectory + Path.DirectorySeparatorChar;
if (assetRoot == PlayoutAssetRoot.OperatorBackgroundDirectory)
{
try
{
return TrustedPlayoutAssetPath.ResolveRelativeFile(
root,
relativePath,
new HashSet<string>(allowedExtensions, StringComparer.OrdinalIgnoreCase));
}
catch (TrustedPlayoutAssetException)
{
throw new PlayoutRequestException(
"신뢰 배경 파일을 확인할 수 없습니다.");
}
}
var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
var rootPrefix = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(candidate) || HasReparsePointBetween(SceneDirectory, candidate))
!File.Exists(candidate) || HasReparsePointBetween(root, candidate))
{
throw new PlayoutRequestException("허용된 장면 자산을 찾을 수 없습니다.");
}
@@ -469,6 +511,14 @@ internal sealed record ValidatedPlayoutOptions(
}
}
private static void ValidateAssetRoot(PlayoutAssetRoot assetRoot)
{
if (!Enum.IsDefined(assetRoot))
{
throw new PlayoutRequestException("배경 자산 루트가 올바르지 않습니다.");
}
}
private static bool IsByte(int value) => value is >= byte.MinValue and <= byte.MaxValue;
private static bool CanMatchProgramTitle(Regex regex)

View File

@@ -55,8 +55,8 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
string builderKey)
{
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
StockIdentity("첫번째", "111111"),
StockIdentity("두번째", "222222"));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = await resolver.ResolveAsync(Entry(
@@ -65,7 +65,7 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
subject: " 첫번째 , 두번째 ",
subtype: "ignored-sub",
graphicType: "ignored-forCutInfo",
dataCode: "ignored-dataCode"));
dataCode: "KOSPI:111111|KOSDAQ:222222"));
Assert.Equal(builderKey, result.BuilderKey);
var request = result switch
@@ -76,35 +76,90 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
};
Assert.Equal(ComparisonGraphPeriod.FiveDays, request.Period);
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째"),
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "첫번째", "111111"),
request.First);
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째"),
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "두번째", "222222"),
request.Second);
AssertStockLookupOrder(executor, "첫번째", "두번째");
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task Exact_pair_identity_preserves_market_and_code_without_cross_market_name_fallback()
{
var executor = new RecordingExecutor(
StockIdentity("Duplicate", "111111"),
StockIdentity("Second", "222222"));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5026SceneLoadRequest>(await resolver.ResolveAsync(Entry(
"5026",
"STOCK",
"Duplicate,Second",
"",
dataCode: "KOSPI:111111|KOSDAQ:222222")));
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "Duplicate", "111111"),
result.Request.First);
Assert.Equal(
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "222222"),
result.Request.Second);
Assert.Equal(2, executor.Calls.Count);
Assert.Equal("SCENE_COMPARISON_RESOLVE_EXACT_KOSPI_STOCK", executor.Calls[0].TableName);
Assert.Equal("SCENE_COMPARISON_RESOLVE_EXACT_KOSDAQ_STOCK", executor.Calls[1].TableName);
Assert.Equal(
new object?[] { "Duplicate", "111111" },
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
Assert.DoesNotContain(
executor.Calls,
call => call.TableName == "SCENE_COMPARISON_RESOLVE_KOSDAQ_STOCK");
}
[Theory]
[InlineData("일봉", ComparisonGraphPeriod.Daily)]
[InlineData("5일", ComparisonGraphPeriod.FiveDays)]
[InlineData("1개월", ComparisonGraphPeriod.OneMonth)]
[InlineData("3개월", ComparisonGraphPeriod.ThreeMonths)]
[InlineData("6개월", ComparisonGraphPeriod.SixMonths)]
[InlineData("12개월", ComparisonGraphPeriod.TwelveMonths)]
[InlineData("")]
[InlineData("KOSPI:111111")]
[InlineData("KOSPI:111111|KOSDAQ:")]
[InlineData("KOSPI:111111|KOSDAQ:222222|KOSPI:333333")]
[InlineData("KOSPI:111111|KOSDAQ:bad/code")]
public async Task Malformed_exact_pair_identity_fails_before_database_access(string dataCode)
{
var executor = new RecordingExecutor();
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(
Entry("5026", "STOCK", "First,Second", "", dataCode: dataCode)));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("\uC77C\uBD09", ComparisonGraphPeriod.Daily)]
[InlineData("5\uC77C", ComparisonGraphPeriod.FiveDays)]
[InlineData("1\uAC1C\uC6D4", ComparisonGraphPeriod.OneMonth)]
[InlineData("3\uAC1C\uC6D4", ComparisonGraphPeriod.ThreeMonths)]
[InlineData("6\uAC1C\uC6D4", ComparisonGraphPeriod.SixMonths)]
[InlineData("12\uAC1C\uC6D4", ComparisonGraphPeriod.TwelveMonths)]
public async Task S5029_maps_every_original_period(
string subtype,
ComparisonGraphPeriod expected)
{
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
StockIdentity("A", "111111"),
StockIdentity("B", "222222"));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
var result = Assert.IsType<LegacyS5029SceneLoadRequest>(
await resolver.ResolveAsync(Entry("5029", "", "A,B", subtype)));
await resolver.ResolveAsync(Entry(
"5029",
"STOCK",
"A,B",
subtype,
dataCode: "KOSPI:111111|KOSDAQ:222222")));
Assert.Equal(expected, result.Request.Period);
Assert.Equal("A", result.Request.First.StockName);
Assert.Equal("111111", result.Request.First.StockCode);
Assert.Equal("B", result.Request.Second.StockName);
}
@@ -251,34 +306,33 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
const string firstName = "A' OR '1'='1";
const string secondName = "B; DELETE FROM T_STOCK";
var executor = new RecordingExecutor(
Count(1), Count(0),
Count(0), Count(1));
StockIdentity(firstName, "111111"),
StockIdentity(secondName, "222222"));
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
_ = await resolver.ResolveAsync(Entry(
"5026",
"",
"STOCK",
firstName + "," + secondName,
""));
"",
dataCode: "KOSPI:111111|KOSDAQ:222222"));
AssertStockLookupOrder(executor, firstName, secondName);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Contains("SELECT COUNT(*)", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("SELECT STOCK_NAME, STOCK_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("INSERT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("UPDATE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("DELETE FROM", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
var parameter = Assert.Single(call.Spec.Parameters);
Assert.Equal("stockName", parameter.Name);
Assert.Equal(DbType.String, parameter.DbType);
Assert.DoesNotContain(
Assert.IsType<string>(parameter.Value),
call.Spec.Sql,
StringComparison.Ordinal);
Assert.Equal(new[] { "stockName", "stockCode" },
call.Spec.Parameters.Select(parameter => parameter.Name));
Assert.All(call.Spec.Parameters, parameter => Assert.Equal(DbType.String, parameter.DbType));
Assert.All(call.Spec.Parameters, parameter => Assert.DoesNotContain(
Assert.IsType<string>(parameter.Value), call.Spec.Sql, StringComparison.Ordinal));
});
Assert.Contains("FROM T_STOCK ", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM T_KOSDAQ_STOCK ", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM T_STOCK", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM T_KOSDAQ_STOCK", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
}
[Theory]
@@ -355,11 +409,35 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() =>
resolver.ResolveAsync(Entry("5026", "", "A,B", "")));
resolver.ResolveAsync(Entry(
"5026",
"STOCK",
"A,B",
"",
dataCode: "KOSPI:111111|KOSDAQ:222222")));
Assert.Single(executor.Calls);
}
[Fact]
public async Task Exact_identity_duplicate_rows_are_ambiguous_even_when_name_and_code_match()
{
var duplicate = StockIdentity("A", "111111");
duplicate.ImportRow(duplicate.Rows[0]);
var executor = new RecordingExecutor(duplicate);
var resolver = new ComparisonAndYieldLegacyRequestResolver(executor);
await Assert.ThrowsAsync<LegacySceneDataException>(() => resolver.ResolveAsync(Entry(
"5026",
"STOCK",
"A,B",
"",
dataCode: "KOSPI:111111|KOSDAQ:222222")));
Assert.Single(executor.Calls);
Assert.DoesNotContain("DISTINCT", executor.Calls[0].Spec.Sql, StringComparison.OrdinalIgnoreCase);
}
private static LegacyPlaylistEntry Entry(
string cutCode,
string groupCode,
@@ -384,6 +462,15 @@ public sealed class ComparisonAndYieldLegacyRequestResolverTests
return table;
}
private static DataTable StockIdentity(string name, string code)
{
var table = new DataTable();
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
table.Rows.Add(name, code);
return table;
}
private static void AssertStockLookupOrder(
RecordingExecutor executor,
params string[] stockNames)

View File

@@ -52,10 +52,11 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
Assert.Contains("FROM t_kosdaq_online1", executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(firstName, executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(secondName, executor.Calls[1].Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { "stockName", "rowLimit" },
Assert.Equal(new[] { "stockName", "stockCode", "rowLimit" },
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal(new object?[] { firstName, 5 },
Assert.Equal(new object?[] { firstName, "FIRST001", 5 },
executor.Calls[0].Spec.Parameters.Select(parameter => parameter.Value));
Assert.Contains("b.f_stock_code = s.STOCK_CODE", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { ComparisonSeriesTarget.First, ComparisonSeriesTarget.Second },
data.Series.Select(series => series.Quote.Series));
Assert.Equal(new[] { firstName, secondName },
@@ -92,6 +93,7 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(rowLimit, call.Spec.Parameters.Single(p => p.Name == "rowLimit").Value);
Assert.Contains(":stockName", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(":stockCode", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains(":rowLimit", call.Spec.Sql, StringComparison.Ordinal);
});
Assert.Contains("t_candle_history", executor.Calls[0].Spec.Sql, StringComparison.Ordinal);
@@ -502,7 +504,15 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
new ComparisonPairSceneLoadRequest(
ComparisonGraphPeriod.FiveDays,
null!,
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second"))
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002")),
new ComparisonPairSceneLoadRequest(
ComparisonGraphPeriod.FiveDays,
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "First", "bad/code"),
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002")),
new ComparisonPairSceneLoadRequest(
ComparisonGraphPeriod.FiveDays,
new ComparisonStockSelection(ComparisonEquityMarket.Kospi, "First", null!),
new ComparisonStockSelection(ComparisonEquityMarket.Kosdaq, "Second", "SECOND002"))
};
foreach (var request in invalidPairRequests)
{
@@ -543,8 +553,8 @@ public sealed class ComparisonAndYieldSceneDataLoadersTests
string secondName,
ComparisonEquityMarket secondMarket) => new(
period,
new ComparisonStockSelection(firstMarket, firstName),
new ComparisonStockSelection(secondMarket, secondName));
new ComparisonStockSelection(firstMarket, firstName, "FIRST001"),
new ComparisonStockSelection(secondMarket, secondName, "SECOND002"));
private static DataTable CandleTable(
string name,

View File

@@ -0,0 +1,531 @@
#nullable enable
using System.Data;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyComparisonPairImportServiceTests
{
private static readonly Encoding Cp949 = CreateCp949();
[Fact]
public void Parser_AcceptsTheExactUc3NineFieldCp949Shape()
{
var rows = LegacyComparisonFileParser.Parse(Encode(
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
"2^코스닥 지수,코스피 지수^^^^^^^\r\n" +
"3^엔비디아,삼성전자^^코스피^^^^^\r\n"));
Assert.Collection(
rows,
row =>
{
Assert.Equal(1, row.RowNumber);
Assert.Equal(new LegacyComparisonFileTarget("삼성전자", "코스피"), row.First);
Assert.Equal(
new LegacyComparisonFileTarget("삼성전자(NXT)", "코스피_NXT"),
row.Second);
},
row =>
{
Assert.Equal(2, row.RowNumber);
Assert.Equal(string.Empty, row.First.Market);
Assert.Equal(string.Empty, row.Second.Market);
},
row =>
{
Assert.Equal(3, row.RowNumber);
Assert.Equal("엔비디아", row.First.Name);
Assert.Equal("코스피", row.Second.Market);
});
}
[Fact]
public void Parser_AcceptsTheAuditedEightRowOriginalFixture()
{
var bytes = Encode(
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
"2^기아(NXT),기아^코스피_NXT^코스피^^^^^\r\n" +
"3^기아(NXT),삼성전자(NXT)^코스피_NXT^코스피_NXT^^^^^\r\n" +
"4^SK하이닉스,삼성전자^코스피^코스피^^^^^\r\n" +
"5^에이비엘바이오(NXT),에이비엘바이오^코스닥_NXT^코스닥^^^^^\r\n" +
"6^코스닥 지수,코스피 지수^^^^^^^\r\n" +
"7^엔비디아,삼성전자^^코스피^^^^^\r\n" +
"8^삼성전자(NXT),엔비디아^코스피_NXT^^^^^^\r\n");
Assert.Equal(
"1EE76BC464FB1C44C8B6546E99CDC21C726C0E2C24AD344F5C19AE41BFC0D2F2",
Convert.ToHexString(SHA256.HashData(bytes)));
var rows = LegacyComparisonFileParser.Parse(bytes);
Assert.Equal(8, rows.Count);
Assert.Equal("엔비디아", rows[7].Second.Name);
Assert.Equal(string.Empty, rows[7].Second.Market);
}
[Theory]
[InlineData("1^삼성전자,기아^코스피^코스피^^^^\r\n")]
[InlineData("01^삼성전자,기아^코스피^코스피^^^^^\r\n")]
[InlineData("2^삼성전자,기아^코스피^코스피^^^^^\r\n")]
[InlineData("1^삼성전자^코스피^코스피^^^^^\r\n")]
[InlineData("1^삼성전자,기아^코스피^코스피^unexpected^^^^\r\n")]
[InlineData("1^삼성전자(NXT),기아^코스피^코스피^^^^^\r\n")]
[InlineData("1^삼성전자,기아^코스피_NXT^코스피^^^^^\r\n")]
[InlineData("1^ 삼성전자,기아^코스피^코스피^^^^^\r\n")]
public void Parser_RejectsMalformedRows(string text)
{
var exception = Assert.Throws<LegacyComparisonImportException>(
() => LegacyComparisonFileParser.Parse(Encode(text)));
Assert.Equal(LegacyComparisonImportFailure.SourceInvalid, exception.Failure);
}
[Fact]
public void Parser_RejectsDuplicatePairsAndInvalidCp949()
{
var duplicate = Encode(
"1^삼성전자,기아^코스피^코스피^^^^^\r\n" +
"2^삼성전자,기아^코스피^코스피^^^^^\r\n");
Assert.Equal(
LegacyComparisonImportFailure.SourceInvalid,
Assert.Throws<LegacyComparisonImportException>(
() => LegacyComparisonFileParser.Parse(duplicate)).Failure);
Assert.Equal(
LegacyComparisonImportFailure.SourceInvalid,
Assert.Throws<LegacyComparisonImportException>(
() => LegacyComparisonFileParser.Parse([0x81])).Failure);
}
[Fact]
public async Task TrustedSource_UsesOnlyTheFixedReadOnlyPathAndReturnsItsDigest()
{
using var directory = new TemporaryDirectory();
var source = new TrustedLegacyComparisonFileSource(directory.Path);
var expected = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
await File.WriteAllBytesAsync(source.SourcePath, expected);
var result = await source.ReadAsync();
Assert.Equal(expected, result.Bytes);
Assert.Equal(Convert.ToHexString(SHA256.HashData(expected)), result.Sha256);
Assert.Equal(
Path.GetFullPath(Path.Combine(directory.Path, TrustedLegacyComparisonFileSource.FixedRelativePath)),
source.SourcePath);
}
[Fact]
public async Task TrustedSource_RejectsMissingAndOversizedFiles()
{
using var directory = new TemporaryDirectory();
var source = new TrustedLegacyComparisonFileSource(directory.Path);
var missing = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => source.ReadAsync());
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, missing.Failure);
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
await File.WriteAllBytesAsync(
source.SourcePath,
new byte[TrustedLegacyComparisonFileSource.MaximumFileBytes + 1]);
var oversized = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => source.ReadAsync());
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, oversized.Failure);
}
[Fact]
public async Task TrustedSource_RejectsAHardLinkedFileEvenWhenItsBytesAreValid()
{
if (!OperatingSystem.IsWindows())
{
return;
}
using var directory = new TemporaryDirectory();
var source = new TrustedLegacyComparisonFileSource(directory.Path);
Directory.CreateDirectory(Path.GetDirectoryName(source.SourcePath)!);
var outside = Path.Combine(directory.Path, "outside-valid-comparison.dat");
await File.WriteAllBytesAsync(
outside,
Encode("1^코스피 지수,코스닥 지수^^^^^^\r\n"));
Assert.True(CreateHardLinkW(source.SourcePath, outside, IntPtr.Zero));
var error = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => source.ReadAsync());
Assert.Equal(LegacyComparisonImportFailure.SourceUnavailable, error.Failure);
}
[Fact]
public async Task ImportAsync_ResolvesEveryTargetKindWithBoundExactReadsAndCachesIdentity()
{
var bytes = Encode(
"1^삼성전자,삼성전자(NXT)^코스피^코스피_NXT^^^^^\r\n" +
"2^코스닥 지수,삼성전자^^코스피^^^^^\r\n" +
"3^엔비디아,삼성전자^^코스피^^^^^\r\n");
var source = new MemorySource(bytes);
var executor = new RecordingExecutor(call => call.TableName switch
{
"LEGACY_COMPARISON_IMPORT_KOSPI" => StockTable("삼성전자", "005930"),
"LEGACY_COMPARISON_IMPORT_NXT_KOSPI" => StockTable("삼성전자", "005930"),
"LEGACY_COMPARISON_IMPORT_WORLD_STOCK" =>
string.Equals(
call.Spec.Parameters[0].Value as string,
"엔비디아",
StringComparison.Ordinal)
? WorldTable("엔비디아", "NAS@NVDA", "US")
: WorldTable(),
_ => throw new InvalidOperationException("Unexpected identity query.")
});
var service = new LegacyComparisonPairImportService(executor, source);
var result = await service.ImportAsync();
Assert.Equal(3, result.SourceRowCount);
Assert.Equal(source.Digest, result.SourceSha256);
Assert.Collection(
result.Pairs,
row =>
{
Assert.Equal(LegacyComparisonImportTargetKind.KrxStock, row.First.Kind);
Assert.Equal("kospi", row.First.Market);
Assert.Equal("005930", row.First.StockCode);
Assert.Equal(LegacyComparisonImportTargetKind.NxtStock, row.Second.Kind);
Assert.Equal("삼성전자", row.Second.StockName);
},
row =>
{
Assert.Equal("Kosdaq", row.First.MarketTarget);
Assert.Equal(LegacyComparisonImportTargetKind.MarketTarget, row.First.Kind);
Assert.Equal("005930", row.Second.StockCode);
},
row =>
{
Assert.Equal(LegacyComparisonImportTargetKind.WorldStock, row.First.Kind);
Assert.Equal("NAS@NVDA", row.First.Symbol);
Assert.Equal("US", row.First.Nation);
Assert.Equal("005930", row.Second.StockCode);
});
Assert.Equal(4, executor.Calls.Count);
Assert.Equal(0, executor.StringSqlCallCount);
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.Single(call.Spec.Parameters);
Assert.DoesNotContain(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
call.Spec.Sql,
StringComparison.Ordinal);
});
Assert.Equal("삼성전자", executor.Calls[0].Spec.Parameters[0].Value);
Assert.Equal("삼성전자", executor.Calls[1].Spec.Parameters[0].Value);
Assert.Equal("코스닥 지수", executor.Calls[2].Spec.Parameters[0].Value);
Assert.Equal("엔비디아", executor.Calls[3].Spec.Parameters[0].Value);
}
[Fact]
public async Task ImportAsync_MapsAllTwentyFourOriginalFixedTargetsExactly()
{
(string Name, string Target)[] expected =
[
("코스피 지수", "Kospi"),
("코스닥 지수", "Kosdaq"),
("코스피200 지수", "Kospi200"),
("선물 지수", "Futures"),
("KRX100지수", "Krx100"),
("환율-원달러", "WonDollar"),
("환율-원엔", "WonYen"),
("환율-원위엔", "WonYuan"),
("환율-원유로", "WonEuro"),
("해외지수-다우", "Dow"),
("해외지수-나스닥", "Nasdaq"),
("해외지수-S&P", "Sp500"),
("해외지수-독일", "GermanyDax"),
("해외지수-영국", "UnitedKingdomFtse"),
("해외지수-프랑스", "FranceCac"),
("해외지수-일본", "Nikkei"),
("해외지수-홍콩", "HangSeng"),
("해외지수-대만", "TaiwanWeighted"),
("해외지수-싱가포르", "SingaporeStraitsTimes"),
("해외지수-태국", "ThailandSet"),
("해외지수-필리핀", "PhilippinesComposite"),
("해외지수-말레이시아", "MalaysiaKlse"),
("해외지수-인도네시아", "IndonesiaComposite"),
("해외지수-중국", "ShanghaiComposite")
];
var text = new StringBuilder();
for (var index = 0; index < expected.Length; index += 2)
{
text.Append(index / 2 + 1)
.Append('^')
.Append(expected[index].Name)
.Append(',')
.Append(expected[index + 1].Name)
.Append("^^^^^^^\r\n");
}
var executor = new RecordingExecutor(call =>
{
Assert.Equal("LEGACY_COMPARISON_IMPORT_WORLD_STOCK", call.TableName);
return WorldTable();
});
var service = new LegacyComparisonPairImportService(
executor,
new MemorySource(Encode(text.ToString())));
var result = await service.ImportAsync();
var actual = result.Pairs
.SelectMany(row => new[] { row.First, row.Second })
.Select(target => target.MarketTarget)
.ToArray();
Assert.Equal(expected.Select(value => value.Target), actual);
Assert.Equal(24, executor.Calls.Count);
Assert.Equal(
expected.Select(value => value.Name),
executor.Calls.Select(call => Assert.IsType<string>(call.Spec.Parameters[0].Value)));
}
[Fact]
public async Task ImportAsync_FailsClosedForMissingAmbiguousAndMalformedMasterRows()
{
var bytes = Encode("1^삼성전자,기아^코스피^코스피^^^^^\r\n");
await AssertFailureAsync(
bytes,
_ => StockTable(),
LegacyComparisonImportFailure.IdentityNotFound);
await AssertFailureAsync(
bytes,
_ => StockTable(("삼성전자", "005930"), ("삼성전자", "999999")),
LegacyComparisonImportFailure.IdentityAmbiguous);
var wrongSchema = new DataTable();
wrongSchema.Columns.Add("stock_name", typeof(string));
wrongSchema.Columns.Add("STOCK_CODE", typeof(string));
await AssertFailureAsync(
bytes,
_ => wrongSchema,
LegacyComparisonImportFailure.DatabaseDataInvalid);
}
[Fact]
public async Task ImportAsync_RejectsAWorldNameThatCollidesWithAFixedMarketLabel()
{
var bytes = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
var service = new LegacyComparisonPairImportService(
new RecordingExecutor(call =>
WorldTable(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
"COLLISION",
"US")),
new MemorySource(bytes));
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => service.ImportAsync());
Assert.Equal(LegacyComparisonImportFailure.IdentityAmbiguous, exception.Failure);
}
[Fact]
public async Task ImportAsync_RejectsAChangedDigestBeforeDatabaseAccess()
{
var bytes = Encode("1^코스닥 지수,코스피 지수^^^^^^^\r\n");
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var source = new CorruptDigestSource(bytes);
var service = new LegacyComparisonPairImportService(executor, source);
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => service.ImportAsync());
Assert.Equal(LegacyComparisonImportFailure.SourceInvalid, exception.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ImportAsync_HonorsPreCanceledRequestsWithoutReadingOrQuerying()
{
var source = new CountingSource();
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyComparisonPairImportService(executor, source);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.ImportAsync(cancellation.Token));
Assert.Equal(0, source.ReadCount);
Assert.Empty(executor.Calls);
}
private static async Task AssertFailureAsync(
byte[] bytes,
Func<RecordedCall, DataTable> handler,
LegacyComparisonImportFailure expected)
{
var service = new LegacyComparisonPairImportService(
new RecordingExecutor(handler),
new MemorySource(bytes));
var exception = await Assert.ThrowsAsync<LegacyComparisonImportException>(
() => service.ImportAsync());
Assert.Equal(expected, exception.Failure);
}
private static DataTable StockTable(params (string Name, string Code)[] rows)
{
var table = new DataTable();
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Name, row.Code);
}
return table;
}
private static DataTable StockTable(string name, string code) =>
StockTable((name, code));
private static DataTable WorldTable(
params (string InputName, string Symbol, string Nation)[] rows)
{
var table = new DataTable();
table.Columns.Add("F_INPUT_NAME", typeof(string));
table.Columns.Add("F_SYMB", typeof(string));
table.Columns.Add("F_NATC", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.InputName, row.Symbol, row.Nation);
}
return table;
}
private static DataTable WorldTable(string inputName, string symbol, string nation) =>
WorldTable((inputName, symbol, nation));
private static byte[] Encode(string value) => Cp949.GetBytes(value);
private static Encoding CreateCp949()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private sealed class MemorySource(byte[] bytes) : ILegacyComparisonFileSource
{
public string Digest { get; } = Convert.ToHexString(SHA256.HashData(bytes));
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(new LegacyComparisonSourceSnapshot(bytes, Digest));
}
}
private sealed class CorruptDigestSource(byte[] bytes) : ILegacyComparisonFileSource
{
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyComparisonSourceSnapshot(bytes, new string('0', 64)));
}
private sealed class CountingSource : ILegacyComparisonFileSource
{
public int ReadCount { get; private set; }
public Task<LegacyComparisonSourceSnapshot> ReadAsync(
CancellationToken cancellationToken = default)
{
ReadCount++;
throw new InvalidOperationException();
}
}
private sealed record RecordedCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
: IDataQueryExecutor
{
private int _stringSqlCallCount;
public List<RecordedCall> Calls { get; } = [];
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException("String SQL fallback is forbidden.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call).Copy());
}
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateHardLinkW(
string fileName,
string existingFileName,
IntPtr securityAttributes);
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"mbn-comparison-import-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch
{
// Best-effort test cleanup only.
}
}
}
}

View File

@@ -0,0 +1,135 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyCutRequirementManifestTests
{
private static readonly StringComparer PathComparer = StringComparer.OrdinalIgnoreCase;
[Fact]
public void Critical_builder_assets_match_the_closed_preflight_manifest()
{
var manifest = ReadManifest();
var actual = new HashSet<string>(PathComparer);
var directions = new[]
{
ScenePriceDirection.Up,
ScenePriceDirection.Flat,
ScenePriceDirection.Down
};
foreach (var direction in directions)
{
AddAssets(actual, new S5006SceneMutationBuilder().Build(new S5006SceneData(
LegacyDomesticEquityMarket.Kospi,
"fixture",
direction,
1,
1,
1m,
1,
1m,
1,
1m,
1,
1m)));
foreach (var target in Enum.GetValues<S6001QuoteTarget>())
{
AddAssets(actual, new S6001SceneMutationBuilder().Build(new S6001SceneData(
target,
"fixture",
1m,
1m,
1m,
direction)));
}
}
var expected = manifest.Assets
.Where(asset => asset.Builder is "s5006" or "s6001" or "shared-price-direction")
.Select(asset => asset.Path)
.ToHashSet(PathComparer);
Assert.Equal(expected.Count, actual.Count);
Assert.Empty(expected.Except(actual, PathComparer));
Assert.Empty(actual.Except(expected, PathComparer));
}
[Fact]
public void Manifest_is_relative_closed_and_includes_all_s6001_videos()
{
var manifest = ReadManifest();
Assert.Equal(1, manifest.SchemaVersion);
Assert.Equal(34, manifest.ReachableBuilders);
Assert.Equal(45, manifest.ActiveAliases.Count);
Assert.Equal(23, manifest.Assets.Count);
Assert.Equal(
13,
manifest.Assets
.Where(asset => asset.Builder == "s6001" && asset.Kind == "Video")
.Select(asset => asset.Path)
.Distinct(PathComparer)
.Count());
Assert.Contains(
manifest.Assets,
asset => asset.Builder == "s5006" &&
PathComparer.Equals(asset.Path, @"Video\큐브배경.vrv"));
Assert.All(manifest.Assets, asset =>
{
Assert.Contains(asset.Kind, new[] { "Image", "Texture", "Video" });
Assert.False(Path.IsPathRooted(asset.Path));
Assert.DoesNotContain(
"..",
asset.Path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
});
}
private static void AddAssets(
ISet<string> paths,
IEnumerable<PlayoutMutation> mutations)
{
foreach (var mutation in mutations)
{
switch (mutation)
{
case PlayoutSetAssetValue asset:
paths.Add(asset.AssetPath);
break;
case PlayoutSetBackgroundTexture texture:
paths.Add(texture.AssetPath);
break;
case PlayoutSetBackgroundVideo video:
paths.Add(video.AssetPath);
break;
}
}
}
private static LegacyCutRequirementManifest ReadManifest()
{
var path = Path.Combine(
AppContext.BaseDirectory,
"Fixtures",
"LegacyCutRequirements.json");
var manifest = JsonSerializer.Deserialize<LegacyCutRequirementManifest>(
File.ReadAllText(path),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Assert.IsType<LegacyCutRequirementManifest>(manifest);
}
private sealed record LegacyCutRequirementManifest(
int SchemaVersion,
int ReachableBuilders,
IReadOnlyList<string> ActiveAliases,
IReadOnlyList<LegacyBuilderAssetRequirement> Assets);
private sealed record LegacyBuilderAssetRequirement(
string Builder,
string Kind,
string Path);
}

View File

@@ -0,0 +1,458 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyForeignIndexCandleRestoreServiceTests
{
public static TheoryData<string, string, string, string, int, string> SupportedProfiles =>
new()
{
{ "다우존스", "5일", "Dow", "DJI@DJI", 5, "8061" },
{ "다우존스", "20일", "Dow", "DJI@DJI", 20, "8040" },
{ "다우존스", "60일", "Dow", "DJI@DJI", 60, "8046" },
{ "다우존스", "120일", "Dow", "DJI@DJI", 120, "8051" },
{ "다우존스", "240일", "Dow", "DJI@DJI", 240, "8056" },
{ "나스닥", "5일", "Nasdaq", "NAS@IXIC", 5, "8061" },
{ "나스닥", "20일", "Nasdaq", "NAS@IXIC", 20, "8040" },
{ "나스닥", "60일", "Nasdaq", "NAS@IXIC", 60, "8046" },
{ "나스닥", "120일", "Nasdaq", "NAS@IXIC", 120, "8051" },
{ "나스닥", "240일", "Nasdaq", "NAS@IXIC", 240, "8056" },
{ "S&P500", "5일", "Sp500", "SPI@SPX", 5, "8061" },
{ "S&P500", "20일", "Sp500", "SPI@SPX", 20, "8040" },
{ "S&P500", "60일", "Sp500", "SPI@SPX", 60, "8046" },
{ "S&P500", "120일", "Sp500", "SPI@SPX", 120, "8051" },
{ "S&P500", "240일", "Sp500", "SPI@SPX", 240, "8056" }
};
[Theory]
[MemberData(nameof(SupportedProfiles))]
public async Task ResolveAsync_VerifiesEveryClosedTargetAndPeriodExactly(
string subject,
string period,
string targetKey,
string symbol,
int periodDays,
string cutCode)
{
var executor = new RecordingExecutor(call =>
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal(LegacyForeignIndexCandleRestoreService.QueryName, call.QueryName);
call.Spec.ValidateFor(DataSourceKind.Oracle);
Assert.Contains("F_INPUT_NAME = :input_name", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_SYMB = :symbol", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_NATC = 'US'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_FDTC = '0'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= 2", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(subject, call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain(symbol, call.Spec.Sql, StringComparison.Ordinal);
Assert.Collection(
call.Spec.Parameters,
parameter =>
{
Assert.Equal("input_name", parameter.Name);
Assert.Equal(subject, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
},
parameter =>
{
Assert.Equal("symbol", parameter.Name);
Assert.Equal(symbol, parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
});
return IdentityTable((subject, symbol, "US", "0"));
});
var service = new LegacyForeignIndexCandleRestoreService(executor);
var row = Row(17, subject, period);
Assert.True(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
var result = await service.ResolveAsync([row]);
var outcome = Assert.Single(result.Rows);
Assert.Equal(17, outcome.ItemIndex);
Assert.True(outcome.IsResolved);
Assert.Null(outcome.Failure);
Assert.Equal(
new LegacyForeignIndexCandleRestoreIdentity(
targetKey,
subject,
symbol,
"US",
"0",
"s8010",
"PRICE",
periodDays,
cutCode),
outcome.Identity);
Assert.Single(executor.Calls);
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task ResolveAsync_PreservesBatchOrderAndCachesEachTargetIdentity()
{
var executor = new RecordingExecutor(call =>
{
var inputName = Assert.IsType<string>(call.Spec.Parameters[0].Value);
var symbol = Assert.IsType<string>(call.Spec.Parameters[1].Value);
return IdentityTable((inputName, symbol, "US", "0"));
});
var service = new LegacyForeignIndexCandleRestoreService(executor);
var rows = new[]
{
Row(900, "S&P500", "240일"),
Row(2, "다우존스", "20일"),
Row(701, "나스닥", "5일"),
Row(3, "다우존스", "5일")
};
var result = await service.ResolveAsync(rows);
Assert.Equal([900, 2, 701, 3], result.Rows.Select(row => row.ItemIndex));
Assert.Equal(
["Sp500", "Dow", "Nasdaq", "Dow"],
result.Rows.Select(row => row.Identity!.TargetKey));
Assert.Equal([240, 20, 5, 5], result.Rows.Select(row => row.Identity!.PeriodDays));
Assert.Equal(3, executor.Calls.Count);
Assert.Equal(
["S&P500", "다우존스", "나스닥"],
executor.Calls.Select(call => call.Spec.Parameters[0].Value));
}
[Fact]
public async Task ResolveAsync_ReturnsNotFoundAmbiguousAndInvalidPerRow()
{
var executor = new RecordingExecutor(call =>
{
var inputName = Assert.IsType<string>(call.Spec.Parameters[0].Value);
var symbol = Assert.IsType<string>(call.Spec.Parameters[1].Value);
return inputName switch
{
"다우존스" => IdentityTable(),
"나스닥" => IdentityTable(
(inputName, symbol, "US", "0"),
(inputName, symbol, "US", "0")),
"S&P500" => WrongSchemaTable(),
_ => throw new InvalidOperationException(inputName)
};
});
var service = new LegacyForeignIndexCandleRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(10, "다우존스", "5일"),
Row(11, "나스닥", "20일"),
Row(12, "S&P500", "60일")
]);
Assert.Equal(
new LegacyForeignIndexCandleRestoreFailure?[]
{
LegacyForeignIndexCandleRestoreFailure.IdentityNotFound,
LegacyForeignIndexCandleRestoreFailure.IdentityAmbiguous,
LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid
},
result.Rows.Select(row => row.Failure));
Assert.All(result.Rows, row =>
{
Assert.False(row.IsResolved);
Assert.Null(row.Identity);
});
}
[Theory]
[InlineData("다우", "5일")]
[InlineData("DOW", "5일")]
[InlineData(" 다우존스", "5일")]
[InlineData("다우존스", "5")]
[InlineData("다우존스", "240")]
[InlineData("다우존스", " 240일")]
public async Task ResolveAsync_RejectsNonExactRawRowsWithoutDatabaseAccess(
string subject,
string period)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyForeignIndexCandleRestoreService(executor);
var row = Row(0, subject, period);
Assert.False(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
Assert.Equal(LegacyForeignIndexCandleRestoreFailure.InvalidRow, outcome.Failure);
Assert.Null(outcome.Identity);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("FOREIGN_INDEX", "캔들그래프", "1/1", "")]
[InlineData(" 해외지수", "캔들그래프", "1/1", "")]
[InlineData("해외지수", "캔들 그래프", "1/1", "")]
[InlineData("해외지수", "CANDLE", "1/1", "")]
[InlineData("해외지수", "캔들그래프", "01/01", "")]
[InlineData("해외지수", "캔들그래프", "1/2", "")]
[InlineData("해외지수", "캔들그래프", "1/1", "DJI@DJI")]
public async Task ResolveAsync_RevalidatesFullRawPlaylistBoundary(
string groupCode,
string graphicType,
string pageText,
string dataCode)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyForeignIndexCandleRestoreService(executor);
var row = Row(
groupCode: groupCode,
graphicType: graphicType,
pageText: pageText,
dataCode: dataCode);
Assert.False(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
Assert.Equal(LegacyForeignIndexCandleRestoreFailure.InvalidRow, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_AllowsDisabledRowSoCallerCanPreserveEnabledState()
{
var executor = new RecordingExecutor(call => IdentityTable((
Assert.IsType<string>(call.Spec.Parameters[0].Value),
Assert.IsType<string>(call.Spec.Parameters[1].Value),
"US",
"0")));
var service = new LegacyForeignIndexCandleRestoreService(executor);
var row = Row(isEnabled: false);
Assert.True(LegacyForeignIndexCandleRestoreService.IsCandidate(row));
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
Assert.True(outcome.IsResolved);
Assert.Equal("Dow", outcome.Identity!.TargetKey);
Assert.Single(executor.Calls);
}
[Fact]
public async Task ResolveAsync_RejectsMalformedBatchSchemaAndDuplicateIndexes()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyForeignIndexCandleRestoreService(executor);
var tooMany = Enumerable.Range(
0,
LegacyForeignIndexCandleRestoreService.MaximumRows + 1)
.Select(index => Row(index))
.ToArray();
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync(tooMany));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[
Row(4, "다우존스", "5일"),
Row(4, "나스닥", "20일")
]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[
null!
]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[
Row(-1, "다우존스", "5일")
]));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_PropagatesDatabaseFailureWithoutRetry()
{
var executor = new RecordingExecutor(_ =>
throw new DataException("simulated read failure"));
var service = new LegacyForeignIndexCandleRestoreService(executor);
await Assert.ThrowsAsync<DataException>(() => service.ResolveAsync(
[Row(0, "다우존스", "5일")]));
Assert.Single(executor.Calls);
}
[Theory]
[InlineData("extra-column")]
[InlineData("wrong-case")]
[InlineData("wrong-type")]
[InlineData("unexpected-value")]
[InlineData("too-many")]
public async Task ResolveAsync_FailsClosedOnExpandedOrTamperedDatabaseData(string mode)
{
var executor = new RecordingExecutor(_ => mode switch
{
"extra-column" => TableWithExtraColumn(),
"wrong-case" => WrongSchemaTable(),
"wrong-type" => WrongTypeTable(),
"unexpected-value" => IdentityTable(("다우존스", "WRONG", "US", "0")),
"too-many" => IdentityTable(
("다우존스", "DJI@DJI", "US", "0"),
("다우존스", "DJI@DJI", "US", "0"),
("다우존스", "DJI@DJI", "US", "0")),
_ => throw new InvalidOperationException(mode)
});
var service = new LegacyForeignIndexCandleRestoreService(executor);
var outcome = Assert.Single((await service.ResolveAsync(
[Row(0, "다우존스", "5일")])).Rows);
Assert.Equal(
LegacyForeignIndexCandleRestoreFailure.DatabaseDataInvalid,
outcome.Failure);
Assert.False(outcome.IsResolved);
}
[Fact]
public async Task ResolveAsync_HonorsPreCancellationWithoutQueryOrRetry()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyForeignIndexCandleRestoreService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ResolveAsync(
[Row(0, "다우존스", "5일")],
cancellation.Token));
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_StopsAfterCancellationObservedByFirstQuery()
{
using var cancellation = new CancellationTokenSource();
var executor = new RecordingExecutor(call =>
{
cancellation.Cancel();
return IdentityTable((
Assert.IsType<string>(call.Spec.Parameters[0].Value),
Assert.IsType<string>(call.Spec.Parameters[1].Value),
"US",
"0"));
}, throwBeforeHandlerWhenCanceled: false);
var service = new LegacyForeignIndexCandleRestoreService(executor);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => service.ResolveAsync(
[
Row(0, "다우존스", "5일"),
Row(1, "나스닥", "5일")
], cancellation.Token));
Assert.Single(executor.Calls);
}
private static DataTable IdentityTable(
params (string InputName, string Symbol, string Nation, string ForeignDomestic)[] rows)
{
var table = new DataTable();
table.Columns.Add("F_INPUT_NAME", typeof(string));
table.Columns.Add("F_SYMB", typeof(string));
table.Columns.Add("F_NATC", typeof(string));
table.Columns.Add("F_FDTC", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.InputName, row.Symbol, row.Nation, row.ForeignDomestic);
}
return table;
}
private static LegacyForeignIndexCandleRestoreRow Row(
int itemIndex = 0,
string subject = "다우존스",
string subtype = "5일",
bool isEnabled = true,
string groupCode = "해외지수",
string graphicType = "캔들그래프",
string pageText = "1/1",
string dataCode = "") =>
new(
itemIndex,
isEnabled,
groupCode,
subject,
graphicType,
subtype,
pageText,
dataCode);
private static DataTable WrongSchemaTable()
{
var table = IdentityTable();
table.Columns[0].ColumnName = "f_input_name";
return table;
}
private static DataTable WrongTypeTable()
{
var table = new DataTable();
table.Columns.Add("F_INPUT_NAME", typeof(string));
table.Columns.Add("F_SYMB", typeof(string));
table.Columns.Add("F_NATC", typeof(string));
table.Columns.Add("F_FDTC", typeof(int));
return table;
}
private static DataTable TableWithExtraColumn()
{
var table = IdentityTable(("다우존스", "DJI@DJI", "US", "0"));
table.Columns.Add("UNEXPECTED", typeof(string));
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(
Func<RecordedCall, DataTable> handler,
bool throwBeforeHandlerWhenCanceled = true) : IDataQueryExecutor
{
private int _stringSqlCallCount;
public List<RecordedCall> Calls { get; } = [];
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException();
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException();
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
if (throwBeforeHandlerWhenCanceled)
{
cancellationToken.ThrowIfCancellationRequested();
}
query.ValidateFor(source);
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call).Copy());
}
}
}

View File

@@ -0,0 +1,102 @@
using System.Text;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyManualOperatorDataCodecTests
{
[Fact]
public void NetSell_requires_five_rows_and_the_original_empty_terminal_record()
{
var expected = Rows();
var canonical = LegacyManualOperatorDataCodec.SerializeNetSell(expected);
Assert.Equal(expected, LegacyManualOperatorDataCodec.ParseNetSell(canonical));
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.ParseNetSell(canonical.AsSpan(0, canonical.Length - 2)));
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeNetSell(expected.Take(4).ToArray()));
}
[Fact]
public void NetSell_rejects_unicode_bom_delimiters_and_non_cp949_text()
{
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.ParseNetSell(
Encoding.UTF8.GetBytes("a^b^c^d\r\n")));
var delimiter = Rows().ToArray();
delimiter[0] = delimiter[0] with { LeftName = "bad^name" };
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeNetSell(delimiter));
var emoji = Rows().ToArray();
emoji[0] = emoji[0] with { LeftName = "😀" };
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeNetSell(emoji));
}
[Fact]
public void Vi_preserves_original_order_and_duplicates_in_nine_columns()
{
LegacyManualViItem[] expected =
[
new("PKR7005930003", "삼성전자"),
new("PKR7016360000", "삼성증권"),
new("PKR7005930003", "삼성전자")
];
var bytes = LegacyManualOperatorDataCodec.SerializeVi(expected);
var actual = LegacyManualOperatorDataCodec.ParseVi(bytes);
Assert.Equal(expected, actual);
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Assert.All(
Encoding.GetEncoding(949).GetString(bytes)
.Split("\r\n", StringSplitOptions.RemoveEmptyEntries),
row => Assert.Equal(9, row.Split('^').Length));
}
[Fact]
public void Vi_enforces_twenty_pages_and_the_4000_byte_serialized_bound()
{
var tooMany = Enumerable.Range(1, 101)
.Select(index => new LegacyManualViItem(index.ToString("D6"), $"종목{index}"))
.ToArray();
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeVi(tooMany));
var tooLarge = Enumerable.Range(1, 100)
.Select(index => new LegacyManualViItem(
$"CODE{index:D3}" + new string('X', 30),
$"종목{index:D3}" + new string('가', 30)))
.ToArray();
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.SerializeVi(tooLarge));
}
[Fact]
public void Vi_accepts_an_existing_empty_file_but_rejects_nonempty_extra_columns()
{
Assert.Empty(LegacyManualOperatorDataCodec.ParseVi(Array.Empty<byte>()));
Assert.Throws<LegacyManualOperatorDataValidationException>(() =>
LegacyManualOperatorDataCodec.ParseVi(
Cp949("005930^삼성전자^unexpected^^^^^^\r\n")));
}
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 byte[] Cp949(string value)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(949).GetBytes(value);
}
}

View File

@@ -0,0 +1,398 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNamedComparisonRestoreServiceTests
{
[Fact]
public async Task ResolveAsync_RestoresFixedPairsWithoutGuessingOrDatabaseAccess()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판"),
Row(
itemIndex: 1,
groupCode: "지수",
subject: "코스피200 지수,KRX100지수",
graphicType: "2열판",
subtype: "예상체결")
]);
Assert.Collection(
result.Rows,
row =>
{
Assert.True(row.IsResolved);
Assert.Equal("two-column-current", row.ActionId);
Assert.Equal("Kospi", row.First!.MarketTarget);
Assert.Equal("Kosdaq", row.Second!.MarketTarget);
},
row =>
{
Assert.True(row.IsResolved);
Assert.Equal("two-column-expected", row.ActionId);
Assert.Equal("Kospi200", row.First!.MarketTarget);
Assert.Equal("Krx100", row.Second!.MarketTarget);
});
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("삼성전자,SK하이닉스")]
[InlineData("삼성전자(NXT),기아(NXT)")]
[InlineData("엔비디아,애플")]
public async Task ResolveAsync_RejectsTwoColumnStocksBecauseLegacyRowsLostEachMarket(
string subject)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(groupCode: "종목", subject: subject, graphicType: "2열판")
]);
var outcome = Assert.Single(result.Rows);
Assert.False(outcome.IsResolved);
Assert.Equal(
LegacyNamedComparisonRestoreFailure.MissingMarketIdentity,
outcome.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_ReportsUnsupportedFixedExpectedPairAsOneFailedRow()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "지수",
subject: "해외지수-다우,해외지수-나스닥",
graphicType: "2열판",
subtype: "예상체결")
]);
Assert.Equal(
LegacyNamedComparisonRestoreFailure.UnsupportedAction,
Assert.Single(result.Rows).Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_BlocksAllSixteenStaleMarketLostRowsWithoutSearchingByName()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var rows = Enumerable.Range(0, 16)
.Select(index => Row(
itemIndex: index,
groupCode: "종목",
subject: $"과거종목{index * 2},과거종목{index * 2 + 1}",
graphicType: "2열판"))
.ToArray();
var result = await service.ResolveAsync(rows);
Assert.Equal(16, result.Rows.Count);
Assert.All(result.Rows, outcome => Assert.Equal(
LegacyNamedComparisonRestoreFailure.MissingMarketIdentity,
outcome.Failure));
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("종목별 비교분석_캔들 그래프", "", "comparison-candle")]
[InlineData("종목별 비교분석_라인 그래프", "", "comparison-line")]
[InlineData("종목별 수익률 비교", "5일", "return-5d")]
[InlineData("종목별 수익률 비교", "1개월", "return-1m")]
[InlineData("종목별 수익률 비교", "3개월", "return-3m")]
[InlineData("종목별 수익률 비교", "6개월", "return-6m")]
[InlineData("종목별 수익률 비교", "12개월", "return-12m")]
public async Task ResolveAsync_RestoresEveryGraphSubtypeWithExactPerTargetMarkets(
string graphicType,
string subtype,
string expectedAction)
{
var executor = new RecordingExecutor(call =>
{
var name = Assert.IsType<string>(Assert.Single(call.Spec.Parameters).Value);
return name switch
{
"삼성전자" => StockTable("삼성전자", "005930"),
"에이비엘바이오" => StockTable("에이비엘바이오", "298380"),
_ => throw new InvalidOperationException()
};
});
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "코스피,코스닥",
subject: "삼성전자,에이비엘바이오",
graphicType: graphicType,
subtype: subtype)
]);
var outcome = Assert.Single(result.Rows);
Assert.True(outcome.IsResolved);
Assert.Equal(expectedAction, outcome.ActionId);
Assert.Equal("kospi", outcome.First!.Market);
Assert.Equal("005930", outcome.First.StockCode);
Assert.Equal("kosdaq", outcome.Second!.Market);
Assert.Equal("298380", outcome.Second.StockCode);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.DoesNotContain(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
call.Spec.Sql,
StringComparison.Ordinal);
});
Assert.Equal(0, executor.StringSqlCallCount);
}
[Fact]
public async Task ResolveAsync_CachesRepeatedIdentityAcrossRows()
{
var executor = new RecordingExecutor(call =>
StockTable(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
call.TableName.EndsWith("KOSPI", StringComparison.Ordinal)
? "005930"
: "298380"));
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "코스피,코스닥",
subject: "삼성전자,에이비엘바이오",
graphicType: "종목별 비교분석_캔들 그래프"),
Row(
itemIndex: 1,
groupCode: "코스피,코스닥",
subject: "삼성전자,에이비엘바이오",
graphicType: "종목별 수익률 비교",
subtype: "5일")
]);
Assert.All(result.Rows, row => Assert.True(row.IsResolved));
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ResolveAsync_FailsEachMissingAmbiguousAndMalformedIdentityClosed()
{
var executor = new RecordingExecutor(call =>
{
var name = Assert.IsType<string>(call.Spec.Parameters[0].Value);
return name switch
{
"없음" => StockTable(),
"중복" => StockTable(("중복", "111111"), ("중복", "222222")),
"오염" => WrongSchemaTable(),
"정상" => StockTable("정상", "333333"),
_ => throw new InvalidOperationException()
};
});
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "코스피,코스닥",
subject: "없음,정상",
graphicType: "종목별 비교분석_캔들 그래프"),
Row(
itemIndex: 1,
groupCode: "코스피,코스닥",
subject: "중복,정상",
graphicType: "종목별 비교분석_라인 그래프"),
Row(
itemIndex: 2,
groupCode: "코스피,코스닥",
subject: "오염,정상",
graphicType: "종목별 수익률 비교",
subtype: "1개월")
]);
Assert.Equal(
new LegacyNamedComparisonRestoreFailure?[]
{
LegacyNamedComparisonRestoreFailure.IdentityNotFound,
LegacyNamedComparisonRestoreFailure.IdentityAmbiguous,
LegacyNamedComparisonRestoreFailure.DatabaseDataInvalid
},
result.Rows.Select(row => row.Failure));
}
[Fact]
public async Task ResolveAsync_RejectsNxtGraphsBeforeTheyCanBecomePlayable()
{
var executor = new RecordingExecutor(call =>
StockTable(
Assert.IsType<string>(call.Spec.Parameters[0].Value),
"005930"));
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: "코스피_NXT,코스피_NXT",
subject: "삼성전자(NXT),기아(NXT)",
graphicType: "종목별 비교분석_캔들 그래프")
]);
var outcome = Assert.Single(result.Rows);
Assert.Equal(
LegacyNamedComparisonRestoreFailure.UnsupportedAction,
outcome.Failure);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("종목별 수익률 비교", "20일")]
[InlineData("종목별 비교분석_캔들 그래프", "5일")]
[InlineData("2열판", "CURRENT")]
public async Task ResolveAsync_RejectsUnknownOrCanonicalizedSubtypeGrammar(
string graphicType,
string subtype)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
var result = await service.ResolveAsync(
[
Row(
groupCode: graphicType == "2열판" ? "지수" : "코스피,코스피",
subject: graphicType == "2열판"
? "코스피 지수,코스닥 지수"
: "삼성전자,기아",
graphicType: graphicType,
subtype: subtype)
]);
Assert.Equal(
LegacyNamedComparisonRestoreFailure.InvalidRow,
Assert.Single(result.Rows).Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_HonorsCancellationAndRejectsDuplicateIndexes()
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedComparisonRestoreService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => service.ResolveAsync(
[Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판")],
cancellation.Token));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판"),
Row(groupCode: "지수", subject: "코스피 지수,코스닥 지수", graphicType: "2열판")
]));
Assert.Empty(executor.Calls);
}
private static LegacyNamedComparisonRestoreRow Row(
int itemIndex = 0,
string groupCode = "코스피,코스피",
string subject = "삼성전자,기아",
string graphicType = "종목별 비교분석_캔들 그래프",
string subtype = "",
string pageText = "1/1",
string dataCode = "") =>
new(
itemIndex,
true,
groupCode,
subject,
graphicType,
subtype,
pageText,
dataCode);
private static DataTable StockTable(params (string Name, string Code)[] rows)
{
var table = new DataTable();
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Name, row.Code);
}
return table;
}
private static DataTable StockTable(string name, string code) =>
StockTable((name, code));
private static DataTable WrongSchemaTable()
{
var table = new DataTable();
table.Columns.Add("stock_name", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string TableName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
: IDataQueryExecutor
{
private int _stringSqlCallCount;
public List<RecordedCall> Calls { get; } = [];
public int StringSqlCallCount => Volatile.Read(ref _stringSqlCallCount);
public DataTable Execute(DataSourceKind source, string tableName, string query)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException();
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string query,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _stringSqlCallCount);
throw new InvalidOperationException();
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
query.ValidateFor(source);
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call).Copy());
}
}
}

View File

@@ -0,0 +1,401 @@
#nullable enable
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyNamedNxtThemeRestoreServiceTests
{
public static TheoryData<string, string, string, string, int, string> SupportedRows =>
new()
{
{ "5단 표그래프", "테마-현재가(입력순)", "five-row-current", "s5074", 5, "INPUT_ORDER" },
{ "5단 표그래프", "테마-현재가(상승률순)", "five-row-current", "s5074", 5, "GAIN_DESC" },
{ "5단 표그래프", "테마-현재가(하락률순)", "five-row-current", "s5074", 5, "GAIN_ASC" },
{ "5단 표그래프", "테마-현재가", "five-row-current", "s5074", 5, "GAIN_ASC" },
{ "6종목 현재가", "테마-현재가(입력순)", "six-row-current", "s5077", 6, "INPUT_ORDER" },
{ "6종목 현재가", "테마-현재가(상승률순)", "six-row-current", "s5077", 6, "GAIN_DESC" },
{ "6종목 현재가", "테마-현재가(하락률순)", "six-row-current", "s5077", 6, "GAIN_ASC" },
{ "6종목 현재가", "테마-현재가", "six-row-current", "s5077", 6, "GAIN_ASC" },
{ "12종목 현재가", "테마-현재가(입력순)", "twelve-row-current", "s5088", 12, "INPUT_ORDER" },
{ "12종목 현재가", "테마-현재가(상승률순)", "twelve-row-current", "s5088", 12, "GAIN_DESC" },
{ "12종목 현재가", "테마-현재가(하락률순)", "twelve-row-current", "s5088", 12, "GAIN_ASC" },
{ "12종목 현재가", "테마-현재가", "twelve-row-current", "s5088", 12, "GAIN_ASC" }
};
[Theory]
[MemberData(nameof(SupportedRows))]
public async Task ResolveAsync_MapsOnlyOriginalCurrentPriceActionsAndRemapsStaleCode(
string graphicType,
string subtype,
string actionId,
string builderKey,
int pageSize,
string sort)
{
var executor = GoodExecutor("반도체", "87654321", previewCount: 2, liveCount: 2);
var service = new LegacyNamedNxtThemeRestoreService(
executor,
new FixedTimeProvider(new DateTimeOffset(2026, 7, 12, 12, 59, 0, TimeSpan.Zero)));
var outcome = Assert.Single((await service.ResolveAsync(
[Row(graphicType: graphicType, subtype: subtype)])).Rows);
Assert.True(outcome.IsResolved);
Assert.Null(outcome.Failure);
Assert.NotNull(outcome.Identity);
Assert.Equal(actionId, outcome.Identity.ActionId);
Assert.Equal(builderKey, outcome.Identity.BuilderKey);
Assert.Equal(builderKey[1..], outcome.Identity.CutCode);
Assert.Equal(pageSize, outcome.Identity.PageSize);
Assert.Equal(sort, outcome.Identity.Sort);
Assert.Equal(ThemeSession.PreMarket, outcome.Identity.Session);
Assert.Equal("반도체", outcome.Identity.ThemeTitle);
Assert.Equal("0123", outcome.Identity.StoredThemeCode);
Assert.Equal("87654321", outcome.Identity.CurrentThemeCode);
Assert.True(outcome.Identity.CodeWasRemapped);
Assert.Equal(2, outcome.Identity.PreviewItemCount);
Assert.Equal(2, outcome.Identity.LiveItemCount);
Assert.Equal(2, executor.Calls.Count);
var identityCall = executor.Calls[0];
Assert.Equal(DataSourceKind.MariaDb, identityCall.Source);
Assert.Equal(LegacyNamedNxtThemeRestoreService.IdentityQueryName, identityCall.QueryName);
identityCall.Spec.ValidateFor(DataSourceKind.MariaDb);
Assert.Contains("BINARY s.SB_TITLE = BINARY @theme_title", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("BINARY d.SB_CODE = BINARY s.SB_CODE", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("F_CURR_PRICE != 0", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LIMIT 2", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("반도체", identityCall.Spec.Sql, StringComparison.Ordinal);
Assert.Collection(identityCall.Spec.Parameters, parameter =>
{
Assert.Equal("theme_title", parameter.Name);
Assert.Equal("반도체", parameter.Value);
Assert.Equal(DbType.String, parameter.DbType);
});
var previewCall = executor.Calls[1];
Assert.Equal("THEME_PREVIEW_NXT", previewCall.QueryName);
Assert.Equal("87654321", previewCall.Spec.Parameters[0].Value);
Assert.Equal("반도체", previewCall.Spec.Parameters[1].Value);
Assert.Equal(241, previewCall.Spec.Parameters[2].Value);
Assert.Equal(0, executor.StringSqlCallCount);
}
[Theory]
[InlineData(0, ThemeSession.PreMarket)]
[InlineData(12, ThemeSession.PreMarket)]
[InlineData(13, ThemeSession.AfterMarket)]
[InlineData(23, ThemeSession.AfterMarket)]
public void SessionForLocalHour_PreservesOriginalBoundary(
int hour,
ThemeSession expected) =>
Assert.Equal(expected, LegacyNamedNxtThemeRestoreService.SessionForLocalHour(hour));
[Fact]
public async Task ResolveAsync_CachesExactTitleButPreservesBatchOrderAndStoredCodes()
{
var executor = GoodExecutor("AI", "90000001", previewCount: 1, liveCount: 1);
var service = new LegacyNamedNxtThemeRestoreService(executor);
var rows = new[]
{
Row(itemIndex: 7, subject: "AI(NXT)", dataCode: "111"),
Row(itemIndex: 2, subject: "AI(NXT)", dataCode: "2222", graphicType: "6종목 현재가")
};
var result = await service.ResolveAsync(rows);
Assert.Equal([7, 2], result.Rows.Select(row => row.ItemIndex));
Assert.Equal(["111", "2222"], result.Rows.Select(row => row.Identity!.StoredThemeCode));
Assert.All(result.Rows, row => Assert.Equal("90000001", row.Identity!.CurrentThemeCode));
Assert.Equal(2, executor.Calls.Count);
}
[Theory]
[InlineData("테마", "반도체(NXT)", "5단 표그래프", "테마-현재가", "1/1", "0123")]
[InlineData("테마_NXT", "반도체", "5단 표그래프", "테마-현재가", "1/1", "0123")]
[InlineData("테마_NXT", "반도체(NXT)", "5단 표그래프", "테마-현재가", "01/1", "0123")]
[InlineData("테마_NXT", "반도체(NXT)", "5단 표그래프", "테마-현재가", "1/1", "R0123")]
public async Task ResolveAsync_InvalidRowsFailWithoutDatabaseAccess(
string groupCode,
string subject,
string graphicType,
string subtype,
string pageText,
string dataCode)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var service = new LegacyNamedNxtThemeRestoreService(executor);
var row = Row(
groupCode: groupCode,
subject: subject,
graphicType: graphicType,
subtype: subtype,
pageText: pageText,
dataCode: dataCode);
var outcome = Assert.Single((await service.ResolveAsync([row])).Rows);
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.InvalidRow, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Theory]
[InlineData("5단 표그래프", "테마-예상체결가(입력순)")]
[InlineData("네모그래프", "테마-현재가(입력순)")]
[InlineData("5단 표그래프", "테마-현재가(거래량순)")]
public async Task ResolveAsync_UnsupportedActionsFailClosedPerRow(
string graphicType,
string subtype)
{
var executor = new RecordingExecutor(_ => throw new InvalidOperationException());
var outcome = Assert.Single((await new LegacyNamedNxtThemeRestoreService(executor)
.ResolveAsync([Row(graphicType: graphicType, subtype: subtype)])).Rows);
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.UnsupportedAction, outcome.Failure);
Assert.Empty(executor.Calls);
}
[Fact]
public async Task ResolveAsync_ReportsNotFoundAndAmbiguousPerTitleWithoutPreview()
{
var executor = new RecordingExecutor(call =>
{
var title = Assert.IsType<string>(call.Spec.Parameters[0].Value);
return title == "없음"
? IdentityTable()
: IdentityTable(
(title, "111", "NXT", "1", "2", "2"),
(title, "222", "NXT", "1", "2", "2"));
});
var result = await new LegacyNamedNxtThemeRestoreService(executor).ResolveAsync(
[
Row(itemIndex: 0, subject: "없음(NXT)"),
Row(itemIndex: 1, subject: "중복(NXT)")
]);
Assert.Equal(
[
LegacyNamedNxtThemeRestoreFailure.IdentityNotFound,
LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous
],
result.Rows.Select(row => row.Failure));
Assert.Equal(2, executor.Calls.Count);
}
[Theory]
[InlineData(0, 0, LegacyNamedNxtThemeRestoreFailure.PreviewEmpty)]
[InlineData(2, 0, LegacyNamedNxtThemeRestoreFailure.LiveItemsEmpty)]
public async Task ResolveAsync_RequiresValidatedPreviewAndAtLeastOneLiveItem(
int previewCount,
int liveCount,
LegacyNamedNxtThemeRestoreFailure expected)
{
var executor = new RecordingExecutor(call => call.QueryName switch
{
LegacyNamedNxtThemeRestoreService.IdentityQueryName =>
IdentityTable(("반도체", "00000001", "NXT", "1",
previewCount.ToString(), liveCount.ToString())),
"THEME_PREVIEW_NXT" => previewCount == 0
? EmptyPreviewTable("반도체", "00000001")
: PreviewTable("반도체", "00000001", previewCount),
_ => throw new InvalidOperationException(call.QueryName)
});
var outcome = Assert.Single((await new LegacyNamedNxtThemeRestoreService(executor)
.ResolveAsync([Row()])).Rows);
Assert.Equal(expected, outcome.Failure);
Assert.False(outcome.IsResolved);
Assert.Equal(2, executor.Calls.Count);
}
[Fact]
public async Task ResolveAsync_FailsClosedOnInvalidDatabaseSchemaOrCodeCardinality()
{
var wrongSchemaExecutor = new RecordingExecutor(_ => new DataTable());
var wrongSchema = Assert.Single((await new LegacyNamedNxtThemeRestoreService(
wrongSchemaExecutor).ResolveAsync([Row()])).Rows);
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.DatabaseDataInvalid, wrongSchema.Failure);
var duplicateCodeExecutor = new RecordingExecutor(_ =>
IdentityTable(("반도체", "00000001", "NXT", "2", "1", "1")));
var duplicateCode = Assert.Single((await new LegacyNamedNxtThemeRestoreService(
duplicateCodeExecutor).ResolveAsync([Row()])).Rows);
Assert.Equal(LegacyNamedNxtThemeRestoreFailure.IdentityAmbiguous, duplicateCode.Failure);
}
[Fact]
public async Task ResolveAsync_PropagatesReadFailureOnceAndHonorsCancellation()
{
var failing = new RecordingExecutor(_ => throw new DataException("simulated"));
await Assert.ThrowsAsync<DataException>(() =>
new LegacyNamedNxtThemeRestoreService(failing).ResolveAsync([Row()]));
Assert.Single(failing.Calls);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var canceled = new RecordingExecutor(_ => throw new InvalidOperationException());
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new LegacyNamedNxtThemeRestoreService(canceled)
.ResolveAsync([Row()], cancellation.Token));
Assert.Empty(canceled.Calls);
}
[Fact]
public async Task ResolveAsync_RejectsInvalidBatchAndAllowsDisabledRows()
{
var executor = GoodExecutor("반도체", "00000001", 1, 1);
var service = new LegacyNamedNxtThemeRestoreService(executor);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => service.ResolveAsync([]));
await Assert.ThrowsAsync<ArgumentException>(() => service.ResolveAsync(
[Row(itemIndex: 3), Row(itemIndex: 3)]));
var outcome = Assert.Single((await service.ResolveAsync([Row(isEnabled: false)])).Rows);
Assert.True(outcome.IsResolved);
Assert.True(LegacyNamedNxtThemeRestoreService.IsCandidate(Row(isEnabled: false)));
}
private static RecordingExecutor GoodExecutor(
string title,
string code,
int previewCount,
int liveCount) =>
new(call => call.QueryName switch
{
LegacyNamedNxtThemeRestoreService.IdentityQueryName => IdentityTable((
title,
code,
"NXT",
"1",
previewCount.ToString(),
liveCount.ToString())),
"THEME_PREVIEW_NXT" => PreviewTable(title, code, previewCount),
_ => throw new InvalidOperationException(call.QueryName)
});
private static LegacyNamedNxtThemeRestoreRow Row(
int itemIndex = 0,
bool isEnabled = true,
string groupCode = "테마_NXT",
string subject = "반도체(NXT)",
string graphicType = "5단 표그래프",
string subtype = "테마-현재가(입력순)",
string pageText = "1/1",
string dataCode = "0123") =>
new(itemIndex, isEnabled, groupCode, subject, graphicType, subtype, pageText, dataCode);
private static DataTable IdentityTable(
params (string Title, string Code, string Market, string CodeCount,
string PreviewCount, string LiveCount)[] rows)
{
var table = new DataTable();
table.Columns.Add("THEME_TITLE", typeof(string));
table.Columns.Add("THEME_CODE", typeof(string));
table.Columns.Add("THEME_MARKET", typeof(string));
table.Columns.Add("CODE_IDENTITY_COUNT", typeof(string));
table.Columns.Add("PREVIEW_ITEM_COUNT", typeof(string));
table.Columns.Add("LIVE_ITEM_COUNT", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(
row.Title,
row.Code,
row.Market,
row.CodeCount,
row.PreviewCount,
row.LiveCount);
}
return table;
}
private static DataTable PreviewTable(string title, string code, int count)
{
var table = CreatePreviewTable();
for (var index = 0; index < count; index++)
{
table.Rows.Add(
title,
code,
"NXT",
$"종목{index + 1}",
$"X{index + 1:D6}",
index.ToString());
}
return table;
}
private static DataTable EmptyPreviewTable(string title, string code)
{
var table = CreatePreviewTable();
table.Rows.Add(title, code, "NXT", DBNull.Value, DBNull.Value, DBNull.Value);
return table;
}
private static DataTable CreatePreviewTable()
{
var table = new DataTable();
table.Columns.Add("THEME_TITLE", typeof(string));
table.Columns.Add("THEME_CODE", typeof(string));
table.Columns.Add("THEME_MARKET", typeof(string));
table.Columns.Add("ITEM_NAME", typeof(string));
table.Columns.Add("ITEM_CODE", typeof(string));
table.Columns.Add("ITEM_INDEX", typeof(string));
return table;
}
private sealed record RecordedCall(
DataSourceKind Source,
string QueryName,
DataQuerySpec Spec,
CancellationToken CancellationToken);
private sealed class RecordingExecutor(Func<RecordedCall, DataTable> handler)
: IDataQueryExecutor
{
public List<RecordedCall> Calls { get; } = [];
public int StringSqlCallCount { get; private set; }
public DataTable Execute(
DataSourceKind source,
string tableName,
string query)
{
StringSqlCallCount++;
throw new InvalidOperationException("String SQL must not be used.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
string sql,
CancellationToken cancellationToken = default)
{
StringSqlCallCount++;
throw new InvalidOperationException("String SQL must not be used.");
}
public Task<DataTable> ExecuteAsync(
DataSourceKind source,
string tableName,
DataQuerySpec query,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var call = new RecordedCall(source, tableName, query, cancellationToken);
Calls.Add(call);
return Task.FromResult(handler(call));
}
}
private sealed class FixedTimeProvider(DateTimeOffset value) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => value.ToUniversalTime();
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
}

View File

@@ -150,6 +150,21 @@ public sealed class LegacyNamedPlaylistPersistenceServiceTests
});
}
[Fact]
public async Task LoadAsync_AllowsManualViSubjectBeyondTheGeneralFieldLimit()
{
var names = string.Join(",", Enumerable.Range(0, 100).Select(index => $"종목{index:D3}"));
Assert.True(names.Length > 256);
Assert.True($"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^".Length <= 4_000);
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => LoadTable(
("00000001", "VI", $"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^", "0"))));
var document = await service.LoadAsync("00000001");
Assert.Equal(names, Assert.Single(document.Items).Selection.Subject);
}
[Fact]
public async Task LoadAsync_ReturnsEmptyDocumentFromLeftJoinSentinel()
{
@@ -294,6 +309,28 @@ public sealed class LegacyNamedPlaylistPersistenceServiceTests
Assert.Equal(NamedPlaylistDbCommandKind.DeleteItems, command.Kind);
}
[Fact]
public async Task ReplaceItemsAsync_AllowsManualViSubjectBeyondTheGeneralFieldLimit()
{
var names = string.Join(",", Enumerable.Range(0, 100).Select(index => $"종목{index:D3}"));
var mutations = new RecordingMutationExecutor(call => Result(call, [0, 1]));
var service = new LegacyNamedPlaylistPersistenceService(
new RecordingQueryExecutor(_ => throw new InvalidOperationException()),
mutations);
await service.ReplaceItemsAsync(
"00000001",
[Item(0, true, "", names, "5단 표그래프", "VI 발동(수동)", "", 1, 20)]);
var insert = Assert.Single(
Assert.Single(mutations.Calls).Transaction.Commands,
command => command.Kind == NamedPlaylistDbCommandKind.InsertItem);
var listText = Assert.Single(
insert.Parameters,
parameter => parameter.Name == "list_text");
Assert.Equal($"1^^{names}^5단 표그래프^VI 발동(수동)^1/20^", listText.Value);
}
[Fact]
public async Task ReplaceItemsAsync_RejectsGapsAndCaretInjectionBeforeMutation()
{

View File

@@ -0,0 +1,162 @@
using System.Data;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.Core.Tests;
public sealed class LegacyStockMasterIdentityValidationServiceTests
{
[Fact]
public async Task ThemeItems_ResolveExactPrefixMarketWithBoundFreshQueries()
{
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
{
"STOCK_MASTER_VERIFY_KOSPI" => StockTable(("삼성전자", "005930")),
"STOCK_MASTER_VERIFY_KOSDAQ" => StockTable(("카카오", "035720")),
_ => throw new InvalidOperationException(call.TableName)
});
var service = new LegacyStockMasterIdentityValidationService(executor);
var result = await service.ValidateThemeItemsAsync(
[
new ThemeCatalogItem(0, "P005930", "삼성전자"),
new ThemeCatalogItem(1, "D035720", "카카오")
]);
Assert.Equal(
[
new StockMasterIdentity(
StockMarket.Kospi,
DataSourceKind.Oracle,
"삼성전자",
"005930"),
new StockMasterIdentity(
StockMarket.Kosdaq,
DataSourceKind.Oracle,
"카카오",
"035720")
], result);
Assert.Equal(2, executor.Calls.Count);
Assert.All(executor.Calls, call =>
{
call.Spec.ValidateFor(call.Source);
Assert.Equal(2, call.Spec.Parameters.Count);
Assert.DoesNotContain("LIKE", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
});
}
[Fact]
public async Task NxtThemeItem_ValidatesStoredNameWithoutDisplaySuffix()
{
var executor = new CatalogRecordingQueryExecutor(call =>
{
Assert.Equal("STOCK_MASTER_VERIFY_NXT_KOSPI", call.TableName);
Assert.DoesNotContain("CONCAT", call.Spec.Sql, StringComparison.OrdinalIgnoreCase);
Assert.Equal("삼성전자", call.Spec.Parameters[1].Value);
return StockTable(("삼성전자", "005930"));
});
var service = new LegacyStockMasterIdentityValidationService(executor);
var result = await service.ValidateThemeItemsAsync(
[new ThemeCatalogItem(0, "X005930", "삼성전자")]);
var identity = Assert.Single(result);
Assert.Equal(StockMarket.NxtKospi, identity.Market);
Assert.Equal(DataSourceKind.MariaDb, identity.Source);
Assert.Equal("삼성전자", identity.Name);
}
[Fact]
public async Task ExpertRecommendation_UsesNxtDisplaySuffixAndRequiresOneMarket()
{
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
{
"STOCK_MASTER_VERIFY_NXT_KOSPI" => StockTable(("삼성전자(NXT)", "005930")),
_ => StockTable()
});
var service = new LegacyStockMasterIdentityValidationService(executor);
var result = await service.ValidateExpertRecommendationsAsync(
[new ExpertCatalogRecommendation(0, "005930", "삼성전자(NXT)", 70_000)]);
var identity = Assert.Single(result);
Assert.Equal(StockMarket.NxtKospi, identity.Market);
Assert.Equal("삼성전자(NXT)", identity.Name);
Assert.Equal(4, executor.Calls.Count);
var nxt = Assert.Single(
executor.Calls,
call => call.TableName == "STOCK_MASTER_VERIFY_NXT_KOSPI");
Assert.Contains("CONCAT", nxt.Spec.Sql, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ExpertRecommendation_RejectsSameExactIdentityAcrossMarkets()
{
var executor = new CatalogRecordingQueryExecutor(call => call.TableName switch
{
"STOCK_MASTER_VERIFY_KOSPI" or "STOCK_MASTER_VERIFY_KOSDAQ" =>
StockTable(("동명이인", "000001")),
_ => StockTable()
});
var service = new LegacyStockMasterIdentityValidationService(executor);
await Assert.ThrowsAsync<StockMasterIdentityDataException>(() =>
service.ValidateExpertRecommendationsAsync(
[new ExpertCatalogRecommendation(0, "000001", "동명이인", 1)]));
}
[Theory]
[InlineData("missing")]
[InlineData("duplicate")]
[InlineData("extra-column")]
public async Task ThemeItem_RejectsMissingDuplicateOrExpandedResult(string mode)
{
var executor = new CatalogRecordingQueryExecutor(_ => mode switch
{
"missing" => StockTable(),
"duplicate" => StockTable(("삼성전자", "005930"), ("삼성전자", "005930")),
"extra-column" => StockTableWithExtraColumn(("삼성전자", "005930")),
_ => throw new InvalidOperationException(mode)
});
var service = new LegacyStockMasterIdentityValidationService(executor);
await Assert.ThrowsAsync<StockMasterIdentityDataException>(() =>
service.ValidateThemeItemsAsync(
[new ThemeCatalogItem(0, "P005930", "삼성전자")]));
}
[Fact]
public async Task PreCanceledValidation_DoesNotTouchTheDatabase()
{
var executor = new CatalogRecordingQueryExecutor(_ => StockTable());
var service = new LegacyStockMasterIdentityValidationService(executor);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.ValidateThemeItemsAsync(
[new ThemeCatalogItem(0, "P005930", "삼성전자")],
cancellation.Token));
Assert.Empty(executor.Calls);
}
private static DataTable StockTable(params (string Name, string Code)[] rows)
{
var table = new DataTable();
table.Columns.Add("STOCK_NAME", typeof(string));
table.Columns.Add("STOCK_CODE", typeof(string));
foreach (var row in rows)
{
table.Rows.Add(row.Name, row.Code);
}
return table;
}
private static DataTable StockTableWithExtraColumn(params (string Name, string Code)[] rows)
{
var table = StockTable(rows);
table.Columns.Add("UNEXPECTED", typeof(string));
return table;
}
}

View File

@@ -28,6 +28,7 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
{
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Contains("TO_NUMBER(TRIM(SB_CODE))", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("'^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("FROM SB_LIST", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
},
@@ -35,7 +36,8 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
{
Assert.Equal(DataSourceKind.MariaDb, call.Source);
Assert.Contains("CAST(TRIM(SB_CODE) AS UNSIGNED)", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("REGEXP '^[0-9]{8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("REGEXP BINARY '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.DoesNotContain("INVALID", call.Spec.Sql, StringComparison.Ordinal);
Assert.Empty(call.Spec.Parameters);
});
}
@@ -166,6 +168,60 @@ public sealed class LegacyThemeCatalogPersistenceServiceTests
transaction.Commands[1].AffectedRowsRule);
}
[Theory]
[InlineData("123")]
[InlineData("0123")]
[InlineData("000123")]
[InlineData("00000123")]
public async Task ExistingIdentity_PreservesLegacyThreeThroughEightDigitCode(string code)
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
code,
"기존 테마");
await service.ReplaceAsync(identity, "수정 테마", []);
var transaction = Assert.Single(mutation.Calls).Transaction;
Assert.Equal(code, transaction.IdentityCode);
Assert.Equal(code, Parameter(transaction.Commands[0], "theme_code").Value);
}
[Theory]
[InlineData("12")]
[InlineData("123456789")]
[InlineData("12A")]
public async Task ExistingIdentity_RejectsCodeOutsideClosedLegacyRange(string code)
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
var identity = new ThemeSelectionIdentity(
ThemeMarket.Krx,
ThemeSession.NotApplicable,
code,
"기존 테마");
await Assert.ThrowsAsync<ArgumentException>(() =>
service.DeleteAsync(identity));
Assert.Empty(mutation.Calls);
}
[Fact]
public async Task CreateStillRequiresNewEightDigitCode()
{
var mutation = new CatalogRecordingMutationExecutor();
var service = new LegacyThemeCatalogPersistenceService(UnusedQuery(), mutation);
await Assert.ThrowsAsync<ArgumentException>(() => service.CreateAsync(
new ThemeCatalogDefinition(ThemeMarket.Krx, "123", "신규 테마", [])));
Assert.Empty(mutation.Calls);
}
[Fact]
public async Task DeleteAsync_DeletesChildrenBeforeIdentityBoundParent()
{

View File

@@ -58,6 +58,9 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.Equal(DataSourceKind.Oracle, call.Source);
Assert.Equal("THEME_SEARCH_KRX", call.TableName);
Assert.Contains("SB_MARKET = 'KRX'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("REGEXP_LIKE(s.SB_CODE, '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("d.SB_TITLE = s.SB_TITLE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("d.SB_CODE = s.SB_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ROWNUM <= :row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("AI", call.Spec.Parameters[0].Value);
Assert.Equal(26, call.Spec.Parameters[1].Value);
@@ -68,6 +71,9 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.Equal(DataSourceKind.MariaDb, call.Source);
Assert.Equal("THEME_SEARCH_NXT", call.TableName);
Assert.Contains("SB_MARKET = 'NXT'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("s.SB_CODE REGEXP BINARY '^[0-9]{3,8}$'", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("BINARY d.SB_TITLE = BINARY s.SB_TITLE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("BINARY d.SB_CODE = BINARY s.SB_CODE", call.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("LIMIT @row_limit", call.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("AI", call.Spec.Parameters[0].Value);
Assert.Equal(26, call.Spec.Parameters[1].Value);
@@ -97,6 +103,24 @@ public sealed class LegacyThemeSelectionServiceTests
Assert.All(executor.Calls, call => Assert.Equal("A!!!_!%", call.Spec.Parameters[0].Value));
}
[Fact]
public async Task SearchAsync_PreservesLegacyThreeAndFourDigitReadIdentities()
{
var executor = new RecordingExecutor(call => call.Source switch
{
DataSourceKind.Oracle => SearchTable(("Legacy KRX", "123", "KRX")),
DataSourceKind.MariaDb => SearchTable(("Legacy NXT", "0123", "NXT")),
_ => throw new InvalidOperationException()
});
var service = new LegacyThemeSelectionService(executor);
var result = await service.SearchAsync("", ThemeSession.PreMarket);
Assert.Equal(
["123", "0123"],
result.Items.Select(static item => item.Identity.ThemeCode));
}
[Fact]
public async Task SearchAsync_AppliesCombinedBoundAndDeterministicMarketOrder()
{
@@ -340,7 +364,7 @@ public sealed class LegacyThemeSelectionServiceTests
new(ThemeMarket.Nxt, ThemeSession.NotApplicable, "00000001", "AI"),
new(ThemeMarket.Nxt, (ThemeSession)99, "00000001", "AI"),
new((ThemeMarket)99, ThemeSession.NotApplicable, "00000001", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "1", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "12", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "0000000A", "AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", " AI"),
new(ThemeMarket.Krx, ThemeSession.NotApplicable, "00000001", "bad\u0000title")

View File

@@ -22,4 +22,10 @@
<ProjectReference Include="..\..\src\MBN_STOCK_WEBVIEW.Core\MBN_STOCK_WEBVIEW.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\scripts\LegacyCutRequirements.json"
Link="Fixtures\LegacyCutRequirements.json"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -90,6 +90,7 @@ public sealed class PagedQuoteSceneDataLoadersTests
var data = await new S5088SceneDataLoader(executor).LoadAsync(
new ThemePagedQuoteLoadRequest(
"0001",
"AI",
PagedQuoteThemeSort.GainRateDescending,
PagedQuoteThemeValueKind.ExpectedExecution));
@@ -97,13 +98,15 @@ public sealed class PagedQuoteSceneDataLoadersTests
Assert.Equal(DataSourceKind.Oracle, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("l.SB_CODE = :themeCode", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("l.SB_TITLE = :themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("t_online1_call", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY q.RATE DESC", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal(new[] { "themeTitle", "maxRows" },
Assert.Equal(new[] { "themeCode", "themeTitle", "maxRows" },
executor.Spec.Parameters.Select(parameter => parameter.Name));
Assert.Equal("AI", executor.Spec.Parameters[0].Value);
Assert.Equal(240, executor.Spec.Parameters[1].Value);
Assert.Equal("0001", executor.Spec.Parameters[0].Value);
Assert.Equal("AI", executor.Spec.Parameters[1].Value);
Assert.Equal(240, executor.Spec.Parameters[2].Value);
Assert.Equal(PagedQuoteBranch.Theme, data.Branch);
Assert.Equal(PagedQuoteTitleStyle.ExpectedExecution, data.TitleStyle);
Assert.Equal(NxtMarketSession.NotApplicable, data.NxtSession);
@@ -120,16 +123,19 @@ public sealed class PagedQuoteSceneDataLoadersTests
var data = await new S5074SceneDataLoader(executor).LoadAsync(
new NxtThemePagedQuoteLoadRequest(
"0002",
"Robotics(NXT)",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket));
Assert.Equal(DataSourceKind.MariaDb, executor.Source);
Assert.NotNull(executor.Spec);
Assert.Contains("l.SB_CODE = @themeCode", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("l.SB_TITLE = @themeTitle", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("l.SB_MARKET = 'NXT'", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Contains("ORDER BY i.SB_I_INDEX", executor.Spec.Sql, StringComparison.Ordinal);
Assert.Equal("Robotics", executor.Spec.Parameters[0].Value);
Assert.Equal("0002", executor.Spec.Parameters[0].Value);
Assert.Equal("Robotics", executor.Spec.Parameters[1].Value);
Assert.Equal(PagedQuoteBranch.NxtTheme, data.Branch);
Assert.Equal(NxtMarketSession.PreMarket, data.NxtSession);
Assert.Equal(PagedQuoteTitleStyle.Exact, data.TitleStyle);
@@ -351,6 +357,7 @@ public sealed class PagedQuoteSceneDataLoadersTests
await new S5074SceneDataLoader(executor).LoadAsync(
new ThemePagedQuoteLoadRequest(
"123",
"Theme",
sort,
PagedQuoteThemeValueKind.Current));
@@ -456,17 +463,40 @@ public sealed class PagedQuoteSceneDataLoadersTests
NxtPagedQuoteList.GainRate,
NxtMarketSession.NotApplicable),
new ThemePagedQuoteLoadRequest(
"123",
"Theme",
(PagedQuoteThemeSort)99,
PagedQuoteThemeValueKind.Current),
new ThemePagedQuoteLoadRequest(
"123",
"Theme",
PagedQuoteThemeSort.InputOrder,
(PagedQuoteThemeValueKind)99),
new NxtThemePagedQuoteLoadRequest(
"123",
"Theme",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.NotApplicable),
new ThemePagedQuoteLoadRequest(
"12",
"Theme",
PagedQuoteThemeSort.InputOrder,
PagedQuoteThemeValueKind.Current),
new NxtThemePagedQuoteLoadRequest(
"12A4",
"Theme",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket),
new NxtThemePagedQuoteLoadRequest(
"1234",
"Theme",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket),
new NxtThemePagedQuoteLoadRequest(
"1234",
"Theme(NXT)Copy(NXT)",
PagedQuoteThemeSort.InputOrder,
NxtMarketSession.PreMarket),
new ExpertPagedQuoteLoadRequest("001", "Analyst A"),
new ViPagedQuoteLoadRequest([])
];

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()
{

View File

@@ -18,6 +18,7 @@ public sealed class PlayoutOptionsLoaderTests
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET",
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT",
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE",
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY",
"MBN_STOCK_PLAYOUT_SCENE_DIRECTORY",
"MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN",
"MBN_STOCK_PLAYOUT_QUEUE_CAPACITY",
@@ -53,6 +54,7 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Null(options.LegacySceneBackgroundAssetPath);
Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount);
Assert.True(options.LegacySceneBackgroundVideoLoopInfinite);
Assert.Null(options.LegacyBackgroundDirectory);
Assert.Empty(options.TestSceneAllowlist);
Assert.False(options.TrustedLiveOutputEnabled);
Assert.True(options.ReconnectEnabled);
@@ -74,6 +76,7 @@ public sealed class PlayoutOptionsLoaderTests
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET", "Video\\studio.vrv")
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT", "2004")
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE", "false")
.Set("MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY", "C:\\env-backgrounds")
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\env-scenes")
.Set("MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN", "ENV TEST")
.Set("MBN_STOCK_PLAYOUT_QUEUE_CAPACITY", "14")
@@ -125,6 +128,7 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Equal("Video\\studio.vrv", options.LegacySceneBackgroundAssetPath);
Assert.Equal(2004, options.LegacySceneBackgroundVideoLoopCount);
Assert.False(options.LegacySceneBackgroundVideoLoopInfinite);
Assert.Equal("C:\\env-backgrounds", options.LegacyBackgroundDirectory);
Assert.Equal("C:\\env-scenes", options.SceneDirectory);
Assert.Equal("ENV TEST", options.TestProcessWindowTitlePattern);
Assert.Equal(14, options.QueueCapacity);

View File

@@ -368,6 +368,85 @@ public sealed class PlayoutSafetyValidationTests : IDisposable
ignoreCase: true);
}
[Fact]
public void ResolveCue_OperatorBackgroundUsesOnlyTheSeparateTrustedRoot()
{
using var backgrounds = TemporarySceneDirectory.Create("studio.vrv");
var options = ValidTestOptions();
options.LegacyBackgroundDirectory = backgrounds.Path;
var validated = ValidatedPlayoutOptions.Create(options);
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"studio.vrv",
LoopCount: 2004,
LoopInfinite: true,
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
]));
var video = Assert.IsType<PlayoutSetBackgroundVideo>(Assert.Single(resolved.Mutations!));
Assert.Equal(
Path.Combine(backgrounds.Path, "studio.vrv"),
video.AssetPath,
ignoreCase: true);
Assert.Equal(PlayoutAssetRoot.OperatorBackgroundDirectory, video.AssetRoot);
}
[Fact]
public void ResolveCue_OperatorBackgroundDoesNotFallBackToCutsRoot()
{
using var backgrounds = TemporarySceneDirectory.Create();
var options = ValidTestOptions();
options.LegacyBackgroundDirectory = backgrounds.Path;
var validated = ValidatedPlayoutOptions.Create(options);
Assert.Throws<PlayoutRequestException>(() => validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"Video\\background.vrv",
LoopCount: 2004,
LoopInfinite: true,
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
])));
}
[Fact]
public void ResolveCue_RevalidatesConfiguredBackgroundRootAtCommandTime()
{
var lateRoot = Path.Combine(Directory.GetParent(_scenes.Path)!.FullName, "late-background");
var options = ValidTestOptions();
options.LegacyBackgroundDirectory = lateRoot;
var validated = ValidatedPlayoutOptions.Create(options);
Directory.CreateDirectory(lateRoot);
File.WriteAllText(Path.Combine(lateRoot, "studio.vrv"), "late trusted background");
var resolved = validated.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
Mutations:
[
new PlayoutSetBackgroundVideo(
"studio.vrv",
LoopCount: 2004,
LoopInfinite: true,
AssetRoot: PlayoutAssetRoot.OperatorBackgroundDirectory)
]));
var video = Assert.IsType<PlayoutSetBackgroundVideo>(Assert.Single(resolved.Mutations!));
Assert.Equal(
Path.Combine(lateRoot, "studio.vrv"),
video.AssetPath,
ignoreCase: true);
}
[Fact]
public void ResolveCue_AcceptsTheLegacy2004BackgroundVideoLoopValue()
{

View File

@@ -1,3 +1,4 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
@@ -17,18 +18,24 @@ public sealed class PlayoutSceneCompositionFactoryTests
[Fact]
public void TrustedRelativeVideo_IsVerifiedEvenInDryRun()
{
using var scenes = TemporarySceneDirectory.Create("Video\\studio.vrv");
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var backgroundRoot = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경");
Directory.CreateDirectory(backgroundRoot);
File.WriteAllText(Path.Combine(backgroundRoot, "studio.vrv"), "trusted background");
var result = PlayoutSceneCompositionFactory.Create(new PlayoutOptions
{
Mode = PlayoutMode.DryRun,
SceneDirectory = scenes.Path,
LegacySceneBackgroundKind = LegacySceneBackgroundKind.Video,
LegacySceneBackgroundAssetPath = "Video\\studio.vrv"
LegacySceneBackgroundAssetPath = "studio.vrv"
});
Assert.Equal(LegacySceneBackgroundKind.Video, result.BackgroundKind);
Assert.Equal(Path.Combine("Video", "studio.vrv"), result.BackgroundAssetPath);
Assert.Equal("studio.vrv", result.BackgroundAssetPath);
Assert.Equal(2004, result.BackgroundVideoLoopCount);
Assert.Equal(
PlayoutAssetRoot.OperatorBackgroundDirectory,
result.BackgroundAssetRoot);
}
[Theory]
@@ -38,7 +45,13 @@ public sealed class PlayoutSceneCompositionFactoryTests
[InlineData("Video\\studio.exe")]
public void InvalidOrMissingBackground_FailsBeforeDryRunPrepare(string relativePath)
{
using var scenes = TemporarySceneDirectory.Create("Video\\studio.vrv");
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
var backgroundRoot = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경");
Directory.CreateDirectory(backgroundRoot);
Directory.CreateDirectory(Path.Combine(backgroundRoot, "Video"));
File.WriteAllText(
Path.Combine(backgroundRoot, "Video", "studio.vrv"),
"trusted background");
var options = new PlayoutOptions
{
Mode = PlayoutMode.DryRun,
@@ -50,4 +63,33 @@ public sealed class PlayoutSceneCompositionFactoryTests
Assert.Throws<PlayoutConfigurationException>(() =>
PlayoutSceneCompositionFactory.Create(options));
}
[Fact]
public void LegacyDefault_UsesSiblingBackgroundRoot_NotTheCutsRoot()
{
using var scenes = TemporarySceneDirectory.Create("기본.vrv");
var backgroundRoot = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경");
Directory.CreateDirectory(backgroundRoot);
File.WriteAllText(
Path.Combine(backgroundRoot, PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName),
"trusted background");
var result = PlayoutSceneCompositionFactory.CreateLegacyDefault(
new PlayoutOptions { SceneDirectory = scenes.Path },
fadeDuration: 6);
Assert.Equal("기본.vrv", result.BackgroundAssetPath);
Assert.Equal(PlayoutAssetRoot.OperatorBackgroundDirectory, result.BackgroundAssetRoot);
}
[Fact]
public void LegacyDefault_MissingSiblingDirectory_FailsClosed()
{
using var scenes = TemporarySceneDirectory.Create("기본.vrv");
Assert.Throws<PlayoutConfigurationException>(() =>
PlayoutSceneCompositionFactory.CreateLegacyDefault(
new PlayoutOptions { SceneDirectory = scenes.Path },
fadeDuration: 6));
}
}

View File

@@ -0,0 +1,122 @@
using System.Runtime.InteropServices;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class TrustedPlayoutAssetPathTests
{
private static readonly IReadOnlySet<string> Allowed = new HashSet<string>(
[".vrv", ".png"],
StringComparer.OrdinalIgnoreCase);
[Fact]
public void ResolveRelativeFile_AcceptsOneInternalRegularFile()
{
using var root = TemporarySceneDirectory.Create("studio.vrv");
var resolved = TrustedPlayoutAssetPath.ResolveRelativeFile(
root.Path,
"studio.vrv",
Allowed);
Assert.Equal(Path.Combine(root.Path, "studio.vrv"), resolved, ignoreCase: true);
}
[Theory]
[InlineData("..\\outside.vrv")]
[InlineData("C:\\outside.vrv")]
public void ResolveRelativeFile_RejectsRootEscape(string input)
{
using var root = TemporarySceneDirectory.Create("studio.vrv");
Assert.Throws<TrustedPlayoutAssetException>(() =>
TrustedPlayoutAssetPath.ResolveRelativeFile(root.Path, input, Allowed));
}
[Fact]
public void ResolveRelativeFile_RejectsMissingFile()
{
using var root = TemporarySceneDirectory.Create();
Assert.Throws<TrustedPlayoutAssetException>(() =>
TrustedPlayoutAssetPath.ResolveRelativeFile(root.Path, "missing.vrv", Allowed));
}
[Fact]
public void ResolveAbsoluteFile_RejectsPickerSelectionOutsideTheRoot()
{
using var root = TemporarySceneDirectory.Create();
using var outside = TemporarySceneDirectory.Create("outside.vrv");
Assert.Throws<TrustedPlayoutAssetException>(() =>
TrustedPlayoutAssetPath.ResolveAbsoluteFile(
root.Path,
Path.Combine(outside.Path, "outside.vrv"),
Allowed));
}
[Fact]
public void ResolveRelativeFile_RejectsHardLinkedFile()
{
using var root = TemporarySceneDirectory.Create();
var parent = Directory.GetParent(root.Path)!.FullName;
var outside = Path.Combine(parent, "outside.vrv");
File.WriteAllText(outside, "outside trusted-looking bytes");
var link = Path.Combine(root.Path, "linked.vrv");
Assert.True(CreateHardLinkW(link, outside, IntPtr.Zero));
Assert.Throws<TrustedPlayoutAssetException>(() =>
TrustedPlayoutAssetPath.ResolveRelativeFile(root.Path, "linked.vrv", Allowed));
}
[Fact]
public void ResolveRelativeFile_RejectsReparseDirectory()
{
using var root = TemporarySceneDirectory.Create();
var parent = Directory.GetParent(root.Path)!.FullName;
var outside = Path.Combine(parent, "outside");
Directory.CreateDirectory(outside);
File.WriteAllText(Path.Combine(outside, "studio.vrv"), "outside bytes");
var link = Path.Combine(root.Path, "linked");
try
{
Directory.CreateSymbolicLink(link, outside);
}
catch (Exception exception) when (
exception is UnauthorizedAccessException or IOException or PlatformNotSupportedException)
{
throw Xunit.Sdk.SkipException.ForSkip(
"The Windows test host does not permit creating a symbolic link.");
}
Assert.Throws<TrustedPlayoutAssetException>(() =>
TrustedPlayoutAssetPath.ResolveRelativeFile(
root.Path,
"linked\\studio.vrv",
Allowed));
}
[Fact]
public void EnsureOpenedRegularFile_BindsTheHandleToTheExpectedPath()
{
using var root = TemporarySceneDirectory.Create("expected.vrv", "other.vrv");
var expected = Path.Combine(root.Path, "expected.vrv");
var other = Path.Combine(root.Path, "other.vrv");
using var stream = new FileStream(
other,
FileMode.Open,
FileAccess.Read,
FileShare.Read);
Assert.Throws<TrustedPlayoutAssetException>(() =>
TrustedPlayoutAssetPath.EnsureOpenedRegularFile(
stream.SafeFileHandle,
expected));
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateHardLinkW(
string fileName,
string existingFileName,
IntPtr securityAttributes);
}

Some files were not shown because too many files have changed in this diff Show More