feat: add native runtime settings
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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.*
|
||||||
|
|||||||
@@ -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 추적 대상이 아니다.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
406
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs
Normal file
406
src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Settings.cs
Normal 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))
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user