feat: add native runtime settings

This commit is contained in:
2026-07-21 12:07:18 +09:00
parent b6208c0971
commit fc4007d676
25 changed files with 3543 additions and 63 deletions

2
.gitignore vendored
View File

@@ -36,6 +36,8 @@ Config/playout.development-live.local.json
**/playout.development-live.local.json **/playout.development-live.local.json
Config/playout*.local.json Config/playout*.local.json
**/playout*.local.json **/playout*.local.json
Config/runtime-folders.local.json
**/runtime-folders.local.json
*.local.ini *.local.ini
.env .env
.env.* .env.*

View File

@@ -32,9 +32,10 @@ Web\...
## Cuts ## Cuts
`Cuts`의 179개 파일과 하위 구조를 출력·게시·MSIX에 그대로 배치한다. 앱의 playout 설정 `Cuts`의 179개 파일과 하위 구조를 출력·게시·MSIX에 그대로 배치한다. 환경 변수, 사용자 설정
명시적인 `sceneDirectory`고 실행 파일 옆 `Cuts`가 있으면 그 경로를 자동으로 사용한다. 메뉴의 디자인 폴더, playout 로컬 JSON의 `sceneDirectory`모두 비어 있고 실행 파일 옆
JSON 또는 환경 변수의 명시적 경로는 계속 우선한다. `Cuts`가 있으면 그 경로를 자동으로 사용한다. 장면 루트의 우선순위는 환경 변수, 사용자 설정,
playout 로컬 JSON, 실행 파일 옆 기본 폴더 순이다.
현재 원본의 `Cuts\Video`는 비어 있다. 따라서 다음 외부 영상은 이 복사만으로 생기지 않는다. 현재 원본의 `Cuts\Video`는 비어 있다. 따라서 다음 외부 영상은 이 복사만으로 생기지 않는다.
@@ -45,6 +46,25 @@ JSON 또는 환경 변수의 명시적 경로는 계속 우선한다.
이 파일은 임의 대체물을 만들지 않고 원 제작 자산을 확보할 때까지 해당 장면의 자산 검사에서 이 파일은 임의 대체물을 만들지 않고 원 제작 자산을 확보할 때까지 해당 장면의 자산 검사에서
명시적으로 누락 처리한다. 명시적으로 누락 처리한다.
## 사용자 설정 메뉴
좌측 메뉴 하단의 `설정`에서는 네이티브 폴더 선택 창으로 디자인(`Cuts`) 폴더, 설정(`Res`)
폴더와 선택 사항인 운영 배경 폴더를 직접 지정할 수 있다. 선택한 고정 로컬 폴더는 필수 파일,
카탈로그 형식과 reparse 여부를 검증한 뒤 다음 앱 전용 파일에 저장한다.
```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json
```
폴더 변경과 기본값 복원은 자동 저장되지만 런타임 자산과 카탈로그에는 앱을 다시 시작한 뒤
적용된다. 같은 화면의 `왼쪽 메뉴 펼쳐서 시작`은 즉시 화면에 반영되고 다음 시작 상태도 위 파일에
저장된다. 절대 경로는 Web 콘텐츠에 전달하지 않는다.
이 화면은 자산 위치, 메뉴 시작 상태와 선택한 `Res` 안의 검증된 DB 프로필 위치만 관리한다.
DB 자격증명의 내용은 표시하거나 직접 편집하지 않는다. Release/default `DryRun`, KTAP 호스트·
포트·채널, Test/Live 게이트, scene allowlist와 벤더 해시는 보호 설정으로 유지되며 폴더를 선택해도
완화되거나 변경되지 않는다.
## Res와 DB 설정 ## Res와 DB 설정
UI 이미지와 UI용 INI는 allowlist로 출력·게시·MSIX에 배치한다. 앱은 실행 시 이 파일들을 UI 이미지와 UI용 INI는 allowlist로 출력·게시·MSIX에 배치한다. 앱은 실행 시 이 파일들을
@@ -61,6 +81,11 @@ CP949 레거시 형식으로 읽고, 파일의 section/row/order를 기존의
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Res\MmoneyCoder.ini
``` ```
사용자 설정 메뉴에서 별도 `Res` 폴더를 선택했고 그 안에 `MmoneyCoder.ini`가 있으면 앱은 저장
전에 형식을 네이티브에서 검증하고 다음 시작부터 그 DB 설정을 우선 사용한다. 파일 내용과
자격증명은 Web 화면에 노출하지 않으며 화면에서 직접 편집할 수도 없다. 단, one-shot Gate A
검증 실행에서는 이 사용자 선택을 무시하고 검증 계획에 고정된 `database.local.json`만 사용한다.
`MmoneyCoder - 복사본.ini`, `.bak`, `.zip`, `afiedt.buf.txt`는 런타임 소비자가 없고 과거 `MmoneyCoder - 복사본.ini`, `.bak`, `.zip`, `afiedt.buf.txt`는 런타임 소비자가 없고 과거
자격증명 또는 SQL 흔적을 담을 수 있어 어떤 출력/패키지에도 넣지 않는다. 실제 자산과 설정은 자격증명 또는 SQL 흔적을 담을 수 있어 어떤 출력/패키지에도 넣지 않는다. 실제 자산과 설정은
Git 추적 대상이 아니다. Git 추적 대상이 아니다.

View File

@@ -86,25 +86,29 @@ SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-Tlb
## 로컬 설정 ## 로컬 설정
런타임 설정 기본 경로는 다음과 같습니다. 송출 런타임 설정과 앱이 관리하는 사용자 폴더 설정의 기본 경로는 다음과 같습니다.
```text ```text
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json %LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\playout.local.json
%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Config\runtime-folders.local.json
``` ```
[playout.example.json](../Config/playout.example.json)을 구조 참고용으로 사용합니다. 예시는 `DryRun`, 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. `sceneDirectory`가 비어 있고 실행 파일 옆에 빌드가 배치한 `Cuts`가 있으면 그 폴더를 기본으로 사용합니다. 별도 씬 루트를 명시할 때는 테스트 장비의 호스트, 채널, 창 제목 패턴과 허용할 씬 이름을 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다. [playout.example.json](../Config/playout.example.json)을 `playout.local.json` 구조 참고용으로 사용합니다. 예시는 `DryRun`, 출력 채널 미지정, 빈 씬 허용 목록, 라이브 신뢰 플래그 해제 상태이므로 실제 출력에 사용할 수 없습니다. `runtime-folders.local.json`은 좌측 `설정` 메뉴에서 네이티브 폴더 선택 창으로 지정한 디자인(`Cuts`), 설정(`Res`), 운영 배경 폴더와 왼쪽 메뉴 시작 상태를 앱이 자동 저장하는 파일이므로 직접 편집하지 않습니다. 폴더 변경은 앱을 다시 시작한 뒤 자산·카탈로그에 적용되며, 메뉴 펼침 상태는 즉시 적용됩니다.
송출 장면 루트는 환경 변수, 사용자 설정 메뉴, `playout.local.json`, 실행 파일 옆 기본 `Cuts` 순으로 우선합니다. 운영 배경 루트도 환경 변수, 사용자 설정 메뉴, `playout.local.json` 순으로 우선하며 모두 비어 있으면 최종 장면 루트의 sibling `배경` 폴더를 사용합니다. 사용자 설정 메뉴가 덮어쓰는 송출 값은 이 두 자산 루트뿐입니다. Release/default `DryRun`, KTAP 호스트·포트·채널, Test/Live 게이트, allowlist와 벤더 해시는 이 화면에서 편집할 수 없고 기존 검사를 그대로 통과해야 합니다. 별도 씬 루트나 보호 설정을 명시할 때는 테스트 장비의 호스트, 채널, 창 제목 패턴과 허용할 씬 이름을 로컬 파일에만 기록하고 Git, 로그 또는 지원 첨부파일에 넣지 않습니다.
| 속성 | 의미 | | 속성 | 의미 |
|---|---| |---|---|
| `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 | | `mode` | `Disabled`, `DryRun`, `Test`, `Live` 중 하나 |
| `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint | | `host`, `port` | 테스트 또는 운영 승인을 받은 KTAP endpoint |
| `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 | | `tcpMode`, `clientPort` | `KTAPConnect`의 전송 모드와 로컬 client port 인자. Test/Live는 유실 방지를 위해 `tcpMode: 1`만 허용 |
| `sceneDirectory` | Test/Live에서 사용하는 `.t2s` 루트의 절대 경로. 명시값이 우선하며, `null`면 실행 파일 옆 `Cuts`가 존재할 때 그 경로를 사용하고 없으면 미설정 상태 | | `sceneDirectory` | Test/Live에서 사용하는 `.t2s` 루트의 절대 경로. 사용자 설정 메뉴의 디자인 폴더가 JSON 값을 덮어쓰고 환경 변수가 다시 우선한다. 모두 비어 있으면 실행 파일 옆 `Cuts`를 사용하고 없으면 미설정 상태 |
| `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 | | `outputChannel` | 확인된 전용 출력 채널. `null`은 안전한 미설정 상태 |
| `layoutIndex` | 씬 플레이어 layout 위치 | | `layoutIndex` | 씬 플레이어 layout 위치 |
| `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) | | `legacySceneFadeDuration` | 원본 `ComboDi.SelectedIndex`에 대응하는 fade. 기본 인덱스 6(화면 7), 허용 범위 0~19(화면 표시 1~20) |
| `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web은 임의 경로를 보내지 않고 F2/F3가 native picker/toggle을 호출한다. 현재 picker는 `sceneDirectory` 내부만 허용하므로 원본의 sibling `배경` root와는 아직 동등하지 않음 | | `legacySceneBackgroundKind` | trusted 공통 배경 `None`, `Texture`, `Video`; Web은 임의 경로를 보내지 않고 F2/F3가 native picker/toggle을 호출한다 |
| `legacySceneBackgroundAssetPath` | `sceneDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 | | `legacyBackgroundDirectory` | F2에서 선택할 수 있는 검증된 운영 배경의 절대 루트. 사용자 설정 메뉴의 운영 배경 폴더가 JSON 값을 덮어쓰고 환경 변수가 다시 우선한다. 미설정이면 `sceneDirectory`의 sibling `배경` 폴더를 사용 |
| `legacySceneBackgroundAssetPath` | `legacyBackgroundDirectory` 아래의 상대 asset. 경로 탈출·reparse·누락 파일은 DryRun에서도 거부 |
| `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경 설정. true는 원본 K3D raw Int32 `10`으로, false는 `0`으로 매핑하며 s5006의 raw `1`과 구분 | | `legacySceneBackgroundVideoLoopCount`, `legacySceneBackgroundVideoLoopInfinite` | 공통 video 배경 설정. true는 원본 K3D raw Int32 `10`으로, false는 `0`으로 매핑하며 s5006의 raw `1`과 구분 |
| `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 | | `testProcessWindowTitlePattern` | 전용 테스트 인스턴스만 식별하는 창 제목 패턴 |
| `testSceneAllowlist` | 실제 COM을 사용하는 `Test``Live` 모두에서 허용한 scene name(code)의 폐쇄형 목록. 경로나 확장자는 넣지 않음(기존 설정 호환을 위해 이름 유지) | | `testSceneAllowlist` | 실제 COM을 사용하는 `Test``Live` 모두에서 허용한 scene name(code)의 폐쇄형 목록. 경로나 확장자는 넣지 않음(기존 설정 호환을 위해 이름 유지) |
@@ -131,6 +135,7 @@ MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_KIND
MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_ASSET
MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_COUNT
MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_VIDEO_LOOP_INFINITE
MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY
MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN MBN_STOCK_PLAYOUT_TEST_WINDOW_TITLE_PATTERN
MBN_STOCK_PLAYOUT_QUEUE_CAPACITY MBN_STOCK_PLAYOUT_QUEUE_CAPACITY
MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS MBN_STOCK_PLAYOUT_CONNECT_TIMEOUT_MS

View File

@@ -33,9 +33,17 @@ public sealed class DatabaseRuntime
public static DatabaseRuntime CreateLegacyExecutableDefault( public static DatabaseRuntime CreateLegacyExecutableDefault(
string baseDirectory, string baseDirectory,
string? fallbackConfigurationPath = null, string? fallbackConfigurationPath = null,
string? legacyOverridePath = null) string? legacyOverridePath = null,
bool preferLegacyOverride = false)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory); ArgumentException.ThrowIfNullOrWhiteSpace(baseDirectory);
if (preferLegacyOverride &&
!string.IsNullOrWhiteSpace(legacyOverridePath) &&
File.Exists(legacyOverridePath))
{
return Create(new LegacyIniDatabaseOptionsLoader().Load(legacyOverridePath));
}
var legacyIniPath = Path.Combine( var legacyIniPath = Path.Combine(
Path.GetFullPath(baseDirectory), Path.GetFullPath(baseDirectory),
"Res", "Res",

View File

@@ -0,0 +1,979 @@
using System.Text.Json;
namespace MBN_STOCK_WEBVIEW.LegacyApplication;
public sealed record LegacyOperatorSettings(
string? SceneDirectory,
string? ResourceDirectory,
string? BackgroundDirectory,
bool NavigationExpanded)
{
public const int CurrentSchemaVersion = 1;
public static LegacyOperatorSettings Default { get; } = new(
SceneDirectory: null,
ResourceDirectory: null,
BackgroundDirectory: null,
NavigationExpanded: true);
}
public enum LegacyOperatorSettingsFolderKind
{
Scene,
Resource,
Background
}
public enum LegacyOperatorFolderValidationFailure
{
None,
InvalidPath,
DirectoryNotFound,
RootDirectory,
NonFixedDrive,
ReparsePoint,
RequiredFileMissing,
RequiredFileUnsafe,
InvalidRuntimeCatalog,
FileUnavailable
}
public sealed record LegacyOperatorValidatedFolder(
string CanonicalPath,
string AbbreviatedPath,
bool DatabaseIniDetected);
public sealed record LegacyOperatorFolderValidationResult(
LegacyOperatorValidatedFolder? Folder,
LegacyOperatorFolderValidationFailure Failure,
string? WarningMessage)
{
public bool IsValid => Failure == LegacyOperatorFolderValidationFailure.None &&
Folder is not null;
}
/// <summary>
/// Validates only native folder-picker results. The returned messages intentionally
/// contain no source path; a separately bounded abbreviation is available for display.
/// </summary>
public static class LegacyOperatorFolderValidator
{
public const int MaximumPathCharacters = 1024;
public const int DefaultDisplayCharacters = 48;
public const string UnconfiguredDisplay = "선택되지 않음";
private const string InvalidFolderMessage =
"선택한 폴더를 안전하게 사용할 수 없습니다.";
private const string MissingSceneMessage =
"선택한 디자인 폴더에 필수 장면 파일이 없습니다.";
private const string InvalidResourceMessage =
"선택한 설정 폴더의 UI 설정 파일을 검증할 수 없습니다.";
private static readonly string[] ResourceFileNames =
[
"종목.ini",
"업종_코스피.ini",
"업종_코스닥.ini",
"해외.ini",
"환율.ini",
"지수.ini",
"종목비교.ini"
];
public static bool TryValidate(
LegacyOperatorSettingsFolderKind kind,
string? path,
out LegacyOperatorValidatedFolder? folder,
out string? warningMessage)
{
var result = Validate(kind, path);
folder = result.Folder;
warningMessage = result.WarningMessage;
return result.IsValid;
}
public static LegacyOperatorFolderValidationResult Validate(
LegacyOperatorSettingsFolderKind kind,
string? path)
{
var failure = LegacyOperatorFolderValidationFailure.InvalidPath;
if (!Enum.IsDefined(kind) || !TryCanonicalizeDirectory(
path,
out var canonicalPath,
out failure))
{
return Invalid(failure, InvalidFolderMessage);
}
var databaseIniDetected = false;
switch (kind)
{
case LegacyOperatorSettingsFolderKind.Scene:
if (!TryValidateRegularFile(
Path.Combine(canonicalPath, "5001.t2s"),
required: true,
out failure))
{
return Invalid(failure, MissingSceneMessage);
}
break;
case LegacyOperatorSettingsFolderKind.Resource:
foreach (var fileName in ResourceFileNames)
{
if (!TryValidateRegularFile(
Path.Combine(canonicalPath, fileName),
required: true,
out failure))
{
return Invalid(failure, InvalidResourceMessage);
}
}
var stockMenu = LegacyRuntimeStockCutMenuLoader
.ResolveFromResourceDirectory(canonicalPath);
if (stockMenu.UsesBuiltInCatalog ||
stockMenu.Failure != LegacyRuntimeStockCutMenuFailure.None)
{
return Invalid(
LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog,
InvalidResourceMessage);
}
var uiCatalogs = LegacyRuntimeUiCatalogLoader
.ResolveFromResourceDirectory(canonicalPath);
if (uiCatalogs.Warnings.Count != 0)
{
return Invalid(
LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog,
InvalidResourceMessage);
}
var databaseIniPath = Path.Combine(canonicalPath, "MmoneyCoder.ini");
if (!TryInspectOptionalRegularFile(
databaseIniPath,
out databaseIniDetected,
out failure))
{
return Invalid(failure, InvalidResourceMessage);
}
break;
case LegacyOperatorSettingsFolderKind.Background:
break;
default:
return Invalid(
LegacyOperatorFolderValidationFailure.InvalidPath,
InvalidFolderMessage);
}
return new LegacyOperatorFolderValidationResult(
new LegacyOperatorValidatedFolder(
canonicalPath,
AbbreviateForDisplay(canonicalPath),
databaseIniDetected),
LegacyOperatorFolderValidationFailure.None,
WarningMessage: null);
}
public static string AbbreviateForDisplay(
string? path,
int maximumCharacters = DefaultDisplayCharacters)
{
maximumCharacters = Math.Clamp(maximumCharacters, 12, 120);
if (string.IsNullOrWhiteSpace(path) ||
path.Any(char.IsControl) ||
!Path.IsPathFullyQualified(path))
{
return UnconfiguredDisplay;
}
try
{
var canonicalPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
var root = Path.GetPathRoot(canonicalPath);
var leaf = Path.GetFileName(canonicalPath);
if (string.IsNullOrEmpty(root) || string.IsNullOrEmpty(leaf))
{
return UnconfiguredDisplay;
}
var display = string.Concat(
Path.TrimEndingDirectorySeparator(root),
Path.DirectorySeparatorChar,
"…",
Path.DirectorySeparatorChar,
leaf);
if (display.Length <= maximumCharacters)
{
return display;
}
var prefix = string.Concat(
Path.TrimEndingDirectorySeparator(root),
Path.DirectorySeparatorChar,
"…",
Path.DirectorySeparatorChar);
var availableLeafCharacters = Math.Max(
1,
maximumCharacters - prefix.Length - 1);
return string.Concat(
prefix,
"…",
leaf[^Math.Min(availableLeafCharacters, leaf.Length)..]);
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or
System.Security.SecurityException)
{
return UnconfiguredDisplay;
}
}
internal static bool TryCanonicalizeDirectory(
string? path,
out string canonicalPath,
out LegacyOperatorFolderValidationFailure failure)
{
canonicalPath = string.Empty;
if (string.IsNullOrWhiteSpace(path) ||
path.Length > MaximumPathCharacters ||
path.Any(char.IsControl) ||
!Path.IsPathFullyQualified(path))
{
failure = LegacyOperatorFolderValidationFailure.InvalidPath;
return false;
}
try
{
canonicalPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path));
var root = Path.GetPathRoot(canonicalPath);
if (string.IsNullOrEmpty(root) ||
root.StartsWith("\\\\", StringComparison.Ordinal) ||
!string.Equals(
Path.GetPathRoot(root),
root,
StringComparison.OrdinalIgnoreCase))
{
failure = LegacyOperatorFolderValidationFailure.InvalidPath;
canonicalPath = string.Empty;
return false;
}
if (string.Equals(
Path.TrimEndingDirectorySeparator(root),
canonicalPath,
StringComparison.OrdinalIgnoreCase))
{
failure = LegacyOperatorFolderValidationFailure.RootDirectory;
canonicalPath = string.Empty;
return false;
}
var drive = new DriveInfo(root);
if (!drive.IsReady || drive.DriveType != DriveType.Fixed)
{
failure = LegacyOperatorFolderValidationFailure.NonFixedDrive;
canonicalPath = string.Empty;
return false;
}
if (!Directory.Exists(canonicalPath))
{
failure = LegacyOperatorFolderValidationFailure.DirectoryNotFound;
canonicalPath = string.Empty;
return false;
}
for (DirectoryInfo? current = new(canonicalPath);
current is not null;
current = current.Parent)
{
if ((current.Attributes & FileAttributes.ReparsePoint) != 0)
{
failure = LegacyOperatorFolderValidationFailure.ReparsePoint;
canonicalPath = string.Empty;
return false;
}
}
failure = LegacyOperatorFolderValidationFailure.None;
return true;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
failure = LegacyOperatorFolderValidationFailure.FileUnavailable;
canonicalPath = string.Empty;
return false;
}
}
private static bool TryValidateRegularFile(
string path,
bool required,
out LegacyOperatorFolderValidationFailure failure)
{
try
{
if (!File.Exists(path))
{
failure = required
? LegacyOperatorFolderValidationFailure.RequiredFileMissing
: LegacyOperatorFolderValidationFailure.None;
return !required;
}
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory |
FileAttributes.ReparsePoint |
FileAttributes.Device)) != 0)
{
failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
return false;
}
failure = LegacyOperatorFolderValidationFailure.None;
return true;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
failure = LegacyOperatorFolderValidationFailure.FileUnavailable;
return false;
}
}
private static bool TryInspectOptionalRegularFile(
string path,
out bool detected,
out LegacyOperatorFolderValidationFailure failure)
{
detected = false;
try
{
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory |
FileAttributes.ReparsePoint |
FileAttributes.Device)) != 0)
{
failure = LegacyOperatorFolderValidationFailure.RequiredFileUnsafe;
return false;
}
detected = true;
failure = LegacyOperatorFolderValidationFailure.None;
return true;
}
catch (Exception exception) when (
exception is FileNotFoundException or DirectoryNotFoundException)
{
failure = LegacyOperatorFolderValidationFailure.None;
return true;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
failure = LegacyOperatorFolderValidationFailure.FileUnavailable;
return false;
}
}
private static LegacyOperatorFolderValidationResult Invalid(
LegacyOperatorFolderValidationFailure failure,
string warningMessage) => new(
Folder: null,
failure,
warningMessage);
}
public enum LegacyOperatorSettingsStoreFailure
{
None,
InvalidConfigurationPath,
FileUnavailable,
FileTooLarge,
InvalidJson,
UnsupportedSchema,
InvalidSettings,
SaveUnavailable
}
public sealed record LegacyOperatorSettingsLoadResult(
LegacyOperatorSettings Settings,
string? WarningMessage)
{
public LegacyOperatorSettingsStoreFailure Failure { get; init; }
public bool LoadedFromDisk { get; init; }
}
public sealed record LegacyOperatorSettingsSaveResult(
bool Succeeded,
string? WarningMessage)
{
public LegacyOperatorSettingsStoreFailure Failure { get; init; }
public LegacyOperatorSettings? SavedSettings { get; init; }
}
/// <summary>
/// Stores only native-approved runtime folders and one presentation preference.
/// Credentials, playout targets, live authorization and asset contents never enter
/// this file.
/// </summary>
public sealed class LegacyOperatorSettingsStore
{
public const int MaximumFileBytes = 16 * 1024;
public const string LoadWarningMessage =
"저장된 폴더 설정을 읽지 못해 안전한 기본값을 사용합니다.";
public const string SaveWarningMessage =
"폴더 설정을 안전하게 저장하지 못했습니다.";
private const string FileName = "runtime-folders.local.json";
private readonly string _configurationPath;
public LegacyOperatorSettingsStore(string? configurationPath = null)
{
_configurationPath = configurationPath ?? DefaultPath;
}
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"Config",
FileName);
public string ConfigurationPath => _configurationPath;
public LegacyOperatorSettingsLoadResult Load()
{
if (!TryNormalizeConfigurationPath(_configurationPath, out var path))
{
return FailedLoad(LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath);
}
try
{
var directory = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(directory))
{
return FailedLoad(
LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath);
}
if (!Directory.Exists(directory))
{
return new LegacyOperatorSettingsLoadResult(
LegacyOperatorSettings.Default,
WarningMessage: null)
{
Failure = LegacyOperatorSettingsStoreFailure.None,
LoadedFromDisk = false
};
}
if (!LegacyOperatorFolderValidator.TryCanonicalizeDirectory(
directory,
out var canonicalDirectory,
out _))
{
return FailedLoad(
LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath);
}
path = Path.Combine(canonicalDirectory, Path.GetFileName(path));
if (!File.Exists(path))
{
return new LegacyOperatorSettingsLoadResult(
LegacyOperatorSettings.Default,
WarningMessage: null)
{
Failure = LegacyOperatorSettingsStoreFailure.None,
LoadedFromDisk = false
};
}
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory |
FileAttributes.ReparsePoint |
FileAttributes.Device)) != 0)
{
return FailedLoad(LegacyOperatorSettingsStoreFailure.FileUnavailable);
}
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
FileOptions.SequentialScan);
if (stream.Length is <= 0 or > MaximumFileBytes)
{
return FailedLoad(
stream.Length > MaximumFileBytes
? LegacyOperatorSettingsStoreFailure.FileTooLarge
: LegacyOperatorSettingsStoreFailure.InvalidJson);
}
var json = new byte[(int)stream.Length];
stream.ReadExactly(json);
if (!TryDeserialize(
json,
out var settings,
out var failure) ||
!TryNormalizeSettings(settings, out var normalizedSettings))
{
return FailedLoad(
failure == LegacyOperatorSettingsStoreFailure.None
? LegacyOperatorSettingsStoreFailure.InvalidSettings
: failure);
}
return new LegacyOperatorSettingsLoadResult(
normalizedSettings,
WarningMessage: null)
{
Failure = LegacyOperatorSettingsStoreFailure.None,
LoadedFromDisk = true
};
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
return FailedLoad(LegacyOperatorSettingsStoreFailure.FileUnavailable);
}
}
public LegacyOperatorSettingsSaveResult Save(LegacyOperatorSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
if (!TryNormalizeSettings(settings, out var normalizedSettings))
{
return FailedSave(LegacyOperatorSettingsStoreFailure.InvalidSettings);
}
if (!TryNormalizeConfigurationPath(_configurationPath, out var path))
{
return FailedSave(
LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath);
}
string? temporaryPath = null;
try
{
var directory = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(directory))
{
return FailedSave(
LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath);
}
if (!TryCreateSafeConfigurationDirectory(
directory,
out var canonicalDirectory))
{
return FailedSave(
LegacyOperatorSettingsStoreFailure.InvalidConfigurationPath);
}
path = Path.Combine(canonicalDirectory, Path.GetFileName(path));
if (File.Exists(path))
{
var attributes = File.GetAttributes(path);
if ((attributes & (FileAttributes.Directory |
FileAttributes.ReparsePoint |
FileAttributes.Device)) != 0)
{
return FailedSave(
LegacyOperatorSettingsStoreFailure.SaveUnavailable);
}
}
var json = Serialize(normalizedSettings);
if (json.Length > MaximumFileBytes)
{
return FailedSave(LegacyOperatorSettingsStoreFailure.FileTooLarge);
}
temporaryPath = Path.Combine(
canonicalDirectory,
$".{FileName}.{Guid.NewGuid():N}.tmp");
using (var stream = new FileStream(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
FileOptions.WriteThrough))
{
stream.Write(json);
stream.Flush(flushToDisk: true);
}
File.Move(temporaryPath, path, overwrite: true);
temporaryPath = null;
return new LegacyOperatorSettingsSaveResult(
Succeeded: true,
WarningMessage: null)
{
Failure = LegacyOperatorSettingsStoreFailure.None,
SavedSettings = normalizedSettings
};
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
return FailedSave(LegacyOperatorSettingsStoreFailure.SaveUnavailable);
}
finally
{
if (temporaryPath is not null)
{
try
{
File.Delete(temporaryPath);
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
// A failed save remains failed. Never replace that result with a
// cleanup exception or expose the temporary path.
}
}
}
}
private static bool TryNormalizeSettings(
LegacyOperatorSettings settings,
out LegacyOperatorSettings normalizedSettings)
{
normalizedSettings = LegacyOperatorSettings.Default;
if (!TryNormalizeOptionalFolder(
LegacyOperatorSettingsFolderKind.Scene,
settings.SceneDirectory,
out var sceneDirectory) ||
!TryNormalizeOptionalFolder(
LegacyOperatorSettingsFolderKind.Resource,
settings.ResourceDirectory,
out var resourceDirectory) ||
!TryNormalizeOptionalFolder(
LegacyOperatorSettingsFolderKind.Background,
settings.BackgroundDirectory,
out var backgroundDirectory))
{
return false;
}
normalizedSettings = new LegacyOperatorSettings(
sceneDirectory,
resourceDirectory,
backgroundDirectory,
settings.NavigationExpanded);
return true;
}
private static bool TryNormalizeOptionalFolder(
LegacyOperatorSettingsFolderKind kind,
string? path,
out string? canonicalPath)
{
canonicalPath = null;
if (path is null)
{
return true;
}
if (!LegacyOperatorFolderValidator.TryValidate(
kind,
path,
out var folder,
out _))
{
return false;
}
canonicalPath = folder!.CanonicalPath;
return true;
}
private static byte[] Serialize(LegacyOperatorSettings settings)
{
using var stream = new MemoryStream(capacity: 512);
using (var writer = new Utf8JsonWriter(
stream,
new JsonWriterOptions { Indented = true }))
{
writer.WriteStartObject();
writer.WriteNumber("schemaVersion", LegacyOperatorSettings.CurrentSchemaVersion);
WriteOptionalString(writer, "sceneDirectory", settings.SceneDirectory);
WriteOptionalString(writer, "resourceDirectory", settings.ResourceDirectory);
WriteOptionalString(writer, "backgroundDirectory", settings.BackgroundDirectory);
writer.WriteBoolean("navigationExpanded", settings.NavigationExpanded);
writer.WriteEndObject();
}
return stream.ToArray();
}
private static void WriteOptionalString(
Utf8JsonWriter writer,
string name,
string? value)
{
if (value is null)
{
writer.WriteNull(name);
}
else
{
writer.WriteString(name, value);
}
}
private static bool TryDeserialize(
ReadOnlyMemory<byte> json,
out LegacyOperatorSettings settings,
out LegacyOperatorSettingsStoreFailure failure)
{
settings = LegacyOperatorSettings.Default;
failure = LegacyOperatorSettingsStoreFailure.InvalidJson;
try
{
using var document = JsonDocument.Parse(
json,
new JsonDocumentOptions
{
AllowTrailingCommas = false,
CommentHandling = JsonCommentHandling.Disallow,
MaxDepth = 4
});
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
var properties = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
foreach (var property in document.RootElement.EnumerateObject())
{
if (!IsExpectedProperty(property.Name) ||
!properties.TryAdd(property.Name, property.Value))
{
return false;
}
}
if (properties.Count != 5 ||
!TryReadInt32(properties, "schemaVersion", out var schemaVersion) ||
!TryReadOptionalString(properties, "sceneDirectory", out var sceneDirectory) ||
!TryReadOptionalString(
properties,
"resourceDirectory",
out var resourceDirectory) ||
!TryReadOptionalString(
properties,
"backgroundDirectory",
out var backgroundDirectory) ||
!TryReadBoolean(
properties,
"navigationExpanded",
out var navigationExpanded))
{
return false;
}
if (schemaVersion != LegacyOperatorSettings.CurrentSchemaVersion)
{
failure = LegacyOperatorSettingsStoreFailure.UnsupportedSchema;
return false;
}
settings = new LegacyOperatorSettings(
sceneDirectory,
resourceDirectory,
backgroundDirectory,
navigationExpanded);
failure = LegacyOperatorSettingsStoreFailure.None;
return true;
}
catch (JsonException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
catch (FormatException)
{
return false;
}
catch (OverflowException)
{
return false;
}
}
private static bool IsExpectedProperty(string name) => name is
"schemaVersion" or
"sceneDirectory" or
"resourceDirectory" or
"backgroundDirectory" or
"navigationExpanded";
private static bool TryReadInt32(
IReadOnlyDictionary<string, JsonElement> properties,
string name,
out int value)
{
value = default;
return properties.TryGetValue(name, out var element) &&
element.ValueKind == JsonValueKind.Number &&
element.TryGetInt32(out value);
}
private static bool TryReadBoolean(
IReadOnlyDictionary<string, JsonElement> properties,
string name,
out bool value)
{
value = default;
if (!properties.TryGetValue(name, out var element) ||
element.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
return false;
}
value = element.GetBoolean();
return true;
}
private static bool TryReadOptionalString(
IReadOnlyDictionary<string, JsonElement> properties,
string name,
out string? value)
{
value = null;
if (!properties.TryGetValue(name, out var element))
{
return false;
}
if (element.ValueKind == JsonValueKind.Null)
{
return true;
}
if (element.ValueKind != JsonValueKind.String)
{
return false;
}
value = element.GetString();
return value is not null;
}
private static bool TryNormalizeConfigurationPath(
string? path,
out string normalizedPath)
{
normalizedPath = string.Empty;
if (string.IsNullOrWhiteSpace(path) ||
path.Length > LegacyOperatorFolderValidator.MaximumPathCharacters ||
path.Any(char.IsControl) ||
!Path.IsPathFullyQualified(path))
{
return false;
}
try
{
normalizedPath = Path.GetFullPath(path);
var root = Path.GetPathRoot(normalizedPath);
var fileName = Path.GetFileName(normalizedPath);
return !string.IsNullOrEmpty(root) &&
!root.StartsWith("\\\\", StringComparison.Ordinal) &&
!string.IsNullOrWhiteSpace(fileName) &&
!fileName.Any(char.IsControl);
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or
System.Security.SecurityException)
{
normalizedPath = string.Empty;
return false;
}
}
private static bool TryCreateSafeConfigurationDirectory(
string directory,
out string canonicalDirectory)
{
canonicalDirectory = string.Empty;
try
{
var existingAncestor = directory;
while (!Directory.Exists(existingAncestor))
{
var parent = Path.GetDirectoryName(existingAncestor);
if (string.IsNullOrEmpty(parent) ||
string.Equals(parent, existingAncestor, StringComparison.OrdinalIgnoreCase))
{
return false;
}
existingAncestor = parent;
}
if (!LegacyOperatorFolderValidator.TryCanonicalizeDirectory(
existingAncestor,
out _,
out _))
{
return false;
}
Directory.CreateDirectory(directory);
return LegacyOperatorFolderValidator.TryCanonicalizeDirectory(
directory,
out canonicalDirectory,
out _);
}
catch (Exception exception) when (
exception is ArgumentException or IOException or UnauthorizedAccessException or
NotSupportedException or System.Security.SecurityException)
{
canonicalDirectory = string.Empty;
return false;
}
}
private static LegacyOperatorSettingsLoadResult FailedLoad(
LegacyOperatorSettingsStoreFailure failure) => new(
LegacyOperatorSettings.Default,
LoadWarningMessage)
{
Failure = failure,
LoadedFromDisk = false
};
private static LegacyOperatorSettingsSaveResult FailedSave(
LegacyOperatorSettingsStoreFailure failure) => new(
Succeeded: false,
SaveWarningMessage)
{
Failure = failure,
SavedSettings = null
};
}

View File

@@ -19,6 +19,7 @@ public enum LegacyRuntimeStockCutMenuFailure
public enum LegacyRuntimeStockCutMenuSource public enum LegacyRuntimeStockCutMenuSource
{ {
ExternalOverride, ExternalOverride,
ConfiguredResourceDirectory,
Packaged, Packaged,
BuiltIn BuiltIn
} }
@@ -65,6 +66,31 @@ public static class LegacyRuntimeStockCutMenuLoader
return CreateFallbackComposition(failure); return CreateFallbackComposition(failure);
} }
/// <summary>
/// Resolves the stock-cut menu from an already selected resource directory.
/// Unlike <see cref="ResolveFromCandidates"/>, this entry point never treats a
/// web-supplied file path as the protected local override. Callers are expected
/// to pass a directory selected and validated by native code.
/// </summary>
public static LegacyRuntimeStockCutMenuComposition ResolveFromResourceDirectory(
string? resourceDirectory)
{
if (!TryCreateResourceCandidatePath(resourceDirectory, out var path))
{
return CreateFallbackComposition(
LegacyRuntimeStockCutMenuFailure.InvalidPath);
}
if (TryLoad(path, out var rows, out var failure))
{
return CreateRuntimeComposition(
rows,
LegacyRuntimeStockCutMenuSource.ConfiguredResourceDirectory);
}
return CreateFallbackComposition(failure);
}
public static LegacyRuntimeStockCutMenuComposition ResolveFromCandidates( public static LegacyRuntimeStockCutMenuComposition ResolveFromCandidates(
string? externalOverridePath, string? externalOverridePath,
string? packagedBaseDirectory) string? packagedBaseDirectory)
@@ -306,6 +332,35 @@ public static class LegacyRuntimeStockCutMenuLoader
} }
} }
private static bool TryCreateResourceCandidatePath(
string? resourceDirectory,
out string path)
{
path = string.Empty;
if (string.IsNullOrWhiteSpace(resourceDirectory) ||
resourceDirectory.Any(char.IsControl) ||
!Path.IsPathFullyQualified(resourceDirectory))
{
return false;
}
try
{
path = Path.Combine(
Path.GetFullPath(resourceDirectory),
"종목.ini");
return true;
}
catch (Exception exception) when (
exception is ArgumentException or
IOException or
NotSupportedException or
System.Security.SecurityException)
{
return false;
}
}
private static bool TryNormalizeExternalOverridePath( private static bool TryNormalizeExternalOverridePath(
string path, string path,
out string normalizedPath) out string normalizedPath)

View File

@@ -189,6 +189,34 @@ public static class LegacyRuntimeUiCatalogLoader
return CreateFallback(warnings); return CreateFallback(warnings);
} }
return ResolveFromValidatedResourceDirectory(resourceDirectory);
}
/// <summary>
/// Resolves the six non-secret UI catalogs from an already selected resource
/// directory. The directory must be selected and validated by native code; no
/// database credentials or playout configuration are read by this loader.
/// </summary>
public static LegacyRuntimeUiCatalogComposition ResolveFromResourceDirectory(
string? resourceDirectory)
{
var warnings = new List<LegacyRuntimeUiCatalogWarning>();
if (!TryNormalizeResourceDirectory(resourceDirectory, out var normalizedDirectory))
{
warnings.Add(new LegacyRuntimeUiCatalogWarning(
"Res",
LegacyRuntimeUiCatalogFailure.InvalidBaseDirectory));
return CreateFallback(warnings);
}
return ResolveFromValidatedResourceDirectory(normalizedDirectory);
}
private static LegacyRuntimeUiCatalogComposition ResolveFromValidatedResourceDirectory(
string resourceDirectory)
{
var warnings = new List<LegacyRuntimeUiCatalogWarning>();
var fixedCatalog = LegacyFixedActionCatalog.Default; var fixedCatalog = LegacyFixedActionCatalog.Default;
var fixedOrders = new Dictionary<LegacyFixedMarket, var fixedOrders = new Dictionary<LegacyFixedMarket,
IReadOnlyList<LegacyFixedAction>>(); IReadOnlyList<LegacyFixedAction>>();
@@ -713,6 +741,31 @@ public static class LegacyRuntimeUiCatalogLoader
} }
} }
private static bool TryNormalizeResourceDirectory(
string? candidate,
out string resourceDirectory)
{
resourceDirectory = string.Empty;
if (string.IsNullOrWhiteSpace(candidate) ||
candidate.Any(char.IsControl) ||
!Path.IsPathFullyQualified(candidate))
{
return false;
}
try
{
resourceDirectory = Path.GetFullPath(candidate);
return true;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or
System.Security.SecurityException)
{
return false;
}
}
private static Encoding CreateCp949Encoding() private static Encoding CreateCp949Encoding()
{ {
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

View File

@@ -633,7 +633,8 @@ public static class LegacyBridgeProtocol
payload, payload,
static () => new LegacyImportManualListsIntent()), static () => new LegacyImportManualListsIntent()),
"dismiss-dialog" => ParseDismiss(payload), "dismiss-dialog" => ParseDismiss(payload),
_ => LegacyPlayoutUiIntentParser.Parse(type, payload) _ => LegacyOperatorSettingsUiIntentParser.Parse(type, payload) ??
LegacyPlayoutUiIntentParser.Parse(type, payload)
}; };
if (intent is null) if (intent is null)
@@ -655,7 +656,8 @@ public static class LegacyBridgeProtocol
LegacyOperatorSnapshot snapshot, LegacyOperatorSnapshot snapshot,
bool isBusy = false, bool isBusy = false,
LegacyOperatorPlayoutSnapshot? playout = null, LegacyOperatorPlayoutSnapshot? playout = null,
LegacyInteractionMetrics? interactionMetrics = null) LegacyInteractionMetrics? interactionMetrics = null,
LegacyOperatorSettingsSnapshot? operatorSettings = null)
{ {
ArgumentNullException.ThrowIfNull(snapshot); ArgumentNullException.ThrowIfNull(snapshot);
var normalizedInteractionMetrics = LegacyInteractionMetrics.Normalize( var normalizedInteractionMetrics = LegacyInteractionMetrics.Normalize(
@@ -1160,6 +1162,39 @@ public static class LegacyBridgeProtocol
hostRasterizationScale = hostRasterizationScale =
normalizedInteractionMetrics.HostRasterizationScale normalizedInteractionMetrics.HostRasterizationScale
}, },
operatorSettings = operatorSettings is null ? null : new
{
operatorSettings.Revision,
design = new
{
operatorSettings.Design.DisplayName,
operatorSettings.Design.IsCustom,
operatorSettings.Design.IsValid,
operatorSettings.Design.Status
},
resource = new
{
operatorSettings.Resource.DisplayName,
operatorSettings.Resource.IsCustom,
operatorSettings.Resource.IsValid,
operatorSettings.Resource.Status
},
background = new
{
operatorSettings.Background.DisplayName,
operatorSettings.Background.IsCustom,
operatorSettings.Background.IsValid,
operatorSettings.Background.Status
},
operatorSettings.NavigationExpanded,
operatorSettings.RestartRequired,
operatorSettings.Message,
operatorSettings.MessageKind,
operatorSettings.AppVersion,
operatorSettings.OracleState,
operatorSettings.MariaState,
operatorSettings.DatabasePollingSeconds
},
isBusy isBusy
}; };

View File

@@ -0,0 +1,117 @@
using System.Text.Json;
namespace MBN_STOCK_WEBVIEW.LegacyBridge;
public enum LegacyOperatorFolderKind
{
Design,
Resource,
Background
}
public enum LegacyOperatorSettingsMessageKind
{
Neutral,
Information,
Success,
Warning,
Error
}
public sealed record LegacyChooseOperatorFolderIntent(LegacyOperatorFolderKind Kind)
: LegacyUiIntent;
public sealed record LegacyResetOperatorFolderIntent(LegacyOperatorFolderKind Kind)
: LegacyUiIntent;
public sealed record LegacySetOperatorNavigationExpandedIntent(bool Expanded)
: LegacyUiIntent;
public sealed record LegacyOperatorFolderSnapshot(
string DisplayName,
bool IsCustom,
bool IsValid,
string Status);
public sealed record LegacyOperatorSettingsSnapshot(
long Revision,
LegacyOperatorFolderSnapshot Design,
LegacyOperatorFolderSnapshot Resource,
LegacyOperatorFolderSnapshot Background,
bool NavigationExpanded,
bool RestartRequired,
string Message,
LegacyOperatorSettingsMessageKind MessageKind,
string AppVersion,
string OracleState,
string MariaState,
int DatabasePollingSeconds);
internal static class LegacyOperatorSettingsUiIntentParser
{
public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch
{
"choose-operator-folder" => ParseFolder(
payload,
static kind => new LegacyChooseOperatorFolderIntent(kind)),
"reset-operator-folder" => ParseFolder(
payload,
static kind => new LegacyResetOperatorFolderIntent(kind)),
"set-operator-navigation-expanded" => ParseNavigationExpanded(payload),
_ => null
};
private static LegacyUiIntent? ParseFolder(
JsonElement payload,
Func<LegacyOperatorFolderKind, LegacyUiIntent> factory)
{
if (!HasExactSingleProperty(payload, "kind", out var kindElement) ||
kindElement.ValueKind != JsonValueKind.String)
{
return null;
}
var kind = kindElement.GetString() switch
{
"design" => LegacyOperatorFolderKind.Design,
"resource" => LegacyOperatorFolderKind.Resource,
"background" => LegacyOperatorFolderKind.Background,
_ => (LegacyOperatorFolderKind?)null
};
return kind.HasValue ? factory(kind.Value) : null;
}
private static LegacyUiIntent? ParseNavigationExpanded(JsonElement payload)
{
if (!HasExactSingleProperty(payload, "expanded", out var expandedElement) ||
expandedElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
return null;
}
return new LegacySetOperatorNavigationExpandedIntent(expandedElement.GetBoolean());
}
private static bool HasExactSingleProperty(
JsonElement payload,
string propertyName,
out JsonElement propertyValue)
{
propertyValue = default;
if (payload.ValueKind != JsonValueKind.Object)
{
return false;
}
using var enumerator = payload.EnumerateObject();
if (!enumerator.MoveNext() ||
!string.Equals(enumerator.Current.Name, propertyName, StringComparison.Ordinal))
{
return false;
}
propertyValue = enumerator.Current.Value;
return !enumerator.MoveNext();
}
}

View File

@@ -15,6 +15,8 @@ public sealed partial class MainWindow
private readonly NativeOperatorLogTransitionTracker _databaseHealthTransitions = private readonly NativeOperatorLogTransitionTracker _databaseHealthTransitions =
new(); new();
private int _databaseActivityPriorityWaiterCount; private int _databaseActivityPriorityWaiterCount;
private int _oracleDatabaseHealthState = -1;
private int _mariaDatabaseHealthState = -1;
private CancellationTokenSource? _databaseHealthCheckCancellation; private CancellationTokenSource? _databaseHealthCheckCancellation;
private Task? _databaseHealthMonitorTask; private Task? _databaseHealthMonitorTask;
@@ -171,6 +173,15 @@ public sealed partial class MainWindow
DataSourceKind source, DataSourceKind source,
DatabaseHealthState state) DatabaseHealthState state)
{ {
ref int currentState = ref (source == DataSourceKind.Oracle
? ref _oracleDatabaseHealthState
: ref _mariaDatabaseHealthState);
var previous = Interlocked.Exchange(ref currentState, (int)state);
if (previous != (int)state)
{
QueueOperatorState();
}
if (!_databaseHealthTransitions.TryObserveDatabaseState( if (!_databaseHealthTransitions.TryObserveDatabaseState(
source, source,
state, state,

View File

@@ -35,7 +35,9 @@ public sealed partial class MainWindow
{ {
var explicitLocalConfigurationExists = File.Exists( var explicitLocalConfigurationExists = File.Exists(
PlayoutOptionsLoader.DefaultPath); PlayoutOptionsLoader.DefaultPath);
_playoutOptions = PlayoutOptionsLoader.Load(); _playoutOptions = PlayoutOptionsLoader.Load(
operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory,
operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory);
ResetRefreshState(); ResetRefreshState();
var startupComposition = LegacyParityStartupCompositionResolver.Resolve( var startupComposition = LegacyParityStartupCompositionResolver.Resolve(
_playoutOptions, _playoutOptions,
@@ -737,7 +739,10 @@ public sealed partial class MainWindow
LegacyCreateNamedPlaylistIntent or LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent; LegacyDeleteSelectedNamedPlaylistIntent or
LegacyChooseOperatorFolderIntent or
LegacyResetOperatorFolderIntent or
LegacySetOperatorNavigationExpandedIntent;
private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is
LegacySetManualNetSellCellIntent or LegacySetManualNetSellCellIntent or

View File

@@ -0,0 +1,406 @@
using MBN_STOCK_WEBVIEW.Infrastructure;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
using MMoneyCoderSharp.Data;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace MBN_STOCK_WEBVIEW.LegacyParityApp;
public sealed partial class MainWindow
{
private long _operatorSettingsRevision = 1;
private string _operatorSettingsMessage = string.Empty;
private LegacyOperatorSettingsMessageKind _operatorSettingsMessageKind =
LegacyOperatorSettingsMessageKind.Neutral;
private LegacyOperatorFolderSnapshot _designFolderSnapshot = new(
"앱 기본 디자인 폴더",
IsCustom: false,
IsValid: false,
"확인 중");
private LegacyOperatorFolderSnapshot _resourceFolderSnapshot = new(
"앱 기본 설정 폴더",
IsCustom: false,
IsValid: false,
"확인 중");
private LegacyOperatorFolderSnapshot _backgroundFolderSnapshot = new(
"장면 폴더 기준 자동 탐색",
IsCustom: false,
IsValid: true,
"필요할 때 선택");
private void InitializeOperatorSettingsStatus(string? warningMessage)
{
if (!string.IsNullOrWhiteSpace(warningMessage))
{
_operatorSettingsMessage = warningMessage;
_operatorSettingsMessageKind = LegacyOperatorSettingsMessageKind.Warning;
}
RefreshOperatorFolderSnapshots();
}
private async Task HandleOperatorSettingsIntentAsync(
LegacyUiIntent intent,
CancellationToken cancellationToken)
{
switch (intent)
{
case LegacyChooseOperatorFolderIntent choose:
await ChooseOperatorFolderAsync(choose.Kind, cancellationToken);
break;
case LegacyResetOperatorFolderIntent reset:
ResetOperatorFolder(reset.Kind);
break;
case LegacySetOperatorNavigationExpandedIntent navigation:
SaveOperatorSettings(
_operatorSettings with
{
NavigationExpanded = navigation.Expanded
},
navigation.Expanded
? "왼쪽 메뉴를 펼쳐서 시작하도록 저장했습니다."
: "왼쪽 메뉴를 접어서 시작하도록 저장했습니다.");
break;
default:
SetOperatorSettingsMessage(
"지원하지 않는 설정 요청입니다.",
LegacyOperatorSettingsMessageKind.Error);
break;
}
}
private async Task ChooseOperatorFolderAsync(
LegacyOperatorFolderKind kind,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
ViewMode = PickerViewMode.List
};
picker.FileTypeFilter.Add("*");
var owner = _windowHandle != 0
? _windowHandle
: WindowNative.GetWindowHandle(this);
InitializeWithWindow.Initialize(picker, owner);
var selected = await picker.PickSingleFolderAsync();
if (selected is null)
{
SetOperatorSettingsMessage(
"폴더 선택을 취소했습니다.",
LegacyOperatorSettingsMessageKind.Information);
return;
}
cancellationToken.ThrowIfCancellationRequested();
var validationKind = ToSettingsFolderKind(kind);
if (!LegacyOperatorFolderValidator.TryValidate(
validationKind,
selected.Path,
out var validated,
out var warningMessage) ||
validated is null)
{
SetOperatorSettingsMessage(
warningMessage ?? "선택한 폴더를 사용할 수 없습니다.",
LegacyOperatorSettingsMessageKind.Warning);
return;
}
if (kind == LegacyOperatorFolderKind.Resource &&
validated.DatabaseIniDetected &&
!CanReadSelectedDatabaseProfile(validated.CanonicalPath))
{
SetOperatorSettingsMessage(
"선택한 설정 폴더의 DB 설정 파일을 검증할 수 없습니다.",
LegacyOperatorSettingsMessageKind.Warning);
return;
}
var next = kind switch
{
LegacyOperatorFolderKind.Design => _operatorSettings with
{
SceneDirectory = validated.CanonicalPath
},
LegacyOperatorFolderKind.Resource => _operatorSettings with
{
ResourceDirectory = validated.CanonicalPath
},
LegacyOperatorFolderKind.Background => _operatorSettings with
{
BackgroundDirectory = validated.CanonicalPath
},
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
SaveOperatorSettings(
next,
$"{OperatorFolderLabel(kind)} 폴더를 저장했습니다. 앱을 다시 시작하면 적용됩니다.");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
SetOperatorSettingsMessage(
"폴더 선택 창을 열거나 선택 결과를 저장하지 못했습니다.",
LegacyOperatorSettingsMessageKind.Error);
}
}
private void ResetOperatorFolder(LegacyOperatorFolderKind kind)
{
var next = kind switch
{
LegacyOperatorFolderKind.Design => _operatorSettings with
{
SceneDirectory = null
},
LegacyOperatorFolderKind.Resource => _operatorSettings with
{
ResourceDirectory = null
},
LegacyOperatorFolderKind.Background => _operatorSettings with
{
BackgroundDirectory = null
},
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
SaveOperatorSettings(
next,
$"{OperatorFolderLabel(kind)} 폴더를 기본값으로 되돌렸습니다. 앱을 다시 시작하면 적용됩니다.");
}
private void SaveOperatorSettings(
LegacyOperatorSettings next,
string successMessage)
{
var result = _operatorSettingsStore.Save(next);
if (!result.Succeeded || result.SavedSettings is null)
{
// The Web surface applies the navigation preference optimistically.
// Advance the snapshot revision even though persistence failed so the
// next state re-applies the still-authoritative native value.
Interlocked.Increment(ref _operatorSettingsRevision);
SetOperatorSettingsMessage(
result.WarningMessage ?? "설정을 저장하지 못했습니다.",
LegacyOperatorSettingsMessageKind.Error);
return;
}
_operatorSettings = result.SavedSettings;
Interlocked.Increment(ref _operatorSettingsRevision);
RefreshOperatorFolderSnapshots();
SetOperatorSettingsMessage(
successMessage,
LegacyOperatorSettingsMessageKind.Success);
}
private static bool CanReadSelectedDatabaseProfile(string resourceDirectory)
{
try
{
_ = new LegacyIniDatabaseOptionsLoader(_ => null).Load(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"));
return true;
}
catch (DatabaseConfigurationException)
{
return false;
}
}
private void SetOperatorSettingsMessage(
string message,
LegacyOperatorSettingsMessageKind kind)
{
_operatorSettingsMessage = message;
_operatorSettingsMessageKind = kind;
}
private void ReconcileRejectedOperatorSettingsIntent(
LegacyUiIntent intent,
string message)
{
if (!IsOperatorSettingsIntent(intent))
{
return;
}
ReconcileRejectedOperatorSettingsIntent(message);
}
private void ReconcileRejectedOperatorSettingsIntent(string message)
{
// Web applies the navigation switch optimistically. A new native revision
// makes every early rejection re-project the persisted authoritative value.
Interlocked.Increment(ref _operatorSettingsRevision);
SetOperatorSettingsMessage(
message,
LegacyOperatorSettingsMessageKind.Warning);
}
private void RefreshOperatorFolderSnapshots()
{
_designFolderSnapshot = CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind.Scene,
_operatorSettings.SceneDirectory,
"앱 기본 디자인 폴더",
"필수 장면 확인됨");
_resourceFolderSnapshot = CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind.Resource,
_operatorSettings.ResourceDirectory,
"앱 기본 설정 폴더",
"UI 설정 검증됨");
_backgroundFolderSnapshot = _operatorSettings.BackgroundDirectory is null
? new LegacyOperatorFolderSnapshot(
"장면 폴더 기준 자동 탐색",
IsCustom: false,
IsValid: true,
"필요할 때 선택")
: CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind.Background,
_operatorSettings.BackgroundDirectory,
"장면 폴더 기준 자동 탐색",
"배경 폴더 확인됨");
}
private static LegacyOperatorFolderSnapshot CreateOperatorFolderSnapshot(
LegacyOperatorSettingsFolderKind kind,
string? configuredPath,
string defaultDisplay,
string validStatus)
{
if (configuredPath is null)
{
var defaultPath = kind switch
{
LegacyOperatorSettingsFolderKind.Scene =>
Path.Combine(AppContext.BaseDirectory, "Cuts"),
LegacyOperatorSettingsFolderKind.Resource =>
Path.Combine(AppContext.BaseDirectory, "Res"),
_ => null
};
if (defaultPath is null ||
!LegacyOperatorFolderValidator.TryValidate(
kind,
defaultPath,
out _,
out _))
{
return new LegacyOperatorFolderSnapshot(
defaultDisplay,
IsCustom: false,
IsValid: false,
"기본 폴더 확인 필요");
}
return new LegacyOperatorFolderSnapshot(
defaultDisplay,
IsCustom: false,
IsValid: true,
validStatus);
}
var validation = LegacyOperatorFolderValidator.Validate(kind, configuredPath);
if (!validation.IsValid || validation.Folder is null)
{
return new LegacyOperatorFolderSnapshot(
LegacyOperatorFolderValidator.AbbreviateForDisplay(configuredPath),
IsCustom: true,
IsValid: false,
"폴더 확인 필요");
}
var status = kind == LegacyOperatorSettingsFolderKind.Resource &&
validation.Folder.DatabaseIniDetected
? "UI·DB 설정 검증됨"
: validStatus;
return new LegacyOperatorFolderSnapshot(
validation.Folder.AbbreviatedPath,
IsCustom: true,
IsValid: true,
status);
}
private LegacyOperatorSettingsSnapshot CreateOperatorSettingsSnapshot() => new(
Volatile.Read(ref _operatorSettingsRevision),
_designFolderSnapshot,
_resourceFolderSnapshot,
_backgroundFolderSnapshot,
_operatorSettings.NavigationExpanded,
RestartRequired: !OperatorFolderSettingsEqual(
_operatorSettings,
_appliedOperatorSettings),
_operatorSettingsMessage,
_operatorSettingsMessageKind,
ResolveApplicationVersion(),
ResolveDatabaseHealthLabel(
Volatile.Read(ref _oracleDatabaseHealthState)),
ResolveDatabaseHealthLabel(
Volatile.Read(ref _mariaDatabaseHealthState)),
DatabasePollingSeconds: (int)DatabaseHealthPollingInterval.TotalSeconds);
private string ResolveDatabaseHealthLabel(int rawState)
{
if (rawState < 0)
{
return _databaseRuntime is null ? "설정 안 됨" : "점검 대기";
}
return (DatabaseHealthState)rawState switch
{
DatabaseHealthState.NotConfigured => "설정 안 됨",
DatabaseHealthState.Healthy => "연결됨",
DatabaseHealthState.Unhealthy => "연결 끊김",
DatabaseHealthState.TimedOut => "응답 지연",
_ => "확인 필요"
};
}
private static bool OperatorFolderSettingsEqual(
LegacyOperatorSettings left,
LegacyOperatorSettings right) =>
string.Equals(
left.SceneDirectory,
right.SceneDirectory,
StringComparison.OrdinalIgnoreCase) &&
string.Equals(
left.ResourceDirectory,
right.ResourceDirectory,
StringComparison.OrdinalIgnoreCase) &&
string.Equals(
left.BackgroundDirectory,
right.BackgroundDirectory,
StringComparison.OrdinalIgnoreCase);
private static string ResolveApplicationVersion()
{
var version = typeof(MainWindow).Assembly.GetName().Version;
return version is null
? "확인 불가"
: $"{version.Major}.{version.Minor}.{Math.Max(0, version.Build)}";
}
private static LegacyOperatorSettingsFolderKind ToSettingsFolderKind(
LegacyOperatorFolderKind kind) => kind switch
{
LegacyOperatorFolderKind.Design => LegacyOperatorSettingsFolderKind.Scene,
LegacyOperatorFolderKind.Resource => LegacyOperatorSettingsFolderKind.Resource,
LegacyOperatorFolderKind.Background => LegacyOperatorSettingsFolderKind.Background,
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
private static string OperatorFolderLabel(LegacyOperatorFolderKind kind) => kind switch
{
LegacyOperatorFolderKind.Design => "디자인",
LegacyOperatorFolderKind.Resource => "설정",
LegacyOperatorFolderKind.Background => "운영 배경",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
}

View File

@@ -25,8 +25,11 @@ public sealed partial class MainWindow : Window
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1); private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization; private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
private readonly WindowSubclassProcedure _windowSubclassProcedure; private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorSettingsStore _operatorSettingsStore;
private readonly LegacyOperatorSettings _appliedOperatorSettings;
private readonly LegacyOperatorController _controller; private readonly LegacyOperatorController _controller;
private readonly LegacyIndustrySelectionWorkflow _industryWorkflow; private readonly LegacyIndustrySelectionWorkflow _industryWorkflow;
private LegacyOperatorSettings _operatorSettings;
private DatabaseRuntime? _databaseRuntime; private DatabaseRuntime? _databaseRuntime;
private bool _closing; private bool _closing;
private bool _interactionMetricsRefreshQueued; private bool _interactionMetricsRefreshQueued;
@@ -44,6 +47,11 @@ public sealed partial class MainWindow : Window
_playoutLaunchAuthorization = _playoutLaunchAuthorization =
PlayoutLaunchAuthorization.CaptureFromEnvironment(); PlayoutLaunchAuthorization.CaptureFromEnvironment();
_windowSubclassProcedure = OnWindowSubclassMessage; _windowSubclassProcedure = OnWindowSubclassMessage;
_operatorSettingsStore = new LegacyOperatorSettingsStore();
var operatorSettingsLoad = _operatorSettingsStore.Load();
_operatorSettings = operatorSettingsLoad.Settings;
_appliedOperatorSettings = operatorSettingsLoad.Settings;
InitializeOperatorSettingsStatus(operatorSettingsLoad.WarningMessage);
InitializeComponent(); InitializeComponent();
EnsureCloseConfirmationAttached(); EnsureCloseConfirmationAttached();
var localApplicationData = Environment.GetFolderPath( var localApplicationData = Environment.GetFolderPath(
@@ -55,11 +63,18 @@ public sealed partial class MainWindow : Window
"MBN_STOCK_WEBVIEW", "MBN_STOCK_WEBVIEW",
"Res", "Res",
"종목.ini"); "종목.ini");
var cutMenuComposition = LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates( var selectedResourceDirectory = _appliedOperatorSettings.ResourceDirectory;
runtimeCutMenuOverridePath, var cutMenuComposition = selectedResourceDirectory is null
AppContext.BaseDirectory); ? LegacyRuntimeStockCutMenuLoader.ResolveFromCandidates(
var runtimeUiCatalogs = LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory( runtimeCutMenuOverridePath,
AppContext.BaseDirectory); AppContext.BaseDirectory)
: LegacyRuntimeStockCutMenuLoader.ResolveFromResourceDirectory(
selectedResourceDirectory);
var runtimeUiCatalogs = selectedResourceDirectory is null
? LegacyRuntimeUiCatalogLoader.ResolveFromBaseDirectory(
AppContext.BaseDirectory)
: LegacyRuntimeUiCatalogLoader.ResolveFromResourceDirectory(
selectedResourceDirectory);
ILegacyStockLookup stockLookup; ILegacyStockLookup stockLookup;
IIndustrySelectionService industrySelectionService; IIndustrySelectionService industrySelectionService;
@@ -79,8 +94,18 @@ public sealed partial class MainWindow : Window
string? initializationError = null; string? initializationError = null;
try try
{ {
_databaseRuntime = DatabaseRuntime.CreateLegacyExecutableDefault( // A Gate A plan pins database.local.json by path, size and SHA-256.
AppContext.BaseDirectory); // Never inspect either packaged or operator-selected legacy INI files
// during that one-shot validation process.
var selectedLegacyDatabaseIni = selectedResourceDirectory is null
? null
: Path.Combine(selectedResourceDirectory, "MmoneyCoder.ini");
_databaseRuntime = _playoutLaunchAuthorization.IsGateA
? DatabaseRuntime.CreateDefault()
: DatabaseRuntime.CreateLegacyExecutableDefault(
AppContext.BaseDirectory,
legacyOverridePath: selectedLegacyDatabaseIni,
preferLegacyOverride: selectedLegacyDatabaseIni is not null);
DataQueryExecutor.Configure(_databaseRuntime.Executor); DataQueryExecutor.Configure(_databaseRuntime.Executor);
var databaseMutationAuthorization = var databaseMutationAuthorization =
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization); new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
@@ -418,11 +443,23 @@ public sealed partial class MainWindow : Window
if (!gateEntered) if (!gateEntered)
{ {
PostState(IsNamedPlaylistModalIntent(intent) if (IsOperatorSettingsIntent(intent))
? ReportIntentFailure( {
ReconcileRejectedOperatorSettingsIntent(
"다른 요청을 처리 중입니다. 완료된 뒤 설정을 다시 시도하세요.");
PostState(ReportIntentFailure(
intent, intent,
"다른 요청을 처리 중이어서 DB 재생목록 요청을 시작하지 않았습니다.") "다른 요청을 처리 중이어서 설정을 변경하지 않았습니다."));
: _controller.Current); }
else
{
PostState(IsNamedPlaylistModalIntent(intent)
? ReportIntentFailure(
intent,
"다른 요청을 처리 중이어서 DB 재생목록 요청을 시작하지 않았습니다.")
: _controller.Current);
}
return; return;
} }
@@ -434,6 +471,12 @@ public sealed partial class MainWindow : Window
if (_playoutLaunchAuthorization.IsGateA && if (_playoutLaunchAuthorization.IsGateA &&
IsPersistentWriteIntent(intent)) IsPersistentWriteIntent(intent))
{ {
if (IsOperatorSettingsIntent(intent))
{
ReconcileRejectedOperatorSettingsIntent(
"Gate A 검증 회차에서는 설정을 변경할 수 없습니다.");
}
PostState(ReportIntentFailure( PostState(ReportIntentFailure(
intent, intent,
"Gate A 검증 회차에서는 DB 및 로컬 영구 저장 변경이 허용되지 않습니다.")); "Gate A 검증 회차에서는 DB 및 로컬 영구 저장 변경이 허용되지 않습니다."));
@@ -445,6 +488,9 @@ public sealed partial class MainWindow : Window
not LegacyConfirmNativeDialogIntent and not LegacyConfirmNativeDialogIntent and
not LegacyCancelNativeDialogIntent) not LegacyCancelNativeDialogIntent)
{ {
ReconcileRejectedOperatorSettingsIntent(
intent,
"확인 창을 먼저 닫은 뒤 설정을 다시 시도하세요.");
// A native MessageBox is modal in the original application. Ignore // A native MessageBox is modal in the original application. Ignore
// every forged or already-posted background intent until the // every forged or already-posted background intent until the
// operator answers the currently visible Yes/No question. // operator answers the currently visible Yes/No question.
@@ -478,6 +524,23 @@ public sealed partial class MainWindow : Window
return; return;
} }
if (IsOperatorSettingsIntent(intent))
{
_intentBusy = true;
PostState(_controller.Current);
dispatchStarted = true;
await HandleOperatorSettingsIntentAsync(
intent,
_lifetimeCancellation.Token);
if (!_closing)
{
_intentBusy = false;
PostState(_controller.Current);
}
return;
}
if ((intent is LegacySearchStocksIntent searchIntent && if ((intent is LegacySearchStocksIntent searchIntent &&
!(searchIntent.Trigger == LegacySearchTrigger.Button && !(searchIntent.Trigger == LegacySearchTrigger.Button &&
searchIntent.Text.Length == 0)) || searchIntent.Text.Length == 0)) ||
@@ -566,9 +629,14 @@ public sealed partial class MainWindow : Window
catch catch
{ {
_intentBusy = false; _intentBusy = false;
ReconcileRejectedOperatorSettingsIntent(
intent,
"설정을 처리하지 못했습니다. 다시 시도하세요.");
var failureState = ReportIntentFailure( var failureState = ReportIntentFailure(
intent, intent,
"요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.", IsOperatorSettingsIntent(intent)
? "설정을 처리하지 못했습니다. 다시 시도하세요."
: "요청을 처리하지 못했습니다. 데이터베이스 연결 상태를 확인하세요.",
writeOutcomeMayBeUnknown: dispatchStarted); writeOutcomeMayBeUnknown: dispatchStarted);
ObserveOperatorMutationQuarantine(failureState); ObserveOperatorMutationQuarantine(failureState);
PostState(failureState); PostState(failureState);
@@ -600,6 +668,11 @@ public sealed partial class MainWindow : Window
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent; LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
private static bool IsOperatorSettingsIntent(LegacyUiIntent intent) => intent is
LegacyChooseOperatorFolderIntent or
LegacyResetOperatorFolderIntent or
LegacySetOperatorNavigationExpandedIntent;
private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) => private static bool IsNamedPlaylistModalIntent(LegacyUiIntent intent) =>
intent is LegacyRefreshNamedPlaylistsIntent or intent is LegacyRefreshNamedPlaylistsIntent or
LegacySelectNamedPlaylistIntent or LegacySelectNamedPlaylistIntent or
@@ -1160,7 +1233,8 @@ public sealed partial class MainWindow : Window
snapshot, snapshot,
_intentBusy, _intentBusy,
CreatePlayoutSnapshot(), CreatePlayoutSnapshot(),
interactionMetrics: _interactionMetrics)); interactionMetrics: _interactionMetrics,
operatorSettings: CreateOperatorSettingsSnapshot()));
} }
} }

View File

@@ -20,6 +20,39 @@
const movingAverage5 = document.getElementById("moving-average-5"); const movingAverage5 = document.getElementById("moving-average-5");
const movingAverage20 = document.getElementById("moving-average-20"); const movingAverage20 = document.getElementById("moving-average-20");
const status = document.getElementById("operator-status"); const status = document.getElementById("operator-status");
const operatorSettingsMessage = document.getElementById("operator-settings-message");
const operatorSettingsRestart = document.getElementById("operator-settings-restart");
const operatorNavigationExpanded = document.getElementById("operator-navigation-expanded");
const operatorSettingsAppVersion = document.getElementById("operator-settings-app-version");
const operatorSettingsPlayoutMode = document.getElementById("operator-settings-playout-mode");
const operatorSettingsPlayoutConnection = document.getElementById(
"operator-settings-playout-connection");
const operatorSettingsOracleState = document.getElementById("operator-settings-oracle-state");
const operatorSettingsMariaState = document.getElementById("operator-settings-maria-state");
const operatorSettingsDbMonitor = document.getElementById("operator-settings-db-monitor");
const operatorFolderControls = Object.freeze({
design: Object.freeze({
name: document.getElementById("operator-design-folder-name"),
status: document.getElementById("operator-design-folder-status"),
source: document.getElementById("operator-design-folder-source"),
select: document.getElementById("operator-design-folder-select"),
reset: document.getElementById("operator-design-folder-reset")
}),
resource: Object.freeze({
name: document.getElementById("operator-resource-folder-name"),
status: document.getElementById("operator-resource-folder-status"),
source: document.getElementById("operator-resource-folder-source"),
select: document.getElementById("operator-resource-folder-select"),
reset: document.getElementById("operator-resource-folder-reset")
}),
background: Object.freeze({
name: document.getElementById("operator-background-folder-name"),
status: document.getElementById("operator-background-folder-status"),
source: document.getElementById("operator-background-folder-source"),
select: document.getElementById("operator-background-folder-select"),
reset: document.getElementById("operator-background-folder-reset")
})
});
const dialog = document.getElementById("legacy-dialog"); const dialog = document.getElementById("legacy-dialog");
const dialogCaption = document.getElementById("legacy-dialog-caption"); const dialogCaption = document.getElementById("legacy-dialog-caption");
const dialogMessage = document.getElementById("legacy-dialog-message"); const dialogMessage = document.getElementById("legacy-dialog-message");
@@ -156,6 +189,11 @@
let manualFinancialRenderStateKey = ""; let manualFinancialRenderStateKey = "";
let manualListRenderStateKey = ""; let manualListRenderStateKey = "";
let operatorCatalogRenderStateKey = ""; let operatorCatalogRenderStateKey = "";
let operatorSettingsRenderRevision = null;
let operatorSettingsRevisionInitialized = false;
let pendingOperatorSettingsFocusId = null;
let pendingOperatorSettingsFallbackFocusId = null;
let operatorSettingsWasBusy = false;
let renderedTreeTabId = null; let renderedTreeTabId = null;
let treeInteractionLocked = false; let treeInteractionLocked = false;
let treeFocusOwnedBeforeRender = false; let treeFocusOwnedBeforeRender = false;
@@ -3681,6 +3719,167 @@
} }
} }
function operatorSettingsText(value, fallback) {
if (typeof value === "string" && value.trim()) return value.trim();
if (value && typeof value === "object") {
const candidate = value.displayName || value.label || value.status || value.message;
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
}
return fallback;
}
function operatorDiagnosticTone(value, label) {
if (value && typeof value === "object") {
if (value.isConnected === true || value.isHealthy === true) return "good";
if (value.isConnected === false || value.isHealthy === false) return "bad";
}
const normalized = String(label || "").toLowerCase();
if (/연결됨|정상|사용 가능|ready|connected|healthy/.test(normalized)) return "good";
if (/확인 중|재연결|대기|dry run|감지됨|unknown|waiting/.test(normalized)) {
return "warning";
}
if (/끊김|미연결|오류|실패|불명확|disconnected|fault|error|failed/.test(normalized)) {
return "bad";
}
return "neutral";
}
function setOperatorDiagnostic(element, value, fallback) {
const label = operatorSettingsText(value, fallback);
if (element.textContent !== label) element.textContent = label;
const tone = operatorDiagnosticTone(value, label);
if (tone === "neutral") element.removeAttribute("data-state");
else element.dataset.state = tone;
}
function operatorPlayoutModeLabel(playout) {
if (!playout) return "확인 중";
if (playout.mode === "live") return "LIVE";
if (playout.mode === "dryRun") return "DRY RUN";
if (playout.mode === "disabled") return "송출 비활성";
return operatorSettingsText(playout.mode, "확인 중");
}
function operatorPlayoutConnectionLabel(playout) {
if (!playout) return "확인 중";
if (playout.outcomeUnknown === true) return "결과 불명확";
if (playout.mode === "disabled") return "송출 비활성";
if (playout.mode === "dryRun") return "DRY RUN 준비됨";
if (playout.isConnected === true) return "Tornado2 연결됨";
if (playout.isProcessRunning === true) return "Tornado2 감지됨";
return "미연결";
}
function renderOperatorFolder(kind, folder, isBusy) {
const controls = operatorFolderControls[kind];
const current = folder && typeof folder === "object" ? folder : {};
const defaultNames = {
design: "앱 기본 디자인 폴더",
resource: "앱 기본 설정 폴더",
background: "앱 기본 배경 폴더"
};
const displayName = operatorSettingsText(current.displayName, defaultNames[kind]);
if (controls.name.textContent !== displayName) controls.name.textContent = displayName;
controls.name.title = displayName;
const valid = current.isValid === true;
const invalid = current.isValid === false;
const statusLabel = operatorSettingsText(
current.status,
valid ? "사용 가능" : (invalid ? "확인 필요" : "확인 중"));
if (controls.status.textContent !== statusLabel) controls.status.textContent = statusLabel;
controls.status.classList.toggle("valid", valid);
controls.status.classList.toggle("invalid", invalid);
controls.status.classList.toggle("neutral", !valid && !invalid);
controls.source.textContent = current.isCustom === true ? "사용자 지정" : "앱 기본값";
controls.select.disabled = isBusy;
controls.reset.disabled = isBusy || current.isCustom !== true;
}
function renderOperatorSettings(state) {
const settings = state && state.operatorSettings;
const isBusy = !settings || state.isBusy === true;
const wasBusy = operatorSettingsWasBusy;
let revisionChanged = false;
renderOperatorFolder("design", settings && settings.design, isBusy);
renderOperatorFolder("resource", settings && settings.resource, isBusy);
renderOperatorFolder("background", settings && settings.background, isBusy);
operatorNavigationExpanded.disabled = isBusy;
if (settings) {
const revision = settings.revision === undefined ? null : settings.revision;
revisionChanged = !operatorSettingsRevisionInitialized ||
revision !== operatorSettingsRenderRevision;
if (revisionChanged) {
const expanded = settings.navigationExpanded !== false;
operatorNavigationExpanded.checked = expanded;
if (window.LegacyWorkspaceNavigation &&
window.LegacyWorkspaceNavigation.expanded() !== expanded) {
window.LegacyWorkspaceNavigation.setExpanded(expanded);
}
operatorSettingsRenderRevision = revision;
operatorSettingsRevisionInitialized = true;
}
}
const message = operatorSettingsText(settings && settings.message, "");
operatorSettingsMessage.hidden = !message;
if (operatorSettingsMessage.textContent !== message) {
operatorSettingsMessage.textContent = message;
}
const messageKind = settings && typeof settings.messageKind === "string"
? settings.messageKind.toLowerCase()
: "";
operatorSettingsMessage.className = "settings-message" +
(["information", "success", "warning", "error"].includes(messageKind)
? " " + messageKind
: "");
operatorSettingsRestart.hidden = !(settings && settings.restartRequired === true);
setOperatorDiagnostic(
operatorSettingsAppVersion,
settings && settings.appVersion,
"확인 중");
const playout = state && state.playout;
setOperatorDiagnostic(operatorSettingsPlayoutMode, operatorPlayoutModeLabel(playout), "확인 중");
setOperatorDiagnostic(
operatorSettingsPlayoutConnection,
operatorPlayoutConnectionLabel(playout),
"확인 중");
setOperatorDiagnostic(
operatorSettingsOracleState,
settings && settings.oracleState,
"확인 중");
setOperatorDiagnostic(
operatorSettingsMariaState,
settings && settings.mariaState,
"확인 중");
const pollingSeconds = Number(settings && settings.databasePollingSeconds);
operatorSettingsDbMonitor.textContent = Number.isFinite(pollingSeconds) && pollingSeconds > 0
? String(pollingSeconds) + "초마다"
: "10초마다";
operatorSettingsWasBusy = isBusy;
if (pendingOperatorSettingsFocusId && !isBusy && (wasBusy || revisionChanged)) {
const focusId = pendingOperatorSettingsFocusId;
const fallbackFocusId = pendingOperatorSettingsFallbackFocusId;
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
window.requestAnimationFrame(function () {
const primary = document.getElementById(focusId);
const fallback = fallbackFocusId
? document.getElementById(fallbackFocusId)
: null;
const target = primary && !primary.disabled ? primary : fallback;
const active = document.activeElement;
if (target && !target.disabled && !target.closest("[inert]") &&
(active === document.body || active === null)) {
target.focus({ preventScroll: true });
}
});
}
}
function render(state) { function render(state) {
const isBusy = state.isBusy === true; const isBusy = state.isBusy === true;
const treeTabId = beginTreeRender(state); const treeTabId = beginTreeRender(state);
@@ -3711,6 +3910,7 @@
renderManualFinancial(state); renderManualFinancial(state);
renderManualLists(state); renderManualLists(state);
renderOperatorCatalog(state); renderOperatorCatalog(state);
renderOperatorSettings(state);
renderPlaylist(state); renderPlaylist(state);
renderNamedPlaylist(state); renderNamedPlaylist(state);
renderDialog(state); renderDialog(state);
@@ -3718,6 +3918,42 @@
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || ""); status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
} }
Object.keys(operatorFolderControls).forEach(function (kind) {
const controls = operatorFolderControls[kind];
controls.select.addEventListener("click", function () {
pendingOperatorSettingsFocusId = controls.select.id;
pendingOperatorSettingsFallbackFocusId = null;
if (!send("choose-operator-folder", { kind: kind })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
}
});
controls.reset.addEventListener("click", function () {
pendingOperatorSettingsFocusId = controls.reset.id;
pendingOperatorSettingsFallbackFocusId = controls.select.id;
if (!send("reset-operator-folder", { kind: kind })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
}
});
});
operatorNavigationExpanded.addEventListener("change", function () {
const expanded = operatorNavigationExpanded.checked === true;
pendingOperatorSettingsFocusId = operatorNavigationExpanded.id;
pendingOperatorSettingsFallbackFocusId = null;
if (!send("set-operator-navigation-expanded", { expanded: expanded })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
operatorNavigationExpanded.checked = !expanded;
return;
}
if (window.LegacyWorkspaceNavigation &&
window.LegacyWorkspaceNavigation.expanded() !== expanded) {
window.LegacyWorkspaceNavigation.setExpanded(expanded);
}
});
searchButton.addEventListener("click", function () { searchButton.addEventListener("click", function () {
send("search-stocks", { text: searchInput.value, trigger: "button" }); send("search-stocks", { text: searchInput.value, trigger: "button" });
}); });

View File

@@ -220,30 +220,163 @@
</div> </div>
<div> <div>
<h2>운영 설정</h2> <h2>운영 설정</h2>
<p>화면 구성과 운영 단축키를 확인합니다. 송출 대상과 데이터베이스 연결은 검증된 로컬 설정을 그대로 사용합니다.</p> <p>이 PC에서 사용할 디자인·설정·배경 폴더와 시작 화면 환경을 관리합니다. 폴더는 Windows 선택 창에서만 지정할 수 있습니다.</p>
</div> </div>
</div> </div>
<div id="operator-settings-message" class="settings-message" role="status"
aria-live="polite" aria-atomic="true" hidden></div>
<div id="operator-settings-restart" class="settings-restart-banner" role="status"
aria-live="polite" aria-atomic="true" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6"></path>
</svg>
<div>
<strong>앱을 다시 시작하면 새 폴더가 적용됩니다.</strong>
<span>현재 송출 작업은 그대로 유지되며, 다음 실행부터 변경된 위치를 사용합니다.</span>
</div>
</div>
<div class="settings-grid"> <div class="settings-grid">
<article class="settings-card settings-card-wide settings-folder-card">
<div class="settings-card-heading">
<div>
<h3>운영 폴더</h3>
<p>경로를 직접 입력하지 않고 폴더 선택 창으로 안전하게 지정합니다.</p>
</div>
<span class="settings-card-label">로컬 PC</span>
</div>
<div class="settings-folder-list">
<section class="settings-folder-row" data-operator-folder="design"
aria-labelledby="operator-design-folder-title">
<div class="settings-folder-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M3.5 6.5h6l2 2h9v10h-17Z"></path>
<path d="m14.5 12.5 1.5-3 1.5 3 3 .5-2.2 2.1.5 3-2.8-1.5-2.8 1.5.5-3-2.2-2.1Z"></path>
</svg>
</div>
<div class="settings-folder-copy">
<div class="settings-folder-title-line">
<h4 id="operator-design-folder-title">디자인 폴더</h4>
<span class="settings-folder-kind">Cuts</span>
</div>
<p>송출 장면(.t2s)과 장면별 디자인 자산의 기준 위치입니다.</p>
<div class="settings-folder-value">
<strong id="operator-design-folder-name">앱 기본 디자인 폴더</strong>
<span id="operator-design-folder-status" class="settings-status-badge neutral">확인 중</span>
<span id="operator-design-folder-source" class="settings-folder-source">앱 기본값</span>
</div>
</div>
<div class="settings-folder-actions">
<button id="operator-design-folder-select" class="settings-primary-action"
type="button">폴더 선택</button>
<button id="operator-design-folder-reset" type="button" disabled>기본값</button>
</div>
</section>
<section class="settings-folder-row" data-operator-folder="resource"
aria-labelledby="operator-resource-folder-title">
<div class="settings-folder-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M3.5 6.5h6l2 2h9v10h-17Z"></path>
<path d="M9 12h7M9 15h5"></path>
</svg>
</div>
<div class="settings-folder-copy">
<div class="settings-folder-title-line">
<h4 id="operator-resource-folder-title">설정 폴더</h4>
<span class="settings-folder-kind">Res</span>
</div>
<p>종목 목록 등 UI 설정과, MmoneyCoder.ini가 있으면 다음 시작부터 적용할 DB 프로필의 위치입니다.</p>
<div class="settings-folder-value">
<strong id="operator-resource-folder-name">앱 기본 설정 폴더</strong>
<span id="operator-resource-folder-status" class="settings-status-badge neutral">확인 중</span>
<span id="operator-resource-folder-source" class="settings-folder-source">앱 기본값</span>
</div>
</div>
<div class="settings-folder-actions">
<button id="operator-resource-folder-select" class="settings-primary-action"
type="button">폴더 선택</button>
<button id="operator-resource-folder-reset" type="button" disabled>기본값</button>
</div>
</section>
<section class="settings-folder-row" data-operator-folder="background"
aria-labelledby="operator-background-folder-title">
<div class="settings-folder-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M3.5 6.5h6l2 2h9v10h-17Z"></path>
<path d="m7.5 16 3-3 2 2 2.5-3 3.5 4"></path>
</svg>
</div>
<div class="settings-folder-copy">
<div class="settings-folder-title-line">
<h4 id="operator-background-folder-title">운영 배경 폴더</h4>
<span class="settings-folder-kind">VRV</span>
</div>
<p>F2에서 선택할 수 있는 검증된 .vrv 배경의 기준·허용 위치입니다.</p>
<div class="settings-folder-value">
<strong id="operator-background-folder-name">앱 기본 배경 폴더</strong>
<span id="operator-background-folder-status" class="settings-status-badge neutral">확인 중</span>
<span id="operator-background-folder-source" class="settings-folder-source">앱 기본값</span>
</div>
</div>
<div class="settings-folder-actions">
<button id="operator-background-folder-select" class="settings-primary-action"
type="button">폴더 선택</button>
<button id="operator-background-folder-reset" type="button" disabled>기본값</button>
</div>
</section>
</div>
</article>
<article class="settings-card"> <article class="settings-card">
<h3>화면 구성</h3> <div class="settings-card-heading">
<dl> <div>
<div><dt>왼쪽 메뉴</dt><dd>상단 메뉴 버튼으로 접기·펼치기</dd></div> <h3>시작 화면</h3>
<div><dt>작업 화면</dt><dd>선택한 종목 또는 그래픽 작업 표시</dd></div> <p>앱을 열었을 때의 작업 공간 표시 방식을 정합니다.</p>
<div><dt>송출 스케줄</dt><dd>오른쪽에 항상 고정 표시</dd></div> </div>
</dl> </div>
</article> <label class="settings-switch-row" for="operator-navigation-expanded">
<article class="settings-card"> <span>
<h3>운영 단축키</h3> <strong>왼쪽 메뉴 펼쳐서 시작</strong>
<dl class="settings-shortcuts"> <small>끄면 아이콘만 보이는 간결한 메뉴로 시작합니다.</small>
<div><dt><kbd>F2</kbd></dt><dd>배경 파일</dd></div> </span>
<div><dt><kbd>F3</kbd></dt><dd>배경 없음</dd></div> <input id="operator-navigation-expanded" type="checkbox" checked>
<div><dt><kbd>F8</kbd></dt><dd>TAKE IN</dd></div> <span class="settings-switch" aria-hidden="true"></span>
<div><dt><kbd>Esc</kbd></dt><dd>TAKE OUT</dd></div> </label>
</article>
<article class="settings-card settings-diagnostics-card">
<div class="settings-card-heading">
<div>
<h3>현재 상태</h3>
<p>문제 확인에 필요한 읽기 전용 정보입니다.</p>
</div>
<span class="settings-card-label settings-card-label-readonly">읽기 전용</span>
</div>
<dl class="settings-diagnostics">
<div><dt>앱 버전</dt><dd id="operator-settings-app-version">확인 중</dd></div>
<div><dt>송출 모드</dt><dd id="operator-settings-playout-mode">확인 중</dd></div>
<div><dt>송출 연결</dt><dd id="operator-settings-playout-connection">확인 중</dd></div>
<div><dt>Oracle</dt><dd id="operator-settings-oracle-state">확인 중</dd></div>
<div><dt>MariaDB</dt><dd id="operator-settings-maria-state">확인 중</dd></div>
<div><dt>DB 상태 감시</dt><dd id="operator-settings-db-monitor">10초마다</dd></div>
</dl> </dl>
</article> </article>
<article class="settings-card settings-card-wide"> <article class="settings-card settings-card-wide">
<h3>보호되는 로컬 설정</h3> <div class="settings-protected-copy">
<p>DB 자격 증명, Tornado2 송출 대상, 자산 경로와 Live/DryRun 권한은 이 화면에서 변경하지 않습니다. 현재 PC에 검증된 운영 프로필이 계속 적용됩니다.</p> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="5" y="10" width="14" height="10" rx="2"></rect>
<path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v2"></path>
</svg>
<div>
<h3>보호되는 운영 설정</h3>
<p>DB 자격 증명의 내용은 이 화면에 표시하거나 직접 편집할 수 없습니다. 다만 선택한 설정(Res) 폴더에 검증된 MmoneyCoder.ini가 있으면 일반 실행의 다음 시작부터 그 DB 프로필을 사용합니다. Gate A 검증 실행에서는 선택한 프로필 대신 검증 계획에 고정된 DB 구성을 사용합니다. Tornado2 송출 대상, Live/DryRun 권한과 장면 허용 목록은 변경할 수 없으며 기존 송출 안전 규칙이 계속 적용됩니다.</p>
</div>
</div>
</article> </article>
</div> </div>
</section> </section>

View File

@@ -1402,7 +1402,7 @@ summary[data-tree-root]:focus-visible {
outline-offset: -3px; outline-offset: -3px;
} }
/* Sidebar catalog navigation and its read-only settings landing. */ /* Sidebar catalog navigation and its native-backed operator settings surface. */
.workspace-nav-section-label { .workspace-nav-section-label {
color: var(--ui-text-subtle); color: var(--ui-text-subtle);
text-transform: uppercase; text-transform: uppercase;
@@ -1484,12 +1484,13 @@ summary[data-tree-root]:focus-visible {
.settings-panel { .settings-panel {
overflow: auto; overflow: auto;
padding: 24px; padding: 24px 24px 40px;
background: var(--ui-canvas); background: var(--ui-canvas);
scrollbar-gutter: stable;
} }
.settings-intro { .settings-intro {
display: flex; display: flex;
max-width: 900px; max-width: 1040px;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 20px; padding: 20px;
@@ -1520,9 +1521,46 @@ summary[data-tree-root]:focus-visible {
} }
.settings-intro h2 { margin: 0 0 5px; font-size: 20px; } .settings-intro h2 { margin: 0 0 5px; font-size: 20px; }
.settings-intro p { margin: 0; color: var(--ui-text-muted); line-height: 1.55; } .settings-intro p { margin: 0; color: var(--ui-text-muted); line-height: 1.55; }
.settings-message,
.settings-restart-banner {
display: flex;
max-width: 1040px;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 12px 14px;
border: 1px solid #c9d8ee;
border-radius: var(--ui-radius);
background: #f8fbff;
color: #344054;
line-height: 1.45;
}
.settings-message.success { border-color: #b8dec7; background: #f0faf4; color: #176b3a; }
.settings-message.information { border-color: #c9d8ee; background: #f4f8ff; color: #245aa5; }
.settings-message.warning { border-color: #ead29a; background: #fff9e9; color: #805600; }
.settings-message.error { border-color: #efb8b8; background: #fff5f5; color: #a32323; }
.settings-restart-banner {
align-items: flex-start;
border-color: #f0d58c;
background: linear-gradient(135deg, #fffaf0, #fff5d9);
color: #694b00;
}
.settings-restart-banner svg {
width: 22px;
height: 22px;
flex: 0 0 22px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.settings-restart-banner strong,
.settings-restart-banner span { display: block; }
.settings-restart-banner span { margin-top: 2px; color: #856912; font-size: 12px; }
.settings-grid { .settings-grid {
display: grid; display: grid;
max-width: 900px; max-width: 1040px;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px; gap: 14px;
margin-top: 16px; margin-top: 16px;
@@ -1535,31 +1573,218 @@ summary[data-tree-root]:focus-visible {
box-shadow: var(--ui-shadow-card); box-shadow: var(--ui-shadow-card);
} }
.settings-card-wide { grid-column: 1 / -1; } .settings-card-wide { grid-column: 1 / -1; }
.settings-card h3 { margin: 0 0 13px; font-size: 15px; } .settings-card h3 { margin: 0; font-size: 15px; }
.settings-card p { margin: 0; color: var(--ui-text-muted); line-height: 1.6; } .settings-card p { margin: 0; color: var(--ui-text-muted); line-height: 1.6; }
.settings-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 15px;
}
.settings-card-heading p { margin-top: 4px; }
.settings-card-label,
.settings-folder-kind {
display: inline-flex;
min-height: 22px;
align-items: center;
padding: 2px 8px;
border: 1px solid #c9d8ee;
border-radius: 999px;
background: var(--ui-accent-soft);
color: #245aa5;
font-size: 10px;
font-weight: 800;
letter-spacing: .04em;
white-space: nowrap;
}
.settings-card-label-readonly {
border-color: #d8dde5;
background: var(--ui-surface-subtle);
color: #667085;
}
.settings-folder-card { padding-bottom: 4px; }
.settings-folder-list {
margin: 0 -18px;
border-top: 1px solid var(--ui-border);
}
.settings-folder-row {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 16px 18px;
border-bottom: 1px solid #edf0f4;
}
.settings-folder-row:last-child { border-bottom: 0; }
.settings-folder-icon {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border-radius: 11px;
background: var(--ui-accent-soft);
color: var(--ui-accent);
}
.settings-folder-icon svg {
width: 23px;
height: 23px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.settings-folder-copy { min-width: 0; }
.settings-folder-title-line {
display: flex;
align-items: center;
gap: 8px;
}
.settings-folder-title-line h4 { margin: 0; font-size: 14px; }
.settings-folder-kind { min-height: 19px; padding: 1px 6px; font-size: 9px; }
.settings-folder-copy > p { margin-top: 3px; font-size: 12px; }
.settings-folder-value {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
margin-top: 7px;
}
.settings-folder-value strong {
max-width: min(100%, 500px);
overflow: hidden;
color: #344054;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-status-badge {
display: inline-flex;
min-height: 20px;
flex: 0 0 auto;
align-items: center;
gap: 5px;
padding: 2px 7px;
border-radius: 999px;
background: #f2f4f7;
color: #667085;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.settings-status-badge::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
content: "";
}
.settings-status-badge.valid { background: #eaf8ef; color: #187744; }
.settings-status-badge.invalid { background: #fff0f0; color: #bd2929; }
.settings-folder-source { color: #5f6b7a; font-size: 10px; white-space: nowrap; }
.settings-folder-actions { display: flex; gap: 7px; }
.settings-folder-actions button {
min-width: 68px;
min-height: 34px;
padding: 6px 10px;
border-radius: 8px;
}
.settings-folder-actions .settings-primary-action:not(:disabled) {
border-color: #a9c6ee;
background: #f4f8ff;
color: #245aa5;
font-weight: 700;
}
.settings-switch-row {
position: relative;
display: flex;
min-height: 56px;
align-items: center;
justify-content: space-between;
gap: 16px;
cursor: pointer;
}
.settings-switch-row > span:first-child { min-width: 0; }
.settings-switch-row strong,
.settings-switch-row small { display: block; }
.settings-switch-row strong { color: #344054; font-size: 13px; }
.settings-switch-row small { margin-top: 4px; color: var(--ui-text-muted); line-height: 1.45; }
.settings-switch-row input {
position: absolute;
right: 13px;
width: 1px;
height: 1px;
opacity: 0;
}
.settings-switch {
position: relative;
width: 42px;
height: 24px;
flex: 0 0 42px;
border: 1px solid #bdc6d2;
border-radius: 999px;
background: #d5dbe3;
transition: background .15s ease, border-color .15s ease;
}
.settings-switch::after {
position: absolute;
left: 3px;
top: 3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px rgba(16, 24, 40, .25);
content: "";
transition: transform .15s ease;
}
.settings-switch-row input:checked + .settings-switch {
border-color: var(--ui-accent);
background: var(--ui-accent);
}
.settings-switch-row input:checked + .settings-switch::after { transform: translateX(18px); }
.settings-switch-row input:focus-visible + .settings-switch {
outline: 2px solid var(--ui-accent);
outline-offset: 3px;
}
.settings-switch-row input:disabled + .settings-switch { opacity: .55; }
.settings-card dl { margin: 0; } .settings-card dl { margin: 0; }
.settings-card dl > div { .settings-diagnostics > div {
display: grid; display: grid;
grid-template-columns: minmax(90px, .42fr) minmax(0, 1fr); grid-template-columns: minmax(90px, .42fr) minmax(0, 1fr);
gap: 12px; gap: 12px;
padding: 9px 0; padding: 9px 0;
border-top: 1px solid #edf0f4; border-top: 1px solid #edf0f4;
} }
.settings-card dl > div:first-child { padding-top: 0; border-top: 0; } .settings-diagnostics > div:first-child { padding-top: 0; border-top: 0; }
.settings-card dl > div:last-child { padding-bottom: 0; } .settings-diagnostics > div:last-child { padding-bottom: 0; }
.settings-card dt { color: #344054; font-weight: 700; } .settings-card dt { color: #344054; font-weight: 700; }
.settings-card dd { margin: 0; color: var(--ui-text-muted); } .settings-card dd { margin: 0; color: var(--ui-text-muted); }
.settings-shortcuts kbd { .settings-diagnostics dd[data-state="good"] { color: #187744; font-weight: 700; }
display: inline-block; .settings-diagnostics dd[data-state="warning"] { color: #946200; font-weight: 700; }
min-width: 40px; .settings-diagnostics dd[data-state="bad"] { color: #bd2929; font-weight: 700; }
padding: 3px 7px; .settings-protected-copy {
border: 1px solid #b8c4d4; display: flex;
border-bottom-width: 2px; align-items: flex-start;
border-radius: 6px; gap: 14px;
background: var(--ui-surface-subtle); }
color: #344054; .settings-protected-copy > svg {
font: 700 11px "Segoe UI", sans-serif; width: 24px;
text-align: center; height: 24px;
flex: 0 0 24px;
fill: none;
stroke: #667085;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.settings-protected-copy h3 { margin-bottom: 5px; }
@media (max-width: 1720px) {
.settings-folder-row { grid-template-columns: 38px minmax(0, 1fr); }
.settings-folder-actions { grid-column: 2; }
.settings-folder-value { flex-wrap: wrap; }
} }
@media (forced-colors: active) { @media (forced-colors: active) {
@@ -1590,6 +1815,17 @@ summary[data-tree-root]:focus-visible {
.playlist-data-row[data-active="true"] { border-top: 3px solid Highlight; } .playlist-data-row[data-active="true"] { border-top: 3px solid Highlight; }
.playlist-data-row[aria-selected="true"] { border-right: 3px solid Highlight; } .playlist-data-row[aria-selected="true"] { border-right: 3px solid Highlight; }
.playlist-data-row.on-air { border-left: 4px double Highlight; } .playlist-data-row.on-air { border-left: 4px double Highlight; }
.settings-status-badge,
.settings-folder-icon,
.settings-message,
.settings-restart-banner,
.settings-switch {
border: 1px solid CanvasText;
background: Canvas;
box-shadow: none;
color: CanvasText;
}
.settings-switch-row input:checked + .settings-switch { background: Highlight; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -19,14 +19,43 @@ public static class PlayoutOptionsLoader
"Config", "Config",
"playout.local.json"); "playout.local.json");
public static PlayoutOptions Load(string? path = null) public static PlayoutOptions Load(
string? path = null,
string? operatorSceneDirectory = null,
string? operatorBackgroundDirectory = null)
{ {
var options = LoadJson(path ?? DefaultPath); var options = LoadJson(path ?? DefaultPath);
ApplyOperatorAssetOverrides(
options,
operatorSceneDirectory,
operatorBackgroundDirectory);
ApplyEnvironment(options); ApplyEnvironment(options);
ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory); ApplyExecutableAssetDefaults(options, AppContext.BaseDirectory);
return options; return options;
} }
/// <summary>
/// Applies only the two non-command asset roots selected by the native settings
/// screen. Process environment values are applied afterwards and therefore retain
/// precedence for controlled diagnostic and development-live launches.
/// </summary>
internal static void ApplyOperatorAssetOverrides(
PlayoutOptions options,
string? sceneDirectory,
string? backgroundDirectory)
{
ArgumentNullException.ThrowIfNull(options);
if (!string.IsNullOrWhiteSpace(sceneDirectory))
{
options.SceneDirectory = sceneDirectory.Trim();
}
if (!string.IsNullOrWhiteSpace(backgroundDirectory))
{
options.LegacyBackgroundDirectory = backgroundDirectory.Trim();
}
}
/// <summary> /// <summary>
/// Loads only the specified JSON file and deliberately ignores all environment /// Loads only the specified JSON file and deliberately ignores all environment
/// overrides. Intended for explicit, reproducible diagnostic commands. /// overrides. Intended for explicit, reproducible diagnostic commands.

View File

@@ -147,6 +147,37 @@ public sealed class LegacyIniDatabaseOptionsLoaderTests
Assert.Equal("maria.dev.invalid", runtime.Options.MariaDb.Host); Assert.Equal("maria.dev.invalid", runtime.Options.MariaDb.Host);
} }
[Fact]
public void Runtime_UserSelectedResCanExplicitlyPrecedeExecutableCredentials()
{
using var directory = TemporaryDirectory.Create();
var executableRes = Path.Combine(directory.Path, "Res");
Directory.CreateDirectory(executableRes);
File.WriteAllBytes(
Path.Combine(executableRes, "MmoneyCoder.ini"),
TemporaryLegacyIni.ValidUtf8Bytes());
using var selected = TemporaryLegacyIni.Create(Encoding.UTF8.GetBytes(
"""
[Oracle]
ConnectionName=selected.oracle.invalid:1521/DEVORA
ID=oracle-user
Pass=oracle-password
[Maria]
ConnectionName=selected.maria.invalid:3306/dev_stock
ID=maria-user
Pass=maria-password
"""));
var runtime = DatabaseRuntime.CreateLegacyExecutableDefault(
directory.Path,
Path.Combine(directory.Path, "missing.json"),
selected.Path,
preferLegacyOverride: true);
Assert.Equal("selected.oracle.invalid", runtime.Options.Oracle.Host);
Assert.Equal("selected.maria.invalid", runtime.Options.MariaDb.Host);
}
[Fact] [Fact]
public void Load_OptionalRealLegacyIni_IsConfiguredWithoutPrintingValues() public void Load_OptionalRealLegacyIni_IsConfiguredWithoutPrintingValues()
{ {

View File

@@ -0,0 +1,493 @@
using System.Text;
using System.Text.Json;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyOperatorSettingsTests
{
private static readonly Encoding Cp949 = CreateCp949Encoding();
[Fact]
public void DefaultPath_IsTheBoundedLocalApplicationConfigurationPath()
{
var expected = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MBN_STOCK_WEBVIEW",
"Config",
"runtime-folders.local.json");
Assert.Equal(expected, LegacyOperatorSettingsStore.DefaultPath);
Assert.True(Path.IsPathFullyQualified(LegacyOperatorSettingsStore.DefaultPath));
}
[Fact]
public void SceneValidation_RequiresOnlyTheBaselineSceneAndReturnsAnAbbreviation()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
sceneDirectory + Path.DirectorySeparatorChar);
Assert.True(result.IsValid);
Assert.Equal(Path.GetFullPath(sceneDirectory), result.Folder!.CanonicalPath);
Assert.Equal("baseline", File.ReadAllText(
Path.Combine(result.Folder.CanonicalPath, "5001.t2s")));
Assert.Contains("Cuts", result.Folder.AbbreviatedPath);
Assert.InRange(
result.Folder.AbbreviatedPath.Length,
1,
LegacyOperatorFolderValidator.DefaultDisplayCharacters);
Assert.False(result.Folder.DatabaseIniDetected);
Assert.Null(result.WarningMessage);
}
[Fact]
public void SceneValidation_MissingBaselineFailsWithoutLeakingThePath()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("sensitive-design-folder");
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
sceneDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.RequiredFileMissing,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, sceneDirectory);
}
[Fact]
public void BackgroundValidation_AcceptsAnEmptyFixedDriveDirectory()
{
using var directory = new TemporaryDirectory();
var backgroundDirectory = directory.CreateDirectory("Background");
Assert.True(LegacyOperatorFolderValidator.TryValidate(
LegacyOperatorSettingsFolderKind.Background,
backgroundDirectory,
out var folder,
out var warning));
Assert.Equal(Path.GetFullPath(backgroundDirectory), folder!.CanonicalPath);
Assert.Null(warning);
}
[Fact]
public void ResourceValidation_ParsesEveryClosedUiIniAndOnlyFlagsDatabaseIni()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
File.WriteAllText(
Path.Combine(resourceDirectory, "MmoneyCoder.ini"),
"secret-content-that-must-not-be-returned");
var stockMenu = LegacyRuntimeStockCutMenuLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.False(stockMenu.UsesBuiltInCatalog);
var uiCatalogs = LegacyRuntimeUiCatalogLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.Empty(uiCatalogs.Warnings);
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.True(result.IsValid, $"{result.Failure}: {result.WarningMessage}");
Assert.True(result.Folder!.DatabaseIniDetected);
Assert.DoesNotContain("secret", result.Folder.AbbreviatedPath,
StringComparison.OrdinalIgnoreCase);
Assert.Null(result.WarningMessage);
}
[Fact]
public void ResourceValidation_InvalidClosedIniFailsWithoutPartialAcceptance()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("private-runtime-resources");
WriteValidResourceFiles(resourceDirectory);
WriteCp949(
Path.Combine(resourceDirectory, "종목.ini"),
["[종목]", "_않은_컷"]);
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.InvalidRuntimeCatalog,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, resourceDirectory);
}
[Fact]
public void ResourceValidation_RejectsAnUnsafeDatabaseIniWithoutReadingIt()
{
using var directory = new TemporaryDirectory();
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
Directory.CreateDirectory(Path.Combine(resourceDirectory, "MmoneyCoder.ini"));
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
resourceDirectory);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.RequiredFileUnsafe,
result.Failure);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, resourceDirectory);
}
[Fact]
public void Validation_RejectsRelativeMissingRootAndControlContainingPaths()
{
using var directory = new TemporaryDirectory();
var missing = Path.Combine(directory.Path, "missing-private-folder");
string?[] candidates =
[
null,
string.Empty,
"relative-folder",
missing,
Path.GetPathRoot(directory.Path),
directory.Path + "\nprivate"
];
foreach (var candidate in candidates)
{
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Background,
candidate);
Assert.False(result.IsValid);
Assert.Null(result.Folder);
AssertPathFree(result.WarningMessage, missing);
}
}
[Fact]
public void Validation_RejectsAReparsePointInTheDirectoryAncestry_WhenSupported()
{
using var directory = new TemporaryDirectory();
var target = directory.CreateDirectory("target");
var link = Path.Combine(directory.Path, "link");
try
{
Directory.CreateSymbolicLink(link, target);
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or
PlatformNotSupportedException or NotSupportedException)
{
return;
}
try
{
var result = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Background,
link);
Assert.False(result.IsValid);
Assert.Equal(
LegacyOperatorFolderValidationFailure.ReparsePoint,
result.Failure);
}
finally
{
Directory.Delete(link);
}
}
[Fact]
public void Load_MissingFileUsesDefaultsWithoutWarning()
{
using var directory = new TemporaryDirectory();
var store = new LegacyOperatorSettingsStore(
Path.Combine(directory.Path, "Config", "runtime-folders.local.json"));
var result = store.Load();
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
Assert.False(result.LoadedFromDisk);
Assert.Equal(LegacyOperatorSettingsStoreFailure.None, result.Failure);
Assert.Null(result.WarningMessage);
}
[Fact]
public void SaveAndLoad_RoundTripsOnlyTheVersionedClosedSchemaAtomically()
{
using var directory = new TemporaryDirectory();
var sceneDirectory = directory.CreateDirectory("Cuts");
File.WriteAllText(Path.Combine(sceneDirectory, "5001.t2s"), "baseline");
var resourceDirectory = directory.CreateDirectory("Res");
WriteValidResourceFiles(resourceDirectory);
var backgroundDirectory = directory.CreateDirectory("Background");
var configurationPath = Path.Combine(
directory.Path,
"Config",
"runtime-folders.local.json");
var store = new LegacyOperatorSettingsStore(configurationPath);
var settings = new LegacyOperatorSettings(
sceneDirectory,
resourceDirectory,
backgroundDirectory,
NavigationExpanded: false);
var firstSave = store.Save(settings);
var secondSave = store.Save(settings with { NavigationExpanded = true });
var loaded = store.Load();
Assert.True(firstSave.Succeeded);
Assert.True(secondSave.Succeeded);
Assert.NotNull(secondSave.SavedSettings);
Assert.Null(secondSave.WarningMessage);
Assert.True(loaded.LoadedFromDisk);
Assert.True(loaded.Settings.NavigationExpanded);
Assert.Equal(Path.GetFullPath(sceneDirectory), loaded.Settings.SceneDirectory);
Assert.Equal(Path.GetFullPath(resourceDirectory), loaded.Settings.ResourceDirectory);
Assert.Equal(
Path.GetFullPath(backgroundDirectory),
loaded.Settings.BackgroundDirectory);
Assert.Empty(Directory.EnumerateFiles(
Path.GetDirectoryName(configurationPath)!,
"*.tmp"));
using var document = JsonDocument.Parse(File.ReadAllBytes(configurationPath));
var names = document.RootElement.EnumerateObject()
.Select(property => property.Name)
.ToArray();
Assert.Equal(
[
"schemaVersion",
"sceneDirectory",
"resourceDirectory",
"backgroundDirectory",
"navigationExpanded"
],
names);
Assert.DoesNotContain(
document.RootElement.EnumerateObject(),
property => property.Name.Contains("password", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("target", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("live", StringComparison.OrdinalIgnoreCase));
}
[Theory]
[InlineData("{}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":2,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.UnsupportedSchema)]
[InlineData("{\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true,\"unexpected\":1}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":1,\"schemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"SchemaVersion\":1,\"sceneDirectory\":null,\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidJson)]
[InlineData("{\"schemaVersion\":1,\"sceneDirectory\":\"relative\",\"resourceDirectory\":null,\"backgroundDirectory\":null,\"navigationExpanded\":true}", LegacyOperatorSettingsStoreFailure.InvalidSettings)]
public void Load_StrictMalformedOrUnsafeJsonFailsClosed(
string json,
LegacyOperatorSettingsStoreFailure expectedFailure)
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
File.WriteAllText(configurationPath, json, new UTF8Encoding(false));
var store = new LegacyOperatorSettingsStore(configurationPath);
var result = store.Load();
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
Assert.False(result.LoadedFromDisk);
Assert.Equal(expectedFailure, result.Failure);
AssertPathFree(result.WarningMessage, directory.Path);
}
[Fact]
public void Load_OversizedJsonFailsBeforeParsing()
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
using (var stream = new FileStream(
configurationPath,
FileMode.CreateNew,
FileAccess.Write))
{
stream.SetLength(LegacyOperatorSettingsStore.MaximumFileBytes + 1L);
}
var result = new LegacyOperatorSettingsStore(configurationPath).Load();
Assert.Equal(LegacyOperatorSettingsStoreFailure.FileTooLarge, result.Failure);
Assert.Equal(LegacyOperatorSettings.Default, result.Settings);
AssertPathFree(result.WarningMessage, configurationPath);
}
[Fact]
public void Save_RejectsUnvalidatedPathsWithoutCreatingAConfigurationFile()
{
using var directory = new TemporaryDirectory();
var configurationPath = Path.Combine(directory.Path, "settings.json");
var store = new LegacyOperatorSettingsStore(configurationPath);
var settings = LegacyOperatorSettings.Default with
{
SceneDirectory = Path.Combine(directory.Path, "not-present-private-cuts")
};
var result = store.Save(settings);
Assert.False(result.Succeeded);
Assert.Equal(LegacyOperatorSettingsStoreFailure.InvalidSettings, result.Failure);
Assert.Null(result.SavedSettings);
Assert.False(File.Exists(configurationPath));
AssertPathFree(result.WarningMessage, settings.SceneDirectory!);
}
[Fact]
public void AbbreviateForDisplay_IsBoundedAndNeverReturnsInvalidInput()
{
using var directory = new TemporaryDirectory();
var longLeaf = new string('a', 80);
var path = directory.CreateDirectory(longLeaf);
var abbreviated = LegacyOperatorFolderValidator.AbbreviateForDisplay(path, 20);
Assert.InRange(abbreviated.Length, 1, 20);
Assert.Contains('…', abbreviated);
Assert.Equal(
LegacyOperatorFolderValidator.UnconfiguredDisplay,
LegacyOperatorFolderValidator.AbbreviateForDisplay("relative\\path"));
}
private static void WriteValidResourceFiles(string resourceDirectory)
{
var stockLabel = LegacyStockCutCatalog.Rows
.First(row => !row.IsSeparator)
.RawLabel;
WriteCp949(
Path.Combine(resourceDirectory, "종목.ini"),
["[종목]", stockLabel]);
WriteFixedFile(
resourceDirectory,
"해외.ini",
LegacyFixedMarket.Overseas);
WriteFixedFile(
resourceDirectory,
"환율.ini",
LegacyFixedMarket.Exchange);
WriteFixedFile(
resourceDirectory,
"지수.ini",
LegacyFixedMarket.Index);
WriteIndustryFile(
resourceDirectory,
"업종_코스피.ini",
IndustryMarket.Kospi);
WriteIndustryFile(
resourceDirectory,
"업종_코스닥.ini",
IndustryMarket.Kosdaq);
WriteComparisonFile(resourceDirectory);
}
private static void WriteFixedFile(
string resourceDirectory,
string fileName,
LegacyFixedMarket market)
{
var lines = new List<string>();
foreach (var section in LegacyFixedActionCatalog.Default
.GetActions(market)
.GroupBy(action => action.Section, StringComparer.Ordinal))
{
lines.Add($"[{section.Key}]");
lines.AddRange(section.Select(action => action.Label));
}
WriteCp949(Path.Combine(resourceDirectory, fileName), lines);
}
private static void WriteIndustryFile(
string resourceDirectory,
string fileName,
IndustryMarket market)
{
var actions = LegacyIndustryActionCatalog.GetActions(market);
var section = market == IndustryMarket.Kospi ? "코스피" : "코스닥";
WriteCp949(
Path.Combine(resourceDirectory, fileName),
[$"[{section}]", .. actions.Select(action => action.Label)]);
}
private static void WriteComparisonFile(string resourceDirectory)
{
var actions = LegacyComparisonWorkflowCatalog.Actions;
var directActions = actions
.Where(action => action.Kind != LegacyComparisonActionKind.Return)
.ToArray();
var returnActions = actions
.Where(action => action.Kind == LegacyComparisonActionKind.Return)
.ToArray();
var lines = new List<string> { "[지수]" };
lines.AddRange(LegacyComparisonWorkflowCatalog.FixedTargets
.Select(target => target.DisplayName));
lines.AddRange(directActions.Select(action => $"[{action.Label}]"));
lines.Add($"[{returnActions[0].Section}]");
lines.AddRange(returnActions.Select(action => action.Label));
WriteCp949(Path.Combine(resourceDirectory, "종목비교.ini"), lines);
}
private static void WriteCp949(string path, IEnumerable<string> lines) =>
File.WriteAllText(path, string.Join("\r\n", lines) + "\r\n", Cp949);
private static void AssertPathFree(string? warningMessage, string sensitivePath)
{
var warning = Assert.IsType<string>(warningMessage);
Assert.InRange(warning.Length, 1, 100);
Assert.DoesNotContain(sensitivePath, warning, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain('\\', warning);
Assert.DoesNotContain('/', warning);
}
private static Encoding CreateCp949Encoding()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
949,
EncoderFallback.ExceptionFallback,
DecoderFallback.ExceptionFallback);
}
private sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"MBN_STOCK_WEBVIEW_SETTINGS_{Guid.NewGuid():N}");
Directory.CreateDirectory(Path);
}
public string Path { get; }
public string CreateDirectory(string name)
{
var path = System.IO.Path.Combine(Path, name);
Directory.CreateDirectory(path);
return path;
}
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
}

View File

@@ -255,6 +255,33 @@ public sealed class LegacyRuntimeStockCutMenuLoaderTests
} }
} }
[Fact]
public void ResolveFromResourceDirectory_ReadsTheDirectNativeSelectedDirectory()
{
var directory = Path.Combine(
Path.GetTempPath(),
$"mbn-stock-cut-direct-resource-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
WriteMenu(Path.Combine(directory, "종목.ini"), "1열판기본_현재가");
var composition = LegacyRuntimeStockCutMenuLoader
.ResolveFromResourceDirectory(directory);
Assert.Equal(
LegacyRuntimeStockCutMenuSource.ConfiguredResourceDirectory,
composition.Source);
Assert.Equal(
"1열판기본_현재가",
Assert.Single(composition.Rows).RawLabel);
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact] [Fact]
public void ResolveFromCandidates_ExternalOverrideTakesPrecedenceOverPackagedMenu() public void ResolveFromCandidates_ExternalOverrideTakesPrecedenceOverPackagedMenu()
{ {

View File

@@ -141,6 +141,25 @@ public sealed class LegacyRuntimeUiCatalogLoaderTests
comparisonWorkflow.Current.Actions.Select(action => action.ActionId)); comparisonWorkflow.Current.Actions.Select(action => action.ActionId));
} }
[Fact]
public void ResolveFromResourceDirectory_ReadsTheDirectNativeSelectedDirectory()
{
using var directory = new TemporaryDirectory();
WriteAllValidFiles(directory.Path);
var resourceDirectory = System.IO.Path.Combine(directory.Path, "Res");
var composition = LegacyRuntimeUiCatalogLoader
.ResolveFromResourceDirectory(resourceDirectory);
Assert.Empty(composition.Warnings);
AssertRuntimeFixedFile(
directory.Path,
"해외.ini",
LegacyFixedMarket.Overseas,
LegacyFixedActionCatalog.ExpectedOverseasCount,
composition.FixedCatalog);
}
[Fact] [Fact]
public void UnknownFixedRow_FallsBackWithoutRegisteringAnAction() public void UnknownFixedRow_FallsBackWithoutRegisteringAnAction()
{ {

View File

@@ -0,0 +1,143 @@
using System.Text.Json;
using MBN_STOCK_WEBVIEW.LegacyApplication;
using MBN_STOCK_WEBVIEW.LegacyBridge;
namespace MBN_STOCK_WEBVIEW.LegacyBridge.Tests;
public sealed class LegacyOperatorSettingsBridgeTests
{
[Theory]
[InlineData("design", LegacyOperatorFolderKind.Design)]
[InlineData("resource", LegacyOperatorFolderKind.Resource)]
[InlineData("background", LegacyOperatorFolderKind.Background)]
public void ParseChooseFolder_AcceptsOnlyClosedFolderKinds(
string wireKind,
LegacyOperatorFolderKind expectedKind)
{
var json =
$"{{\"type\":\"choose-operator-folder\",\"payload\":{{\"kind\":\"{wireKind}\"}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error));
Assert.Null(error);
var intent = Assert.IsType<LegacyChooseOperatorFolderIntent>(parsed);
Assert.Equal(expectedKind, intent.Kind);
}
[Theory]
[InlineData("design", LegacyOperatorFolderKind.Design)]
[InlineData("resource", LegacyOperatorFolderKind.Resource)]
[InlineData("background", LegacyOperatorFolderKind.Background)]
public void ParseResetFolder_AcceptsOnlyClosedFolderKinds(
string wireKind,
LegacyOperatorFolderKind expectedKind)
{
var json =
$"{{\"type\":\"reset-operator-folder\",\"payload\":{{\"kind\":\"{wireKind}\"}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error));
Assert.Null(error);
var intent = Assert.IsType<LegacyResetOperatorFolderIntent>(parsed);
Assert.Equal(expectedKind, intent.Kind);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ParseNavigationExpanded_RequiresAnExactBoolean(bool expanded)
{
var json =
$"{{\"type\":\"set-operator-navigation-expanded\",\"payload\":{{\"expanded\":{expanded.ToString().ToLowerInvariant()}}}}}";
Assert.True(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out var error));
Assert.Null(error);
var intent = Assert.IsType<LegacySetOperatorNavigationExpandedIntent>(parsed);
Assert.Equal(expanded, intent.Expanded);
}
[Theory]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{}}")]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"settings\"}}")]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"Design\"}}")]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":1}}")]
[InlineData("{\"type\":\"choose-operator-folder\",\"payload\":{\"kind\":\"design\",\"path\":\"C:\\\\Cuts\"}}")]
[InlineData("{\"type\":\"reset-operator-folder\",\"payload\":{\"kind\":\"resource\",\"extra\":false}}")]
[InlineData("{\"type\":\"reset-operator-folder\",\"payload\":{\"kind\":\"resource\",\"kind\":\"design\"}}")]
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{}}")]
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{\"expanded\":\"true\"}}")]
[InlineData("{\"type\":\"set-operator-navigation-expanded\",\"payload\":{\"expanded\":true,\"path\":\"C:\\\\Config\"}}")]
public void ParseSettingsIntents_RejectsMissingUnknownAndExtraAuthority(string json)
{
Assert.False(LegacyBridgeProtocol.TryParseIntent(json, out var parsed, out _));
Assert.Null(parsed);
}
[Fact]
public void SerializeState_ProjectsOperatorSettingsWithClosedWireNames()
{
var settings = new LegacyOperatorSettingsSnapshot(
17,
new LegacyOperatorFolderSnapshot("D:\\Design", true, true, "사용 가능"),
new LegacyOperatorFolderSnapshot("D:\\Resource", true, false, "Res 폴더 없음"),
new LegacyOperatorFolderSnapshot("앱 기본 배경", false, true, "기본값"),
NavigationExpanded: false,
RestartRequired: true,
Message: "재시작 후 적용됩니다.",
MessageKind: LegacyOperatorSettingsMessageKind.Warning,
AppVersion: "0.1.0.0",
OracleState: "연결됨",
MariaState: "연결 끊김",
DatabasePollingSeconds: 10);
var json = LegacyBridgeProtocol.SerializeState(
CreateSnapshot(),
operatorSettings: settings);
using var document = JsonDocument.Parse(json);
var projected = document.RootElement
.GetProperty("payload")
.GetProperty("operatorSettings");
Assert.Equal(17, projected.GetProperty("revision").GetInt64());
Assert.Equal("D:\\Design", projected.GetProperty("design")
.GetProperty("displayName").GetString());
Assert.True(projected.GetProperty("design").GetProperty("isCustom").GetBoolean());
Assert.True(projected.GetProperty("design").GetProperty("isValid").GetBoolean());
Assert.Equal("사용 가능", projected.GetProperty("design")
.GetProperty("status").GetString());
Assert.False(projected.GetProperty("resource").GetProperty("isValid").GetBoolean());
Assert.False(projected.GetProperty("background").GetProperty("isCustom").GetBoolean());
Assert.False(projected.GetProperty("navigationExpanded").GetBoolean());
Assert.True(projected.GetProperty("restartRequired").GetBoolean());
Assert.Equal("재시작 후 적용됩니다.", projected.GetProperty("message").GetString());
Assert.Equal("warning", projected.GetProperty("messageKind").GetString());
Assert.Equal("0.1.0.0", projected.GetProperty("appVersion").GetString());
Assert.Equal("연결됨", projected.GetProperty("oracleState").GetString());
Assert.Equal("연결 끊김", projected.GetProperty("mariaState").GetString());
Assert.Equal(10, projected.GetProperty("databasePollingSeconds").GetInt32());
Assert.False(projected.TryGetProperty("Design", out _));
Assert.False(projected.GetProperty("design").TryGetProperty("DisplayName", out _));
}
[Fact]
public void SerializeState_UsesNullOperatorSettingsWhenNotProvided()
{
using var document = JsonDocument.Parse(
LegacyBridgeProtocol.SerializeState(CreateSnapshot()));
Assert.Equal(
JsonValueKind.Null,
document.RootElement.GetProperty("payload")
.GetProperty("operatorSettings").ValueKind);
}
private static LegacyOperatorSnapshot CreateSnapshot() => new(
1,
string.Empty,
[],
null,
[],
[],
null,
string.Empty,
LegacyOperatorStatusKind.Neutral);
}

View File

@@ -0,0 +1,164 @@
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyOperatorSettingsNativeContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string AppRoot = Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp");
private static readonly string Window = File.ReadAllText(
Path.Combine(AppRoot, "MainWindow.xaml.cs"));
private static readonly string Settings = File.ReadAllText(
Path.Combine(AppRoot, "MainWindow.Settings.cs"));
private static readonly string Playout = File.ReadAllText(
Path.Combine(AppRoot, "MainWindow.Playout.cs"));
[Fact]
public void FolderPathsComeOnlyFromTheWindowOwnedNativePicker()
{
Assert.Contains("var picker = new FolderPicker", Settings, StringComparison.Ordinal);
Assert.Contains("picker.FileTypeFilter.Add(\"*\")", Settings,
StringComparison.Ordinal);
Assert.Contains("InitializeWithWindow.Initialize(picker, owner);", Settings,
StringComparison.Ordinal);
Assert.Contains("await picker.PickSingleFolderAsync()", Settings,
StringComparison.Ordinal);
Assert.Contains("selected.Path", Settings, StringComparison.Ordinal);
Assert.Contains("LegacyOperatorFolderValidator.TryValidate(", Settings,
StringComparison.Ordinal);
Assert.DoesNotContain("intent.Path", Settings, StringComparison.Ordinal);
Assert.DoesNotContain("choose.Path", Settings, StringComparison.Ordinal);
}
[Fact]
public void SelectedFoldersAreLoadedAtStartupAndAppliedOnlyAtCompositionBoundaries()
{
Assert.Contains("_operatorSettingsStore.Load()", Window, StringComparison.Ordinal);
Assert.Contains("_appliedOperatorSettings = operatorSettingsLoad.Settings", Window,
StringComparison.Ordinal);
Assert.Contains("ResolveFromResourceDirectory(", Window, StringComparison.Ordinal);
Assert.Contains("preferLegacyOverride: selectedLegacyDatabaseIni is not null", Window,
StringComparison.Ordinal);
Assert.Contains(
"operatorSceneDirectory: _appliedOperatorSettings.SceneDirectory",
Playout,
StringComparison.Ordinal);
Assert.Contains(
"operatorBackgroundDirectory: _appliedOperatorSettings.BackgroundDirectory",
Playout,
StringComparison.Ordinal);
Assert.DoesNotContain("InitializePlayoutRuntime();", Settings, StringComparison.Ordinal);
Assert.DoesNotContain("CreateLegacyExecutableDefault(", Settings,
StringComparison.Ordinal);
}
[Fact]
public void GateAUsesOnlyThePinnedJsonDatabaseProfile()
{
var databaseComposition = Slice(
Window,
"string? initializationError = null;",
"DataQueryExecutor.Configure(_databaseRuntime.Executor);");
var gateA = RequiredIndex(
databaseComposition,
"_playoutLaunchAuthorization.IsGateA");
var pinnedJson = RequiredIndex(
databaseComposition,
"DatabaseRuntime.CreateDefault()",
gateA);
var legacyIni = RequiredIndex(
databaseComposition,
"DatabaseRuntime.CreateLegacyExecutableDefault(",
pinnedJson);
Assert.True(gateA < pinnedJson);
Assert.True(pinnedJson < legacyIni);
}
[Fact]
public void SettingsWritesAreGateABlockedAndDoNotWaitForDatabaseHealthActivity()
{
var process = Slice(
Window,
"private async Task ProcessIntentAsync(LegacyUiIntent intent)",
"private static bool IsOrderedPlayoutIntent");
var gateABlock = RequiredIndex(process, "IsPersistentWriteIntent(intent)");
var settingsDispatch = RequiredIndex(
process,
"await HandleOperatorSettingsIntentAsync(",
gateABlock);
var databaseWait = RequiredIndex(
process,
"await AcquirePriorityDatabaseActivityAsync(_lifetimeCancellation.Token)");
Assert.True(gateABlock < settingsDispatch);
Assert.True(settingsDispatch < databaseWait);
Assert.Contains("HandleOperatorSettingsIntentAsync(", process,
StringComparison.Ordinal);
Assert.Contains("LegacyChooseOperatorFolderIntent", Playout,
StringComparison.Ordinal);
Assert.Contains("LegacyResetOperatorFolderIntent", Playout,
StringComparison.Ordinal);
Assert.Contains("LegacySetOperatorNavigationExpandedIntent", Playout,
StringComparison.Ordinal);
Assert.Contains("ReconcileRejectedOperatorSettingsIntent(", process,
StringComparison.Ordinal);
}
[Fact]
public void SettingsStateIsProjectedWithoutPublishingFullNativePaths()
{
Assert.Contains("operatorSettings: CreateOperatorSettingsSnapshot()", Window,
StringComparison.Ordinal);
Assert.Contains("AbbreviatedPath", Settings, StringComparison.Ordinal);
Assert.DoesNotContain("CanonicalPath,\n _operatorSettingsMessage", Settings,
StringComparison.Ordinal);
Assert.Contains("DatabasePollingSeconds: (int)DatabaseHealthPollingInterval.TotalSeconds",
Settings,
StringComparison.Ordinal);
}
[Fact]
public void FailedSettingsSaveRevisionsTheSnapshotToUndoOptimisticNavigation()
{
Assert.Contains(
"if (!result.Succeeded || result.SavedSettings is null)",
Settings,
StringComparison.Ordinal);
Assert.Contains(
"Interlocked.Increment(ref _operatorSettingsRevision);",
Settings,
StringComparison.Ordinal);
}
private static string Slice(string value, string start, string end)
{
var startIndex = RequiredIndex(value, start);
var endIndex = RequiredIndex(value, end, startIndex + start.Length);
return value[startIndex..endIndex];
}
private static int RequiredIndex(string value, string needle, int startIndex = 0)
{
var index = value.IndexOf(needle, startIndex, StringComparison.Ordinal);
Assert.True(index >= 0, $"Required marker was not found: {needle}");
return index;
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Repository root was not found.");
}
}

View File

@@ -0,0 +1,147 @@
using System.Text.RegularExpressions;
namespace MBN_STOCK_WEBVIEW.LegacyWeb.Tests;
public sealed class LegacyOperatorSettingsWebContractTests
{
private static readonly string RepositoryRoot = FindRepositoryRoot();
private static readonly string WebRoot = Path.Combine(
RepositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"Web");
private static readonly string Markup = File.ReadAllText(Path.Combine(WebRoot, "index.html"));
private static readonly string Styles = File.ReadAllText(Path.Combine(WebRoot, "styles.css"));
private static readonly string App = File.ReadAllText(Path.Combine(WebRoot, "app.js"));
[Fact]
public void SettingsScreenOffersThreeNativeFolderPickersAndRestartFeedback()
{
var expectedKinds = new[] { "design", "resource", "background" };
var actualKinds = Regex.Matches(Markup, "data-operator-folder=\"([^\"]+)\"")
.Select(match => match.Groups[1].Value)
.ToArray();
Assert.Equal(expectedKinds, actualKinds);
foreach (var kind in expectedKinds)
{
Assert.Contains($"id=\"operator-{kind}-folder-name\"", Markup,
StringComparison.Ordinal);
Assert.Contains($"id=\"operator-{kind}-folder-status\"", Markup,
StringComparison.Ordinal);
Assert.Contains($"id=\"operator-{kind}-folder-select\"", Markup,
StringComparison.Ordinal);
Assert.Contains($"id=\"operator-{kind}-folder-reset\"", Markup,
StringComparison.Ordinal);
}
Assert.Matches(
"id=\"operator-settings-message\"[^>]*role=\"status\"[^>]*aria-live=\"polite\"",
Markup);
Assert.Matches(
"id=\"operator-settings-restart\"[^>]*role=\"status\"[^>]*aria-live=\"polite\"",
Markup);
Assert.Contains("앱을 다시 시작하면 새 폴더가 적용됩니다.", Markup,
StringComparison.Ordinal);
Assert.Contains("settings-status-badge.valid", Styles, StringComparison.Ordinal);
Assert.Contains("settings-status-badge.invalid", Styles, StringComparison.Ordinal);
}
[Fact]
public void SettingsDispatchesOnlyClosedFolderKindsAndNeverWebSuppliedPaths()
{
var dispatch = Slice(
App,
"Object.keys(operatorFolderControls).forEach(function (kind)",
"searchButton.addEventListener");
Assert.Contains("send(\"choose-operator-folder\", { kind: kind })", dispatch,
StringComparison.Ordinal);
Assert.Contains("send(\"reset-operator-folder\", { kind: kind })", dispatch,
StringComparison.Ordinal);
Assert.Contains(
"send(\"set-operator-navigation-expanded\", { expanded: expanded })",
dispatch,
StringComparison.Ordinal);
Assert.DoesNotContain("path", dispatch, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("displayName", dispatch, StringComparison.Ordinal);
}
[Fact]
public void RepeatedSettingsStateKeepsStaticControlsAndAppliesNavigationByRevision()
{
var folderRender = Slice(App, "function renderOperatorFolder", "function render(state)");
var settingsRender = Slice(
App,
"function renderOperatorSettings",
"function render(state)");
Assert.DoesNotContain("innerHTML", folderRender, StringComparison.Ordinal);
Assert.DoesNotContain("replaceChildren", folderRender, StringComparison.Ordinal);
Assert.Contains("operatorSettingsRevisionInitialized", settingsRender,
StringComparison.Ordinal);
Assert.Contains("revision !== operatorSettingsRenderRevision", settingsRender,
StringComparison.Ordinal);
Assert.Matches(
new Regex(
@"if \(revisionChanged\) \{[\s\S]*?LegacyWorkspaceNavigation\.setExpanded\(expanded\);[\s\S]*?operatorSettingsRenderRevision = revision;",
RegexOptions.CultureInvariant),
settingsRender);
Assert.Single(
Regex.Matches(settingsRender, "LegacyWorkspaceNavigation.setExpanded").Cast<Match>());
Assert.Contains("pendingOperatorSettingsFocusId", settingsRender,
StringComparison.Ordinal);
Assert.Contains("target.focus({ preventScroll: true });", settingsRender,
StringComparison.Ordinal);
}
[Fact]
public void SettingsShowsSafeDiagnosticsAndNamesProtectedValues()
{
foreach (var id in new[]
{
"operator-settings-app-version",
"operator-settings-playout-mode",
"operator-settings-playout-connection",
"operator-settings-oracle-state",
"operator-settings-maria-state",
"operator-settings-db-monitor"
})
{
Assert.Contains($"id=\"{id}\"", Markup, StringComparison.Ordinal);
}
Assert.Contains("id=\"operator-navigation-expanded\"", Markup,
StringComparison.Ordinal);
Assert.Contains("databasePollingSeconds", App, StringComparison.Ordinal);
Assert.Contains("DB 자격 증명", Markup, StringComparison.Ordinal);
Assert.Contains("Tornado2 송출 대상", Markup, StringComparison.Ordinal);
Assert.Contains("Live/DryRun 권한", Markup, StringComparison.Ordinal);
Assert.Contains("장면 허용 목록", Markup, StringComparison.Ordinal);
}
private static string Slice(string source, string startMarker, string endMarker)
{
var start = source.IndexOf(startMarker, StringComparison.Ordinal);
Assert.True(start >= 0, $"Missing start marker: {startMarker}");
var end = source.IndexOf(endMarker, start, StringComparison.Ordinal);
Assert.True(end > start, $"Missing end marker: {endMarker}");
return source[start..end];
}
private static string FindRepositoryRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "MBN_STOCK_WEBVIEW.sln")))
{
return directory.FullName;
}
directory = directory.Parent;
}
throw new DirectoryNotFoundException("Repository root could not be located.");
}
}

View File

@@ -95,6 +95,53 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Equal("C:\\explicit-cuts", options.SceneDirectory); Assert.Equal("C:\\explicit-cuts", options.SceneDirectory);
} }
[Fact]
public void ApplyOperatorAssetOverrides_ChangesOnlyNativeAssetRoots()
{
var options = new PlayoutOptions
{
Mode = PlayoutMode.Live,
Host = "protected-host",
SceneDirectory = "C:\\json-cuts",
LegacyBackgroundDirectory = "C:\\json-backgrounds",
TrustedLiveOutputEnabled = true,
TestSceneAllowlist = ["5001"]
};
PlayoutOptionsLoader.ApplyOperatorAssetOverrides(
options,
" C:\\operator-cuts ",
" C:\\operator-backgrounds ");
Assert.Equal("C:\\operator-cuts", options.SceneDirectory);
Assert.Equal("C:\\operator-backgrounds", options.LegacyBackgroundDirectory);
Assert.Equal(PlayoutMode.Live, options.Mode);
Assert.Equal("protected-host", options.Host);
Assert.True(options.TrustedLiveOutputEnabled);
Assert.Equal(["5001"], options.TestSceneAllowlist);
}
[Fact]
public void Load_EnvironmentAssetRootsOverrideNativeOperatorSelection()
{
using var environment = ClearedEnvironment()
.Set("MBN_STOCK_PLAYOUT_SCENE_DIRECTORY", "C:\\environment-cuts")
.Set(
"MBN_STOCK_PLAYOUT_LEGACY_BACKGROUND_DIRECTORY",
"C:\\environment-backgrounds");
using var file = TemporaryJsonFile.Missing();
var options = PlayoutOptionsLoader.Load(
file.Path,
"C:\\operator-cuts",
"C:\\operator-backgrounds");
Assert.Equal("C:\\environment-cuts", options.SceneDirectory);
Assert.Equal("C:\\environment-backgrounds", options.LegacyBackgroundDirectory);
Assert.Equal(PlayoutMode.DryRun, options.Mode);
Assert.False(options.TrustedLiveOutputEnabled);
}
[Fact] [Fact]
public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates() public void Load_EnvironmentOverridesJson_ExceptFileOnlySafetyGates()
{ {