fix: restore legacy playout parity and live startup

This commit is contained in:
2026-07-29 14:07:36 +09:00
parent 80b50c80b3
commit b35c000796
31 changed files with 3356 additions and 382 deletions

View File

@@ -39,15 +39,22 @@ SHA-256을 이 PC의 최초 기준으로 자동 고정한다. 이후 DLL이나
담당자 두 사람이 다음 내용을 먼저 확인한다.
1. 이 PC와 로컬 Tornado2/PGM은 개발용이며 실제 방송 라우팅에 영향이 없다.
2. `Tornado2*` 프로세스는 정확히 하나이고 주 창 제목은 승인값과 정확히 일치한다.
3. Tornado2의 `Tools > Option > Control > Network Server > TCP Port`가 지정 개발 환경의
회사 기본값 `30001`인지 확인한다. 다른 값이면 자동 첫 실행 설정을 사용하지 않는다.
4. 해당 TCP LISTEN 소유 PID와 Tornado2 PID가 같고, 실행 도중 프로세스 세대가 바뀌지 않는다.
1. 이 PC와 `127.0.0.1:30001` K3D/PGM 대상은 개발용이며 실제 방송 라우팅에 영향이 없다.
2. 일반 Development Live 앱은 Tornado2 프로세스가 없어도 K3D가 해당 endpoint에서
`OnHello`를 반환하면 연결할 수 있다. 앱은 `OnHello` 확인 전까지 명령을 차단해야 한다.
3. Tornado2 프로세스가 이미 있으면 정확히 하나여야 하고, 프로세스·창 검사가 모두 성공해
주 창이 `PGM`/`PROGRAM`으로 식별되어야 한다. 다른 창이거나 둘 이상이거나 실행 도중
프로세스가 새로 나타나거나 세대가 바뀌면 앱은 기존 연결의 명령을 차단해야 한다.
4. Network Server TCP Port는 지정 개발 환경의 회사 기본값 `30001`이어야 한다. 다른 값이면
자동 첫 실행 설정을 사용하지 않는다.
5. `View > Network Monitoring Window`와 PGM 화면을 동시에 관찰할 담당자가 있다.
한 항목이라도 불명확하면 이후 명령을 보내지 않는다.
`Tornado2*` 프로세스 정확히 하나와 TCP LISTEN 소유 PID 일치는 아래 Gate A 또는
connect-only 진단을 실행할 때 적용하는 추가 조건이다. 일반 Development Live의
0-process + K3D `OnHello` 경로에 이 PID 조건을 적용하지 않는다.
## 2. 기존 자산 보유 PC: clone 후 1회 초기화
상사 PC처럼 승인된 자산과 벤더 설치가 이미 있는 경우에는 이 흐름을 우선 사용한다. 별도
@@ -358,7 +365,17 @@ powershell -NoProfile -ExecutionPolicy Bypass `
| `testSceneAllowlist` | 아래 active alias 45개만 허용 |
| `trustedLiveOutputEnabled` | `true` |
| `reconnectEnabled` / `maximumReconnectAttempts` | 인수 회차에서는 `false` / `0` |
| `maximumAutomaticRefreshesPerTakeIn` | 최초 인수 회차에서는 `0` |
| `maximumAutomaticRefreshesPerTakeIn` | `null`. 원본처럼 TAKE IN 뒤 3초 간격 자동 갱신을 계속한다. |
이전 초기화 도구가 만든 `maximumAutomaticRefreshesPerTakeIn: 0` 프로필은 자동 갱신을
완전히 끄므로 현재 일반 Development Live 계약과 일치하지 않는다. 기존 파일을 앱이
묵시적으로 수정하지는 않는다. 첫 설정 창에서 다시 설정한 뒤
`기존 Live 설정 교체 후 다시 시도`를 한 번 실행하거나,
`Initialize-ExistingDevelopmentPc.ps1`을 사용하는 검토된 수동 초기화 명령에
`-ReplaceLiveConfig`를 명시한다. `Initialize-DevelopmentLiveConfig.ps1`을 직접
사용하는 경우의 동등한 명시 옵션은 `-Force`이다. 이 절차는 기존 Live 승인 파일을 먼저 무효화하고
`playout.local.json` 검증이 끝난 뒤 승인을 다시 발급한다. Gate A 검증 프로필의
명시적 `0` 상한은 별도 안전 계약이므로 변경하지 않는다.
```text
5001, 5006, 5011, 5016, 50160, 5023, 5024, 5025, 5026, 5029,
@@ -409,6 +426,8 @@ strict 파일 규칙은
Network Monitoring을 먼저 연다. 독립 승인 해시는 이 진단을 실행할 현재 PowerShell
프로세스에만 설정한 뒤 실제 port로 connect-only 진단을 한 번 실행한다.
이 진단은 0-process 경로가 아니다. `Tornado2*` 프로세스가 정확히 하나이고 해당
`127.0.0.1:30001` TCP LISTEN 소유 PID가 그 Tornado2 PID와 같은 경우에만 실행한다.
```powershell
$env:MBN_STOCK_K3D_NATIVE_SHA256 = '<independently-approved-native-sha256>'

View File

@@ -151,6 +151,15 @@ Studio의 시작 드롭다운에는 다음 프로필 하나만 표시된다.
기존 이중 게이트를 유지하기 위해 로컬 설정에는 `trustedLiveOutputEnabled: true`
명시되어야 한다.
일반 Development Live의 `maximumAutomaticRefreshesPerTakeIn``null`이어야 한다.
이는 기존 프로그램처럼 TAKE IN 뒤 3초 간격 자동 갱신을 계속한다는 뜻이다. 이전
초기화 도구가 만든 값 `0`은 자동 갱신을 끄므로 새 strict 계약에서 거부된다. 앱이
보호된 파일을 자동 수정하지는 않으며, 첫 설정 창의
`기존 Live 설정 교체 후 다시 시도` 또는 검토된
`Initialize-ExistingDevelopmentPc.ps1 -ReplaceLiveConfig` 절차로 한 번 교체해야 한다.
교체 과정은 기존 Live 승인을 먼저 무효화한 뒤 새 설정 검증과 승인 재발급을 마친다.
Gate A 검증 설정의 `0`은 의도적인 회차 안전 상한이므로 유지한다.
실제 cue의 `PlayoutCue.SceneName`은 builder의 대표 SceneCode가 아니라 활성 cut alias다.
전체 이관 UI를 개발 PGM에서 검증할 때 `testSceneAllowlist`에는
`LegacySceneRuntimeCoverage.ExpectedCutCodes`와 동일한 다음 45개 alias를 사용한다.

View File

@@ -205,7 +205,7 @@ $playout = [ordered]@{
reconnectDelayMilliseconds = 1000
maximumReconnectAttempts = 0
reconnectEnabled = $false
maximumAutomaticRefreshesPerTakeIn = 0
maximumAutomaticRefreshesPerTakeIn = $null
}
$authorization = [ordered]@{

View File

@@ -1450,7 +1450,7 @@ function Get-DevelopmentLivePreflight {
reconnectDelayMilliseconds = 1000
maximumReconnectAttempts = 0
reconnectEnabled = $false
maximumAutomaticRefreshesPerTakeIn = 0
maximumAutomaticRefreshesPerTakeIn = $null
}
$authorization = [ordered]@{
schemaVersion = 1

View File

@@ -145,8 +145,9 @@ public interface ILegacySceneCueProvider
}
/// <summary>
/// Read-only page-zero DTO boundary used to calculate PageN before PREPARE. Implementations
/// must not build a cue, dispatch an engine command, or touch COM/vendor objects.
/// Read-only page-zero DTO boundary used to calculate PageN before a new playlist cue.
/// Implementations must not build a cue, dispatch an engine command, or touch COM/vendor
/// objects.
/// </summary>
public interface ILegacyScenePagePlanProvider
{
@@ -204,6 +205,14 @@ public sealed record LegacyPlayoutWorkflowState(
public int OnAirCueIndexZeroBased { get; init; } = -1;
public string? OnAirEntryId { get; init; }
/// <summary>
/// Page-navigation identity of the row currently owned by the legacy NEXT
/// cursor. This can differ from <see cref="IsPageNavigationEnabled"/> after
/// NEXT consumes a deterministically unplayable row while the previous scene
/// truthfully remains on air.
/// </summary>
public bool IsNextCursorPageNavigationEnabled { get; init; }
}
/// <summary>
@@ -219,11 +228,14 @@ public sealed class LegacyPlayoutWorkflow
private readonly ILegacyScenePagePlanProvider? _pagePlanProvider;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly object _engineStatusSync = new();
private readonly Dictionary<string, LegacyPlaylistPageNavigation>
_verifiedDerivedPageNavigation = new(StringComparer.Ordinal);
private IReadOnlyList<LegacyPlaylistEntry> _playlist = [];
private ActivePage? _prepared;
private ActivePage? _onAir;
private int _nextCursorIndexZeroBased = -1;
private string? _nextCursorEntryId;
private int _nextCursorPageIndexZeroBased = -1;
private int _engineClearRequested;
private long _lastObservedEngineStatusSequence = long.MinValue;
@@ -257,46 +269,13 @@ public sealed class LegacyPlayoutWorkflow
"Playlist page plans can be queried only while the playout workflow is idle.");
}
var provider = _pagePlanProvider ?? throw new InvalidOperationException(
"The legacy scene provider does not expose read-only page preflight.");
var snapshot = ValidatePlaylistEntries(entries, nameof(entries));
var results = new LegacyPlaylistPagePlan[snapshot.Count];
for (var index = 0; index < snapshot.Count; index++)
{
cancellationToken.ThrowIfCancellationRequested();
var entry = snapshot[index];
var plan = await provider.CreatePagePlanAsync(entry, cancellationToken)
.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(plan);
if (plan.ItemCount < 0 ||
plan.PageSize is not (ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve) ||
plan.PageCount is < 0 or > ScenePaging.MaximumPageCount ||
plan.AccessibleItemCount < 0 ||
plan.AccessibleItemCount > plan.ItemCount ||
plan.IsTruncated != (plan.AccessibleItemCount < plan.ItemCount) ||
string.IsNullOrWhiteSpace(plan.BuilderKey))
{
throw new LegacySceneDataException(
"The scene provider returned an invalid page preflight plan.");
}
var expected = ScenePaging.CreatePlan(plan.ItemCount, plan.PageSize);
if (!expected.IsValid || expected.Plan is null ||
expected.Plan.TotalPages != plan.PageCount ||
expected.Plan.AccessibleItemCount != plan.AccessibleItemCount)
{
throw new LegacySceneDataException(
"The scene provider page preflight does not match PageN rules.");
}
results[index] = new LegacyPlaylistPagePlan(
entry.EntryId,
plan.ItemCount,
(int)plan.PageSize,
plan.PageCount,
plan.AccessibleItemCount,
plan.IsTruncated,
plan.BuilderKey);
results[index] = await CreateValidatedPagePlanAsync(
snapshot[index],
cancellationToken).ConfigureAwait(false);
}
return Array.AsReadOnly(results);
@@ -307,6 +286,47 @@ public sealed class LegacyPlayoutWorkflow
}
}
private async Task<LegacyPlaylistPagePlan> CreateValidatedPagePlanAsync(
LegacyPlaylistEntry entry,
CancellationToken cancellationToken)
{
var provider = _pagePlanProvider ?? throw new InvalidOperationException(
"The legacy scene provider does not expose read-only page preflight.");
cancellationToken.ThrowIfCancellationRequested();
var plan = await provider.CreatePagePlanAsync(entry, cancellationToken)
.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(plan);
if (plan.ItemCount < 0 ||
plan.PageSize is not (ScenePageSize.Five or ScenePageSize.Six or ScenePageSize.Twelve) ||
plan.PageCount is < 0 or > ScenePaging.MaximumPageCount ||
plan.AccessibleItemCount < 0 ||
plan.AccessibleItemCount > plan.ItemCount ||
plan.IsTruncated != (plan.AccessibleItemCount < plan.ItemCount) ||
string.IsNullOrWhiteSpace(plan.BuilderKey))
{
throw new LegacySceneDataException(
"The scene provider returned an invalid page preflight plan.");
}
var expected = ScenePaging.CreatePlan(plan.ItemCount, plan.PageSize);
if (!expected.IsValid || expected.Plan is null ||
expected.Plan.TotalPages != plan.PageCount ||
expected.Plan.AccessibleItemCount != plan.AccessibleItemCount)
{
throw new LegacySceneDataException(
"The scene provider page preflight does not match PageN rules.");
}
return new LegacyPlaylistPagePlan(
entry.EntryId,
plan.ItemCount,
(int)plan.PageSize,
plan.PageCount,
plan.AccessibleItemCount,
plan.IsTruncated,
plan.BuilderKey);
}
/// <summary>
/// Reconciles asynchronous OnCutOut/OnStopAll/disconnect status with the native
/// playlist state. It never dispatches an engine command. If a command currently
@@ -367,11 +387,13 @@ public sealed class LegacyPlayoutWorkflow
var takeOut = await _engine.TakeOutAsync(
PlayoutTakeOutScope.All,
cancellationToken).ConfigureAwait(false);
if (takeOut.IsSuccess)
if (takeOut.IsSuccess &&
!_engine.Status.IsTakeOutCompletionPending)
{
_prepared = null;
_onAir = null;
_playlist = [];
_verifiedDerivedPageNavigation.Clear();
ResetNextCursor();
State = EmptyState;
}
@@ -388,6 +410,10 @@ public sealed class LegacyPlayoutWorkflow
"선택한 위치 이후에 활성화된 플레이리스트 항목이 없습니다.");
}
snapshot = await CreatePlaylistWithFreshTargetPageNavigationAsync(
snapshot,
selectedIndex.Value,
cancellationToken).ConfigureAwait(false);
var page = await CreatePageAsync(
snapshot,
selectedIndex.Value,
@@ -399,7 +425,8 @@ public sealed class LegacyPlayoutWorkflow
{
_playlist = snapshot;
_prepared = page;
SetNextCursor(page.CueIndexZeroBased);
RecordVerifiedDerivedPageNavigation(snapshot, page);
SetNextCursor(page.CueIndexZeroBased, page.PageIndexZeroBased);
PublishState();
}
@@ -448,6 +475,10 @@ public sealed class LegacyPlayoutWorkflow
"There is no enabled playlist entry at or after the selection.");
}
snapshot = await CreatePlaylistWithFreshTargetPageNavigationAsync(
snapshot,
selectedIndex.Value,
cancellationToken).ConfigureAwait(false);
var page = await CreatePageAsync(
snapshot,
selectedIndex.Value,
@@ -467,7 +498,7 @@ public sealed class LegacyPlayoutWorkflow
_prepared = page;
if (!hadOnAirScene)
{
SetNextCursor(page.CueIndexZeroBased);
SetNextCursor(page.CueIndexZeroBased, page.PageIndexZeroBased);
}
PublishState();
@@ -476,7 +507,8 @@ public sealed class LegacyPlayoutWorkflow
{
_onAir = page;
_prepared = null;
SetNextCursor(page.CueIndexZeroBased);
RecordVerifiedDerivedPageNavigation(snapshot, page);
SetNextCursor(page.CueIndexZeroBased, page.PageIndexZeroBased);
PublishState();
}
@@ -538,7 +570,10 @@ public sealed class LegacyPlayoutWorkflow
{
_onAir = refreshed;
_prepared = null;
SetNextCursor(refreshed.CueIndexZeroBased);
RecordVerifiedDerivedPageNavigation(_playlist, refreshed);
SetNextCursor(
refreshed.CueIndexZeroBased,
refreshed.PageIndexZeroBased);
PublishState();
}
@@ -583,31 +618,82 @@ public sealed class LegacyPlayoutWorkflow
"송출 중 변경된 플레이리스트의 현재 행 이전 구간을 안전하게 확인할 수 없습니다.");
}
var cursorIndex = ResolveNextCursorIndex(
_onAir.CueIndexZeroBased,
_onAir.PageIndexZeroBased);
if (_onAir.Plan is { } currentPlan &&
_onAir.PageIndexZeroBased + 1 < currentPlan.TotalPages)
cursorIndex == _onAir.CueIndexZeroBased &&
_nextCursorPageIndexZeroBased + 1 < currentPlan.TotalPages)
{
var nextPage = await CreatePageAsync(
_playlist,
_onAir.CueIndexZeroBased,
_onAir.PageIndexZeroBased + 1,
cancellationToken).ConfigureAwait(false);
if (!string.Equals(
nextPage.Page.Cue.SceneName,
_onAir.Page.Cue.SceneName,
StringComparison.OrdinalIgnoreCase))
var previousPageCursor = _nextCursorPageIndexZeroBased;
var targetPageIndex = previousPageCursor + 1;
// MainForm wrote PageN before loading/building the next page. A
// deterministic page-data failure therefore consumed that NEXT,
// even though the previous page truthfully remained on PGM.
SetNextCursor(cursorIndex, targetPageIndex);
PublishState();
ActivePage nextPage;
try
{
throw new LegacySceneDataException(
"A page update must target the scene already on air.");
nextPage = await CreatePageAsync(
_playlist,
_onAir.CueIndexZeroBased,
targetPageIndex,
cancellationToken).ConfigureAwait(false);
if (!string.Equals(
nextPage.Page.Cue.SceneName,
_onAir.Page.Cue.SceneName,
StringComparison.OrdinalIgnoreCase))
{
throw new LegacySceneDataException(
"A page update must target the scene already on air.");
}
}
catch (OperationCanceledException)
{
SetNextCursor(cursorIndex, previousPageCursor);
PublishState();
throw;
}
catch
{
// MainForm advanced PageN before any provider/database work and
// swallowed those failures. Only cancellation proves that the
// operator did not complete this NEXT gesture; every other
// pre-dispatch failure consumes this page.
throw;
}
// btnNext_Click calls Next_Scene(0). For all 5/6/12-row branches
// the idx == 0 path loads and prepares a fresh scene before Play.
var result = await _engine.NextAsync(
nextPage.Page.Cue,
cancellationToken).ConfigureAwait(false);
PlayoutResult result;
try
{
result = await _engine.NextAsync(
nextPage.Page.Cue,
cancellationToken).ConfigureAwait(false);
}
catch
{
SetNextCursor(cursorIndex, previousPageCursor);
PublishState();
throw;
}
if (result.IsSuccess)
{
_onAir = nextPage;
_prepared = null;
RecordVerifiedDerivedPageNavigation(_playlist, nextPage);
PublishState();
}
else if (result.Code != PlayoutResultCode.Rejected)
{
// As with playlist NEXT, only a deterministic rejection proves
// this one target was not sent and can safely remain consumed.
SetNextCursor(cursorIndex, previousPageCursor);
PublishState();
}
@@ -616,7 +702,7 @@ public sealed class LegacyPlayoutWorkflow
var previousCursorIndex = _nextCursorIndexZeroBased;
var previousCursorEntryId = _nextCursorEntryId;
var cursorIndex = ResolveNextCursorIndex(_onAir.CueIndexZeroBased);
var previousCursorPageIndex = _nextCursorPageIndexZeroBased;
var targetIndex = FindNextEnabledCue(cursorIndex + 1);
if (targetIndex is null)
{
@@ -628,28 +714,37 @@ public sealed class LegacyPlayoutWorkflow
// selected scene. Keep that progress cursor independent from _onAir: a
// known-unplayable row consumes exactly one NEXT, while the previous
// scene remains the truthful PGM identity.
SetNextCursor(targetIndex.Value);
SetNextCursor(targetIndex.Value, pageIndexZeroBased: 0);
PublishState();
IReadOnlyList<LegacyPlaylistEntry> nextPlaylist;
ActivePage nextCue;
try
{
nextCue = await CreatePageAsync(
nextPlaylist = await CreatePlaylistWithFreshTargetPageNavigationAsync(
_playlist,
targetIndex.Value,
cancellationToken).ConfigureAwait(false);
nextCue = await CreatePageAsync(
nextPlaylist,
targetIndex.Value,
pageIndexZeroBased: 0,
cancellationToken).ConfigureAwait(false);
}
catch (LegacySceneDataException)
catch (OperationCanceledException)
{
// This is a deterministic, pre-dispatch failure for this row. Keep
// the cursor on it so the following NEXT starts after it.
RestoreNextCursor(
previousCursorIndex,
previousCursorEntryId,
previousCursorPageIndex);
PublishState();
throw;
}
catch
{
RestoreNextCursor(previousCursorIndex, previousCursorEntryId);
PublishState();
// MainForm assigned m_crow before Show_PlayList and swallowed any
// provider/database failure. Retain the advanced row cursor so the
// following NEXT continues after this unplayable row.
throw;
}
@@ -661,15 +756,20 @@ public sealed class LegacyPlayoutWorkflow
}
catch
{
RestoreNextCursor(previousCursorIndex, previousCursorEntryId);
RestoreNextCursor(
previousCursorIndex,
previousCursorEntryId,
previousCursorPageIndex);
PublishState();
throw;
}
if (nextResult.IsSuccess)
{
_playlist = nextPlaylist;
_onAir = nextCue;
_prepared = null;
RecordVerifiedDerivedPageNavigation(nextPlaylist, nextCue);
PublishState();
}
else if (nextResult.Code != PlayoutResultCode.Rejected)
@@ -677,7 +777,10 @@ public sealed class LegacyPlayoutWorkflow
// A deterministic rejection means this specific row was not sent and
// may be left behind. Cancellation, unavailability, failure, timeout,
// and OutcomeUnknown do not prove a row-specific no-output result.
RestoreNextCursor(previousCursorIndex, previousCursorEntryId);
RestoreNextCursor(
previousCursorIndex,
previousCursorEntryId,
previousCursorPageIndex);
PublishState();
}
@@ -690,6 +793,42 @@ public sealed class LegacyPlayoutWorkflow
}
}
private async Task<IReadOnlyList<LegacyPlaylistEntry>> CreatePlaylistWithFreshTargetPageNavigationAsync(
IReadOnlyList<LegacyPlaylistEntry> sourcePlaylist,
int targetIndex,
CancellationToken cancellationToken)
{
var entry = sourcePlaylist[targetIndex];
if (entry.PageNavigation?.PageSize is not { } expectedPageSize)
{
return sourcePlaylist;
}
var plan = await CreateValidatedPagePlanAsync(entry, cancellationToken)
.ConfigureAwait(false);
if (!string.Equals(plan.EntryId, entry.EntryId, StringComparison.Ordinal) ||
plan.PageSize != (int)expectedPageSize ||
plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
{
throw new LegacySceneDataException(
"The playlist page preflight does not match the NEXT target PageN row.");
}
if (entry.PageNavigation.FrozenPageCount == plan.PageCount)
{
return sourcePlaylist;
}
var updated = sourcePlaylist.ToArray();
updated[targetIndex] = entry with
{
PageNavigation = new LegacyPlaylistPageNavigation(
expectedPageSize,
plan.PageCount)
};
return Array.AsReadOnly(updated);
}
private bool TryAdoptOperatorPlaylistTail(
IReadOnlyList<LegacyPlaylistEntry> operatorPlaylist)
{
@@ -715,6 +854,7 @@ public sealed class LegacyPlayoutWorkflow
return false;
}
var normalizedCandidate = candidate.ToArray();
for (var index = 0; index <= currentIndex; index++)
{
// MainForm allowed the PROGRAM operator to change the active-row
@@ -723,10 +863,32 @@ public sealed class LegacyPlayoutWorkflow
// consulted rows after m_crow. Accept that one harmless visible field,
// while keeping every identity, scene, selection, page and live-source
// field in the retained prefix strictly equal.
var candidateWithRetainedEnabledState = candidate[index] with
var candidateWithRetainedEnabledState = normalizedCandidate[index] with
{
IsEnabled = _playlist[index].IsEnabled
};
if (_playlist[index].PageNavigation is null &&
candidateWithRetainedEnabledState.PageNavigation is { } candidateNavigation &&
_verifiedDerivedPageNavigation.TryGetValue(
_playlist[index].EntryId,
out var verifiedNavigation) &&
candidateNavigation == verifiedNavigation)
{
// A historical 1/0 PageText row intentionally loads with null
// navigation. After a successful provider-derived build the UI
// reflects 1/N; accept exactly that verified metadata transition
// without changing the retained provider-authoritative contract.
candidateWithRetainedEnabledState =
candidateWithRetainedEnabledState with
{
PageNavigation = null
};
normalizedCandidate[index] = normalizedCandidate[index] with
{
PageNavigation = null
};
}
if (!Equals(candidateWithRetainedEnabledState, _playlist[index]))
{
return false;
@@ -734,6 +896,8 @@ public sealed class LegacyPlayoutWorkflow
}
var retainedCursorEntryId = _nextCursorEntryId;
var retainedCursorPageIndex = _nextCursorPageIndexZeroBased;
candidate = Array.AsReadOnly(normalizedCandidate);
_playlist = candidate;
var retainedCursorIndex = retainedCursorEntryId is null
? -1
@@ -748,13 +912,15 @@ public sealed class LegacyPlayoutWorkflow
.Single();
if (retainedCursorIndex >= currentIndex)
{
SetNextCursor(retainedCursorIndex);
SetNextCursor(
retainedCursorIndex,
Math.Max(0, retainedCursorPageIndex));
}
else
{
// If a mutable future tail removed the last attempted row, resume from
// the still-verified on-air anchor in the newly adopted list.
SetNextCursor(currentIndex);
SetNextCursor(currentIndex, _onAir.PageIndexZeroBased);
}
PublishState();
return true;
@@ -813,11 +979,13 @@ public sealed class LegacyPlayoutWorkflow
{
var result = await _engine.TakeOutAsync(scope, cancellationToken)
.ConfigureAwait(false);
if (result.IsSuccess)
if (result.IsSuccess &&
!_engine.Status.IsTakeOutCompletionPending)
{
_prepared = null;
_onAir = null;
_playlist = [];
_verifiedDerivedPageNavigation.Clear();
ResetNextCursor();
State = EmptyState;
}
@@ -931,6 +1099,26 @@ public sealed class LegacyPlayoutWorkflow
pageNavigationEnabled);
}
private void RecordVerifiedDerivedPageNavigation(
IReadOnlyList<LegacyPlaylistEntry> sourcePlaylist,
ActivePage page)
{
var entry = sourcePlaylist[page.CueIndexZeroBased];
if (entry.PageNavigation is null &&
page.Page.PageSize is { } derivedPageSize &&
page.Plan is { TotalPages: >= 1 } derivedPlan &&
page.IsPageNavigationEnabled)
{
_verifiedDerivedPageNavigation[entry.EntryId] =
new LegacyPlaylistPageNavigation(
derivedPageSize,
derivedPlan.TotalPages);
return;
}
_verifiedDerivedPageNavigation.Remove(entry.EntryId);
}
private static IReadOnlyList<LegacyPlaylistEntry> ValidatePlaylist(
IReadOnlyList<LegacyPlaylistEntry>? playlist,
int cueIndexZeroBased)
@@ -970,7 +1158,9 @@ public sealed class LegacyPlayoutWorkflow
return Array.AsReadOnly(entries);
}
private int ResolveNextCursorIndex(int fallbackIndex)
private int ResolveNextCursorIndex(
int fallbackIndex,
int fallbackPageIndexZeroBased = 0)
{
if (_nextCursorEntryId is not null &&
_nextCursorIndexZeroBased >= 0 &&
@@ -983,30 +1173,41 @@ public sealed class LegacyPlayoutWorkflow
return _nextCursorIndexZeroBased;
}
SetNextCursor(fallbackIndex);
SetNextCursor(fallbackIndex, fallbackPageIndexZeroBased);
return _nextCursorIndexZeroBased;
}
private void SetNextCursor(int index)
private void SetNextCursor(int index, int pageIndexZeroBased = 0)
{
if (index < 0 || index >= _playlist.Count)
{
throw new InvalidOperationException("The NEXT cursor is outside the retained playlist.");
}
if (pageIndexZeroBased < 0)
{
throw new InvalidOperationException("The NEXT page cursor cannot be negative.");
}
_nextCursorIndexZeroBased = index;
_nextCursorEntryId = _playlist[index].EntryId;
_nextCursorPageIndexZeroBased = pageIndexZeroBased;
}
private void RestoreNextCursor(int index, string? entryId)
private void RestoreNextCursor(
int index,
string? entryId,
int pageIndexZeroBased)
{
if (entryId is not null &&
index >= 0 &&
index < _playlist.Count &&
pageIndexZeroBased >= 0 &&
string.Equals(_playlist[index].EntryId, entryId, StringComparison.Ordinal))
{
_nextCursorIndexZeroBased = index;
_nextCursorEntryId = entryId;
_nextCursorPageIndexZeroBased = pageIndexZeroBased;
return;
}
@@ -1017,6 +1218,7 @@ public sealed class LegacyPlayoutWorkflow
{
_nextCursorIndexZeroBased = -1;
_nextCursorEntryId = null;
_nextCursorPageIndexZeroBased = -1;
}
private int? FindNextEnabledCue(int startIndex)
@@ -1048,8 +1250,16 @@ public sealed class LegacyPlayoutWorkflow
var pageCount = active.Plan?.TotalPages ?? 1;
var pageSize = active.Page.PageSize is { } size ? (int)size : 0;
var isLastPage = active.PageIndexZeroBased + 1 >= pageCount;
var cursorIndex = ResolveNextCursorIndex(active.CueIndexZeroBased);
var cursorIndex = ResolveNextCursorIndex(
active.CueIndexZeroBased,
active.PageIndexZeroBased);
var cursorOwnsActiveEntry = cursorIndex == active.CueIndexZeroBased;
var pageIndexZeroBased = cursorOwnsActiveEntry
? Math.Min(_nextCursorPageIndexZeroBased, pageCount - 1)
: active.PageIndexZeroBased;
var isLastPage =
!cursorOwnsActiveEntry ||
pageIndexZeroBased + 1 >= pageCount;
var nextKind = forcedNextKind ?? (!isLastPage
? LegacyWorkflowNextKind.PageNext
: FindNextEnabledCue(cursorIndex + 1).HasValue
@@ -1059,7 +1269,7 @@ public sealed class LegacyPlayoutWorkflow
cursorIndex,
_prepared?.Page.Cue.SceneName,
_onAir?.Page.Cue.SceneName,
active.PageIndexZeroBased,
pageIndexZeroBased,
pageCount,
pageSize,
active.Page.ItemCount,
@@ -1071,14 +1281,18 @@ public sealed class LegacyPlayoutWorkflow
? active.Page.ItemCount
: Math.Min(
pageSize,
Math.Max(0, active.Page.ItemCount - (active.PageIndexZeroBased * pageSize))),
Math.Max(0, active.Page.ItemCount - (pageIndexZeroBased * pageSize))),
active.Page.PreviewFields ?? Array.Empty<LegacyScenePreviewField>(),
active.IsPageNavigationEnabled)
{
OnAirCueIndexZeroBased = _onAir?.CueIndexZeroBased ?? -1,
OnAirEntryId = _onAir is null
? null
: _playlist[_onAir.CueIndexZeroBased].EntryId
: _playlist[_onAir.CueIndexZeroBased].EntryId,
IsNextCursorPageNavigationEnabled =
cursorOwnsActiveEntry
? active.IsPageNavigationEnabled
: _playlist[cursorIndex].PageNavigation?.IsEnabled == true
};
}
@@ -1154,6 +1368,7 @@ public sealed class LegacyPlayoutWorkflow
_prepared = null;
_onAir = null;
_playlist = [];
_verifiedDerivedPageNavigation.Clear();
ResetNextCursor();
State = EmptyState;
}

View File

@@ -4631,8 +4631,10 @@ public sealed class LegacyOperatorController
out var fixedSelection,
out _)
? fixedSelection
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
? stockDetailSelection
: TryCreateNamedStockCandleSelection(selection, out var stockCandleSelection)
? stockCandleSelection
: TryCreateNamedStockDetailSelection(selection, out var stockDetailSelection)
? stockDetailSelection
: TryCreateNamedLegacyIndustryFixedSelection(
selection,
out var legacyIndustryFixedSelection)
@@ -5165,6 +5167,35 @@ public sealed class LegacyOperatorController
label.Contains("코스닥", StringComparison.Ordinal) ? "코스닥" :
"전체";
private static bool TryCreateNamedStockCandleSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)
{
normalized = null!;
try
{
LegacyComparisonValueGuard.RequireText(
selection.Subject,
200,
nameof(selection.Subject));
LegacyComparisonValueGuard.RequireStockCode(
selection.DataCode,
nameof(selection.DataCode));
}
catch (ArgumentException)
{
return false;
}
return LegacyStockCutCatalog.TryCreateDomesticStockCandleSelection(
selection.GroupCode,
selection.Subject,
selection.GraphicType,
selection.Subtype,
selection.DataCode,
out normalized);
}
private static bool TryCreateNamedStockDetailSelection(
LegacyNamedPlaylistSelectionView selection,
out CoreSceneSelection normalized)

View File

@@ -230,14 +230,22 @@ public static class LegacyStockCutCatalog
"1열판상세_거래량" => nameof(S5011DetailBranch.Volume),
_ => null
};
var playoutSelection = runtimeDetailBranch is null
? null
: new LegacySceneSelection(
var playoutSelection = runtimeDetailBranch is not null
? new LegacySceneSelection(
marketText,
stockName,
graphicType,
runtimeDetailBranch,
stockCode);
stockCode)
: TryCreateDomesticStockCandleSelection(
marketText,
stockName,
graphicType,
subtype,
stockCode,
out var candleSelection)
? candleSelection
: null;
return new LegacyOperatorPlaylistRow(
rowId,
@@ -253,6 +261,53 @@ public static class LegacyStockCutCatalog
PlayoutSelection: playoutSelection);
}
internal static bool TryCreateDomesticStockCandleSelection(
string marketText,
string stockName,
string graphicType,
string subtype,
string stockCode,
out LegacySceneSelection selection)
{
selection = null!;
var runtimeMarket = marketText switch
{
"코스피" => "KOSPI_STOCK",
"코스닥" => "KOSDAQ_STOCK",
_ => null
};
var runtimeMode = graphicType switch
{
"캔들그래프" => "PRICE",
"캔들그래프(거래량)" => "VOLUME",
"캔들그래프(예상체결가)" => "EXPECTED",
_ => null
};
if (runtimeMarket is null ||
runtimeMode is null ||
string.IsNullOrWhiteSpace(stockName) ||
string.IsNullOrWhiteSpace(stockCode))
{
return false;
}
var rawCutLabel = $"{graphicType}_{subtype}";
if (!Rows.Any(row =>
!row.IsSeparator &&
string.Equals(row.RawLabel, rawCutLabel, StringComparison.Ordinal)))
{
return false;
}
selection = new LegacySceneSelection(
runtimeMarket,
stockName,
string.Empty,
runtimeMode,
stockCode);
return true;
}
private static IReadOnlyList<LegacyCutCatalogRow> CreateRows()
{
var displayOrdinal = 0;

View File

@@ -61,20 +61,78 @@ public partial class App : Application
args.Arguments,
Environment.GetCommandLineArgs());
var developmentLive = DevelopmentLiveLaunchBootstrap.TryApply(launchArguments);
var configurationRecoveryMessage =
ResolveDevelopmentLiveConfigurationRecoveryMessage(developmentLive);
var recoverySetupAvailable =
configurationRecoveryMessage is not null &&
FirstRunSetupWindow.IsVerifiedSetupAvailable();
var displayedRecoveryMessage =
configurationRecoveryMessage is not null && !recoverySetupAvailable
? configurationRecoveryMessage +
" 현재 빌드에는 검증된 Git 소스 경로가 포함되어 있지 않아 자동 교체를 실행할 수 없습니다. " +
"최신 소스를 Visual Studio에서 Debug x64로 다시 빌드해 실행해 주세요."
: configurationRecoveryMessage;
IsDevelopmentLiveLaunchRequested = developmentLive.IsRequested;
IsDevelopmentLiveLaunch = developmentLive.IsApplied;
DevelopmentLiveStartupFailureCode = developmentLive.IsLaunchAllowed
? null
: developmentLive.Code;
DevelopmentLiveStartupFailureMessage = developmentLive.FailureMessage;
IsDevelopmentLiveLaunch =
developmentLive.IsApplied &&
configurationRecoveryMessage is null;
DevelopmentLiveStartupFailureCode = configurationRecoveryMessage is not null
? "development-live-configuration-replacement-required"
: developmentLive.IsLaunchAllowed
? null
: developmentLive.Code;
DevelopmentLiveStartupFailureMessage =
displayedRecoveryMessage ??
developmentLive.FailureMessage;
System.Diagnostics.Debug.WriteLine(
$"Development Live bootstrap: {developmentLive.Code}");
_window = new MainWindow();
_window = configurationRecoveryMessage is null
? new MainWindow()
: new FirstRunSetupWindow(
displayedRecoveryMessage,
liveConfigReplacementAvailable: recoverySetupAvailable);
#endif
_window.Activate();
}
#if !SOURCE_ONLY_RUNTIME
private static string? ResolveDevelopmentLiveConfigurationRecoveryMessage(
DevelopmentLiveBootstrapResult developmentLive)
{
if (developmentLive.Code is
"development-live-authorization-unavailable" or
"development-live-authorization-invalid")
{
return
"개발 Live 승인 파일이 없거나 유효하지 않아 시작을 중단했습니다. " +
"저장된 Cuts/Res 경로를 확인한 뒤 아래 교체 버튼으로 설정을 다시 검증해 주세요. " +
"버튼을 누르기 전에는 기존 Live 설정을 변경하지 않습니다.";
}
if (!developmentLive.IsApplied)
{
return null;
}
try
{
// Read-only preflight. Do not construct MainWindow (and therefore do not
// create a playout engine) when a protected profile from an older build
// no longer satisfies the exact Development Live contract.
_ = PlayoutOptionsLoader.LoadDevelopmentLive();
return null;
}
catch (PlayoutConfigurationException)
{
return
"기존 Development Live 설정 형식이 최신 버전과 달라 시작을 중단했습니다. " +
"저장된 Cuts/Res 경로를 확인한 뒤 아래 교체 버튼으로 설정을 다시 검증해 주세요. " +
"버튼을 누르기 전에는 기존 Live 설정을 변경하지 않습니다.";
}
}
#endif
private void OnMainInstanceActivated(object? sender, AppActivationArguments args)
{
_window?.DispatcherQueue.TryEnqueue(_window.Activate);

View File

@@ -23,14 +23,37 @@ public sealed partial class FirstRunSetupWindow : Window
private string? _resourceDirectory;
private bool _setupRunning;
private bool _liveConfigReplacementAvailable;
private bool _runtimeFoldersReady;
private readonly AppWindow _appWindow;
public FirstRunSetupWindow()
: this(startupFailureMessage: null, liveConfigReplacementAvailable: false)
{
}
internal FirstRunSetupWindow(
string? startupFailureMessage,
bool liveConfigReplacementAvailable)
{
InitializeComponent();
_appWindow = ResolveAppWindow();
_appWindow.Resize(new SizeInt32(820, 720));
_appWindow.Closing += OnAppWindowClosing;
RestoreStoredRuntimeFolders();
if (!string.IsNullOrWhiteSpace(startupFailureMessage))
{
_liveConfigReplacementAvailable =
liveConfigReplacementAvailable &&
IsVerifiedSetupAvailable();
ReplaceLiveConfigButton.Visibility =
_liveConfigReplacementAvailable
? Visibility.Visible
: Visibility.Collapsed;
ReplaceLiveConfigButton.IsEnabled =
_liveConfigReplacementAvailable && _runtimeFoldersReady;
ShowError(startupFailureMessage);
}
}
private async void OnChooseCutsClicked(object sender, RoutedEventArgs e)
@@ -232,6 +255,8 @@ public sealed partial class FirstRunSetupWindow : Window
private void RefreshReadyState()
{
_runtimeFoldersReady = false;
ReplaceLiveConfigButton.IsEnabled = false;
if (_cutsDirectory is null || _resourceDirectory is null)
{
InstallButton.IsEnabled = false;
@@ -252,13 +277,45 @@ public sealed partial class FirstRunSetupWindow : Window
return;
}
_runtimeFoldersReady = true;
InstallButton.IsEnabled = true;
ReplaceLiveConfigButton.IsEnabled = _liveConfigReplacementAvailable;
SetupStatusBar.Title = "준비 완료";
SetupStatusBar.Message =
"설정 시작을 누르면 Cuts/Res, DB, 표준 K3D 및 Development Live를 검증합니다.";
SetupStatusBar.Severity = InfoBarSeverity.Success;
}
private void RestoreStoredRuntimeFolders()
{
var loaded = new LegacyOperatorSettingsStore().Load();
if (loaded.Failure != LegacyOperatorSettingsStoreFailure.None)
{
RefreshReadyState();
return;
}
var cutsValidation = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Scene,
loaded.Settings.SceneDirectory);
if (cutsValidation.IsValid && cutsValidation.Folder is not null)
{
_cutsDirectory = cutsValidation.Folder.CanonicalPath;
CutsPathText.Text = _cutsDirectory;
}
var resourceValidation = LegacyOperatorFolderValidator.Validate(
LegacyOperatorSettingsFolderKind.Resource,
loaded.Settings.ResourceDirectory);
if (resourceValidation.IsValid && resourceValidation.Folder is not null)
{
_resourceDirectory = resourceValidation.Folder.CanonicalPath;
ResourcePathText.Text = _resourceDirectory;
}
RefreshReadyState();
}
private static bool TryResolveRuntimeRoot(
string? cutsDirectory,
string? resourceDirectory,
@@ -327,34 +384,58 @@ public sealed partial class FirstRunSetupWindow : Window
return true;
}
internal static bool IsVerifiedSetupAvailable() =>
TryResolveRepositoryRoot(out _);
private static string ResolveRepositoryRoot()
{
var metadata = typeof(FirstRunSetupWindow).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.Single(attribute => string.Equals(
attribute.Key,
RepositoryRootMetadataKey,
StringComparison.Ordinal));
if (string.IsNullOrWhiteSpace(metadata.Value))
{
throw new InvalidOperationException(
"The source repository metadata is empty.");
}
var repositoryRoot = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(metadata.Value));
var project = Path.Combine(
repositoryRoot,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MBN_STOCK_WEBVIEW.LegacyParityApp.csproj");
if (!File.Exists(project))
if (!TryResolveRepositoryRoot(out var repositoryRoot))
{
throw new InvalidOperationException(
"The source repository metadata is not usable.");
}
return repositoryRoot;
return repositoryRoot!;
}
private static bool TryResolveRepositoryRoot(out string? repositoryRoot)
{
repositoryRoot = null;
try
{
var metadata = typeof(FirstRunSetupWindow).Assembly
.GetCustomAttributes<AssemblyMetadataAttribute>()
.SingleOrDefault(attribute => string.Equals(
attribute.Key,
RepositoryRootMetadataKey,
StringComparison.Ordinal));
if (string.IsNullOrWhiteSpace(metadata?.Value))
{
return false;
}
var candidate = Path.TrimEndingDirectorySeparator(
Path.GetFullPath(metadata.Value));
var project = Path.Combine(
candidate,
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MBN_STOCK_WEBVIEW.LegacyParityApp.csproj");
if (!File.Exists(project))
{
return false;
}
repositoryRoot = candidate;
return true;
}
catch (Exception exception) when (
exception is ArgumentException or IOException or
UnauthorizedAccessException or NotSupportedException or
System.Security.SecurityException or InvalidOperationException)
{
return false;
}
}
private static async Task RunVerifiedSetupAsync(
@@ -558,10 +639,10 @@ public sealed partial class FirstRunSetupWindow : Window
ChooseCutsButton.IsEnabled = !running;
ChooseResourceButton.IsEnabled = !running;
ReplaceLiveConfigButton.IsEnabled =
!running && _liveConfigReplacementAvailable;
InstallButton.IsEnabled = !running &&
_cutsDirectory is not null &&
_resourceDirectory is not null;
!running &&
_liveConfigReplacementAvailable &&
_runtimeFoldersReady;
InstallButton.IsEnabled = !running && _runtimeFoldersReady;
SetupProgress.Visibility = running
? Visibility.Visible
: Visibility.Collapsed;

View File

@@ -25,9 +25,9 @@
<ApplicationVersion>1</ApplicationVersion>
<Version>0.1.0</Version>
<!--
Source-only F5 runs from an MSIX layout, not necessarily from the clone.
Embed this local, non-secret build path so the first-run window can write
the direct local runtime binding into the exact clone Visual Studio built.
Debug F5 runs from an MSIX layout, not necessarily from the clone.
Embed this local, non-secret build path so both first-run setup and a
protected-config recovery can update the exact clone Visual Studio built.
-->
<SourceRepositoryRoot>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\..'))</SourceRepositoryRoot>
<!--
@@ -78,8 +78,7 @@
<ItemGroup>
<AssemblyMetadata Include="SourceRepositoryRoot"
Value="$(SourceRepositoryRoot)"
Condition="'$(LegacyRuntimeAssetsEnabled)' != 'true' and
'$(Configuration)' == 'Debug'" />
Condition="'$(Configuration)' == 'Debug'" />
</ItemGroup>
<ItemGroup>

View File

@@ -27,6 +27,14 @@ public sealed partial class MainWindow
private int _playoutQuarantined;
private int _playoutShutdownStarted;
private int _operatorMutationQuarantined;
private const string PendingPlayOperatorMessage =
"송출 명령이 수락되어 Tornado 재생 완료 이벤트를 기다리고 있습니다.";
private const string CompletedPlayOperatorMessage =
"Tornado 재생 완료 이벤트가 확인되었습니다.";
private const string PendingTakeOutOperatorMessage =
"TAKE OUT 명령이 수락되어 Tornado 완료 이벤트를 기다리고 있습니다.";
private const string CompletedTakeOutOperatorMessage =
"장면 송출이 종료되었습니다.";
private void InitializePlayoutRuntime()
{
@@ -109,8 +117,20 @@ public sealed partial class MainWindow
var result = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess)
{
_playoutInitializationWarning = null;
_playoutInitializationError = result.Message;
if (engine.Status.State is
PlayoutConnectionState.Connecting or
PlayoutConnectionState.Reconnecting)
{
// KTAPConnect was dispatched once and accepted. OnHello is
// observed by the monitor; this is pending confirmation, not an
// initialization failure and must never trigger another connect.
_playoutInitializationError = null;
}
else
{
_playoutInitializationWarning = null;
_playoutInitializationError = result.Message;
}
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -127,6 +147,18 @@ public sealed partial class MainWindow
private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args)
{
if (sender is not IPlayoutEngine statusSource ||
!ReferenceEquals(statusSource, _playoutEngine))
{
return;
}
if (args.Current.State == PlayoutConnectionState.Connected &&
args.Current.IsConnected)
{
_playoutInitializationError = null;
}
_playoutWorkflow?.ObserveEngineStatus(args.Current);
if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
@@ -142,7 +174,146 @@ public sealed partial class MainWindow
}
}
QueueOperatorState();
QueuePlayoutStatus(statusSource, args);
}
private void QueuePlayoutStatus(
IPlayoutEngine statusSource,
PlayoutStatusChangedEventArgs args)
{
if (_closing || !_webViewReady)
{
return;
}
var completionKind = ResolvePlayoutCompletionTransition(args);
var (expectedPendingMessage, completedMessage) = completionKind switch
{
PlayoutCompletionKind.Play => (
PendingPlayOperatorMessage,
CompletedPlayOperatorMessage),
PlayoutCompletionKind.TakeOut => (
PendingTakeOutOperatorMessage,
CompletedTakeOutOperatorMessage),
_ => (string.Empty, string.Empty)
};
DispatcherQueue.TryEnqueue(() =>
{
if (_closing)
{
return;
}
var latestStatus = statusSource.Status;
var snapshot = _controller.Current;
if (ReferenceEquals(statusSource, _playoutEngine) &&
completionKind != PlayoutCompletionKind.None &&
latestStatus.Sequence >= args.Current.Sequence &&
IsPlayoutCompletionStillConfirmed(completionKind, latestStatus) &&
snapshot.StatusKind == LegacyOperatorStatusKind.Information &&
string.Equals(
snapshot.StatusMessage,
expectedPendingMessage,
StringComparison.Ordinal))
{
snapshot = _controller.ReportInformation(completedMessage);
}
PostState(snapshot);
});
}
private static PlayoutCompletionKind ResolvePlayoutCompletionTransition(
PlayoutStatusChangedEventArgs args)
{
if (args.Current.State != PlayoutConnectionState.Connected ||
!args.Current.IsConnected)
{
return PlayoutCompletionKind.None;
}
if (args.Previous.IsTakeOutCompletionPending &&
!args.Current.IsTakeOutCompletionPending &&
string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) &&
string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
{
return PlayoutCompletionKind.TakeOut;
}
if (args.Previous.IsPlayCompletionPending &&
!args.Current.IsPlayCompletionPending &&
!string.IsNullOrWhiteSpace(args.Current.OnAirSceneName))
{
return PlayoutCompletionKind.Play;
}
return PlayoutCompletionKind.None;
}
private static bool IsPlayoutCompletionStillConfirmed(
PlayoutCompletionKind completionKind,
PlayoutStatus status) =>
status.State == PlayoutConnectionState.Connected &&
status.IsConnected &&
completionKind switch
{
PlayoutCompletionKind.TakeOut =>
!status.IsTakeOutCompletionPending &&
string.IsNullOrWhiteSpace(status.PreparedSceneName) &&
string.IsNullOrWhiteSpace(status.OnAirSceneName),
PlayoutCompletionKind.Play =>
!status.IsPlayCompletionPending &&
!status.IsTakeOutCompletionPending &&
!string.IsNullOrWhiteSpace(status.OnAirSceneName),
_ => false
};
private LegacyOperatorSnapshot ReconcileCompletedPlayoutMessage(
IPlayoutEngine statusSource,
LegacyOperatorPlayoutCommand command,
string resultMessage,
LegacyOperatorSnapshot snapshot)
{
var completionKind = command switch
{
LegacyOperatorPlayoutCommand.TakeOut
when string.Equals(
resultMessage,
PendingTakeOutOperatorMessage,
StringComparison.Ordinal) =>
PlayoutCompletionKind.TakeOut,
LegacyOperatorPlayoutCommand.TakeIn or
LegacyOperatorPlayoutCommand.Next
when string.Equals(
resultMessage,
PendingPlayOperatorMessage,
StringComparison.Ordinal) =>
PlayoutCompletionKind.Play,
_ => PlayoutCompletionKind.None
};
if (completionKind == PlayoutCompletionKind.None ||
snapshot.StatusKind != LegacyOperatorStatusKind.Information ||
!ReferenceEquals(statusSource, _playoutEngine) ||
!IsPlayoutCompletionStillConfirmed(completionKind, statusSource.Status))
{
return snapshot;
}
// A lifecycle callback can be drained after the engine publishes the
// pending state but before this method stores result.Message. In that
// ordering the queued completion handler has already run and cannot
// replace the pending text. Reconcile once immediately after storing it.
return _controller.ReportInformation(
completionKind == PlayoutCompletionKind.TakeOut
? CompletedTakeOutOperatorMessage
: CompletedPlayOperatorMessage);
}
private enum PlayoutCompletionKind
{
None,
Play,
TakeOut
}
private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(
@@ -180,11 +351,27 @@ public sealed partial class MainWindow
return _controller.ReportError("다른 송출 명령을 처리하고 있습니다.");
}
LegacySceneCueCompositionOptions? activeCompositionBeforeNext = null;
string? nextCursorEntryIdBefore = null;
var nextCursorPageIndexBefore = 0;
try
{
Interlocked.Exchange(ref _playoutBusy, 1);
var nextKindBefore = workflow.State.NextKind;
var pageNavigationEnabledBefore = workflow.State.IsPageNavigationEnabled;
var pageNavigationEnabledBefore =
workflow.State.IsNextCursorPageNavigationEnabled;
nextCursorEntryIdBefore = workflow.State.CurrentEntryId;
nextCursorPageIndexBefore = workflow.State.PageIndexZeroBased;
if (command == LegacyOperatorPlayoutCommand.Next)
{
// MainForm.btnNext_Click stopped timer1 before checking any pending
// state or inspecting the target row. A quick NEXT immediately after
// TAKE IN must therefore leave the timer stopped even while the Play
// completion callback is still pending.
activeCompositionBeforeNext = _compositionOptions!.Current;
StopRefreshLoop();
}
if (engine.Status.IsTakeOutCompletionPending)
{
return _controller.ReportError(
@@ -198,7 +385,11 @@ public sealed partial class MainWindow
"Tornado 재생 완료 이벤트를 기다리는 중입니다. 결과가 확인될 때까지 TAKE OUT만 사용할 수 있습니다.");
}
StopRefreshLoop();
if (command != LegacyOperatorPlayoutCommand.Next)
{
StopRefreshLoop();
}
if (command is LegacyOperatorPlayoutCommand.Prepare or
LegacyOperatorPlayoutCommand.TakeIn or
LegacyOperatorPlayoutCommand.Next)
@@ -273,6 +464,20 @@ public sealed partial class MainWindow
if (!result.IsSuccess)
{
RestoreActiveCompositionAfterFailedNext(
command,
activeCompositionBeforeNext);
if (result.Code == PlayoutResultCode.Rejected &&
TryReportConsumedNext(
command,
workflow,
nextCursorEntryIdBefore,
nextCursorPageIndexBefore,
out var consumedSnapshot))
{
return consumedSnapshot;
}
return _controller.ReportError(result.Message);
}
@@ -289,23 +494,87 @@ public sealed partial class MainWindow
StartRefreshLoop(workflow);
}
return _controller.ReportInformation(result.Message);
var information = _controller.ReportInformation(result.Message);
return ReconcileCompletedPlayoutMessage(
engine,
command,
result.Message,
information);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
RestoreActiveCompositionAfterFailedNext(
command,
activeCompositionBeforeNext);
return _controller.ReportError("송출 요청이 취소되었습니다.");
}
catch (DatabaseOperationException exception)
{
RestoreActiveCompositionAfterFailedNext(
command,
activeCompositionBeforeNext);
if (TryReportConsumedNext(
command,
workflow,
nextCursorEntryIdBefore,
nextCursorPageIndexBefore,
out var consumedSnapshot))
{
return consumedSnapshot;
}
return _controller.ReportError(exception.Message);
}
catch (LegacySceneDataException exception)
{
RestoreActiveCompositionAfterFailedNext(
command,
activeCompositionBeforeNext);
if (TryReportConsumedNext(
command,
workflow,
nextCursorEntryIdBefore,
nextCursorPageIndexBefore,
out var consumedSnapshot))
{
return consumedSnapshot;
}
return _controller.ReportError(exception.Message);
}
catch (Exception exception) when (
exception is LegacySceneDataException or ArgumentException or InvalidOperationException)
exception is ArgumentException or InvalidOperationException)
{
RestoreActiveCompositionAfterFailedNext(
command,
activeCompositionBeforeNext);
if (TryReportConsumedNext(
command,
workflow,
nextCursorEntryIdBefore,
nextCursorPageIndexBefore,
out var consumedSnapshot))
{
return consumedSnapshot;
}
return _controller.ReportError(exception.Message);
}
catch
{
RestoreActiveCompositionAfterFailedNext(
command,
activeCompositionBeforeNext);
if (TryReportConsumedNext(
command,
workflow,
nextCursorEntryIdBefore,
nextCursorPageIndexBefore,
out var consumedSnapshot))
{
return consumedSnapshot;
}
await QuarantinePlayoutAsync().ConfigureAwait(false);
return _controller.ReportError(
"송출 엔진 명령의 결과를 확인할 수 없습니다. 명령을 반복하지 마세요.");
@@ -318,6 +587,61 @@ public sealed partial class MainWindow
}
}
private void RestoreActiveCompositionAfterFailedNext(
LegacyOperatorPlayoutCommand command,
LegacySceneCueCompositionOptions? activeCompositionBeforeNext)
{
if (command == LegacyOperatorPlayoutCommand.Next &&
activeCompositionBeforeNext is not null)
{
_compositionOptions!.Update(activeCompositionBeforeNext);
}
}
private bool TryReportConsumedNext(
LegacyOperatorPlayoutCommand command,
LegacyPlayoutWorkflow workflow,
string? cursorEntryIdBefore,
int cursorPageIndexBefore,
out LegacyOperatorSnapshot snapshot)
{
snapshot = _controller.Current;
if (command != LegacyOperatorPlayoutCommand.Next ||
cursorEntryIdBefore is null)
{
return false;
}
var currentEntryId = workflow.State.CurrentEntryId;
var consumedPlaylistRow = !string.Equals(
cursorEntryIdBefore,
currentEntryId,
StringComparison.Ordinal);
var consumedPage =
string.Equals(
cursorEntryIdBefore,
currentEntryId,
StringComparison.Ordinal) &&
cursorPageIndexBefore != workflow.State.PageIndexZeroBased;
if (!consumedPlaylistRow && !consumedPage)
{
return false;
}
if (consumedPage)
{
_controller.ReflectSuccessfulPlayout(
currentEntryId!,
workflow.State.PageIndexZeroBased,
workflow.State.PageCount,
synchronizeOperatorSelection: false);
}
snapshot = _controller.ReportInformation(
"현재 컷은 송출할 수 없어 송출하지 않았습니다.");
return true;
}
private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(
LegacyPlayoutWorkflow workflow,
CancellationToken cancellationToken)
@@ -325,51 +649,46 @@ public sealed partial class MainWindow
var fadeDuration = _compositionOptions!.Current.FadeDuration;
var request = _controller.CreatePlayoutRequest(fadeDuration);
// The first PREPARE/TAKE IN freezes the complete playlist. Resolve every
// operator PageN row from a fresh read-only DB preflight before that snapshot
// is handed to the workflow. A later F8 while PREPARED/PROGRAM reuses the
// already-frozen 1/N values and must not query a second navigation contract.
// MainForm built only the selected row (or the first enabled row after it).
// Refresh that one PageN contract before the first PREPARE/TAKE IN; unrelated
// stale rows in a saved legacy schedule must not block the selected cue.
if (workflow.State.CurrentEntryId is not null)
{
return request;
}
var pagedEntries = request.Playlist
.Where(static entry => entry.PageNavigation?.PageSize.HasValue == true)
.ToArray();
if (pagedEntries.Length == 0)
var targetEntry = request.Playlist
.Skip(Math.Max(0, request.SelectedIndexZeroBased))
.FirstOrDefault(static entry => entry.IsEnabled);
if (targetEntry?.PageNavigation?.PageSize is not { } expectedPageSize)
{
return request;
}
var plans = await workflow.PreflightPagePlansAsync(
Array.AsReadOnly(pagedEntries),
Array.AsReadOnly(new[] { targetEntry }),
cancellationToken).ConfigureAwait(false);
if (plans.Count != pagedEntries.Length)
if (plans.Count != 1)
{
throw new LegacySceneDataException(
"The playlist page preflight did not return every PageN entry.");
"The playlist page preflight did not return the requested PageN entry.");
}
foreach (var plan in plans)
var plan = plans[0];
if (!string.Equals(plan.EntryId, targetEntry.EntryId, StringComparison.Ordinal) ||
plan.PageSize != (int)expectedPageSize ||
plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
{
var entry = pagedEntries.SingleOrDefault(candidate =>
string.Equals(candidate.EntryId, plan.EntryId, StringComparison.Ordinal));
if (entry?.PageNavigation?.PageSize is not { } expectedPageSize ||
plan.PageSize != (int)expectedPageSize ||
plan.PageCount is < 1 or > ScenePaging.MaximumPageCount)
{
throw new LegacySceneDataException(
"The playlist page preflight does not match the operator PageN row.");
}
_controller.ReflectSuccessfulPlayout(
plan.EntryId,
pageIndexZeroBased: 0,
pageCount: plan.PageCount,
synchronizeOperatorSelection: false);
throw new LegacySceneDataException(
"The playlist page preflight does not match the operator PageN row.");
}
_controller.ReflectSuccessfulPlayout(
plan.EntryId,
pageIndexZeroBased: 0,
pageCount: plan.PageCount,
synchronizeOperatorSelection: false);
return _controller.CreatePlayoutRequest(fadeDuration);
}

View File

@@ -5192,7 +5192,10 @@
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.connectionState === "connecting" ||
playout.connectionState === "reconnecting") return "연결 확인 중";
if (playout.connectionState === "faulted") return "연결 확인 실패";
if (playout.isConnected === true) return "K3D 송출 연결됨";
if (playout.isProcessRunning === true) return "Tornado2 감지됨";
return "미연결";
}

View File

@@ -223,7 +223,7 @@
</div>
<div class="playout-connection-summary">
<div class="connection-state" id="playout-connection-state" role="status"
aria-label="Tornado2 연결 상태" aria-live="polite"
aria-label="송출 연결 상태" aria-live="polite"
aria-atomic="true">미연결</div>
</div>
</header>

View File

@@ -49,7 +49,10 @@
if (playout.outcomeUnknown) return "결과 불명확";
if (playout.mode === "disabled") return "송출 비활성";
if (playout.mode === "dryRun") return "DRY RUN";
if (playout.isConnected) return "Tornado2 연결됨";
if (playout.connectionState === "connecting" ||
playout.connectionState === "reconnecting") return "연결 확인 중";
if (playout.connectionState === "faulted") return "연결 확인 실패";
if (playout.isConnected) return "K3D 송출 연결됨";
if (playout.isProcessRunning) return "Tornado2 감지됨";
return "미연결";
}

View File

@@ -215,7 +215,7 @@ public static class PlayoutOptionsLoader
options.ReconnectDelayMilliseconds != 1_000 ||
options.ReconnectEnabled ||
options.MaximumReconnectAttempts != 0 ||
options.MaximumAutomaticRefreshesPerTakeIn != 0)
options.MaximumAutomaticRefreshesPerTakeIn is not null)
{
throw new PlayoutConfigurationException(
"Development Live requires the exact protected local base configuration.");
@@ -312,7 +312,7 @@ public static class PlayoutOptionsLoader
!IsExactInteger(root, "reconnectDelayMilliseconds", 1_000) ||
!IsExactInteger(root, "maximumReconnectAttempts", 0) ||
!IsExactBoolean(root, "reconnectEnabled", false) ||
!IsExactInteger(root, "maximumAutomaticRefreshesPerTakeIn", 0))
!IsNull(root, "maximumAutomaticRefreshesPerTakeIn"))
{
throw new InvalidDataException();
}

View File

@@ -20,6 +20,7 @@ internal sealed record ValidatedPlayoutOptions(
Regex? TestProcessWindowTitleRegex,
IReadOnlySet<string> TestSceneAllowlist,
bool TrustedLiveOutputEnabled,
bool DevelopmentLiveBootstrapApplied,
int QueueCapacity,
TimeSpan ConnectTimeout,
TimeSpan OperationTimeout,
@@ -165,7 +166,7 @@ internal sealed record ValidatedPlayoutOptions(
!string.IsNullOrWhiteSpace(options.LegacyBackgroundDirectory) ||
options.ReconnectEnabled ||
options.MaximumReconnectAttempts != 0 ||
options.MaximumAutomaticRefreshesPerTakeIn != 0)
options.MaximumAutomaticRefreshesPerTakeIn is not null)
{
throw new PlayoutConfigurationException(
"Development Live configuration no longer matches its protected startup contract.");
@@ -223,6 +224,7 @@ internal sealed record ValidatedPlayoutOptions(
titleRegex,
allowlist,
options.TrustedLiveOutputEnabled,
options.DevelopmentLiveBootstrapApplied,
options.QueueCapacity,
TimeSpan.FromMilliseconds(options.ConnectTimeoutMilliseconds),
TimeSpan.FromMilliseconds(options.OperationTimeoutMilliseconds),

View File

@@ -35,6 +35,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private TornadoProcessSnapshot _processSnapshot = new(0, 0, 0);
private PlayoutConnectionState _connectionState;
private DateTimeOffset _nextReconnectAt;
private DateTimeOffset? _ktapHelloConfirmationDeadlineUtc;
private int _reconnectAttempts;
private long _sequence;
private int _lastKtapConnectState;
@@ -287,22 +288,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
if (!eligible)
{
_reconnectAttempts = 0;
if (_onAirSceneName is not null)
if (_session is not null || _onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
if (_session is not null && !_outcomeUnknown)
{
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
}
_preparedCue = null;
_onAirSceneName = null;
ScheduleReconnect();
@@ -316,25 +308,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
if (HasConnectedProcessGenerationChanged(_processSnapshot))
{
_reconnectAttempts = 0;
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
_preparedCue = null;
_onAirSceneName = null;
ScheduleReconnect();
_connectionState = _connectionRequested
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
await AbandonCurrentSessionAsync().ConfigureAwait(false);
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
return;
}
if (_session is not null &&
@@ -482,23 +458,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
_processSnapshot = CaptureProcessSnapshot();
if (!IsProcessEligible(_processSnapshot))
{
if (_onAirSceneName is not null)
if (_session is not null || _onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
if (_session is not null)
{
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
_preparedCue = null;
_onAirSceneName = null;
}
_connectionState = reconnecting
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
@@ -511,26 +476,21 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
if (_session is not null && !HasConnectedProcessGenerationChanged(_processSnapshot))
{
_connectionState = PlayoutConnectionState.Connected;
PublishStatus("Tornado 테스트 송출에 연결되었습니다.");
return Result(PlayoutOperation.Connect, PlayoutResultCode.Success, "송출 연결이 준비되었습니다.");
UpdateKtapHelloConfirmationState(_session, initializeDeadline: false);
var existingSessionMessage = CurrentMessage();
PublishStatus(existingSessionMessage);
return Result(
PlayoutOperation.Connect,
IsKtapCommandChannelConfirmed(_session)
? PlayoutResultCode.Success
: PlayoutResultCode.Unavailable,
existingSessionMessage);
}
if (_session is not null)
{
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
_preparedCue = null;
_onAirSceneName = null;
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
var processSnapshotBeforeConnect = _processSnapshot;
@@ -557,6 +517,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return Result(PlayoutOperation.Connect, PlayoutResultCode.Unavailable, registration.Message);
}
ResetKtapEvidenceForNewSessionAttempt();
_connectionState = reconnecting
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Connecting;
@@ -627,41 +588,23 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
if (!IsProcessEligible(_processSnapshot) ||
!processSnapshotBeforeConnect.HasSameProcessGeneration(_processSnapshot))
{
if (_abandonOnTargetMismatch)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
if (!await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false))
{
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
}
ScheduleReconnect();
_connectionState = reconnecting
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
var processMessage = IsProcessEligible(_processSnapshot)
? ProcessChangedMessage
: ProcessUnavailableMessage(_processSnapshot);
PublishStatus(processMessage);
return Result(
PlayoutOperation.Connect,
PlayoutResultCode.Unavailable,
processMessage);
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
_connectedProcessSnapshot = _processSnapshot;
_connectionState = PlayoutConnectionState.Connected;
_reconnectAttempts = 0;
_preparedCue = null;
_onAirSceneName = null;
PublishStatus("KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다.");
UpdateKtapHelloConfirmationState(_session, initializeDeadline: true);
var connectionMessage = CurrentMessage();
PublishStatus(connectionMessage);
return Result(
PlayoutOperation.Connect,
PlayoutResultCode.Success,
"KTAPConnect 요청이 수락되었습니다. 서버 콜백은 운영자 확인이 필요합니다.");
IsKtapCommandChannelConfirmed(_session)
? PlayoutResultCode.Success
: PlayoutResultCode.Unavailable,
connectionMessage);
}
catch (OperationCanceledException)
{
@@ -904,7 +847,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_onAirSceneName = SafeSceneName(prepared);
_preparedCue = null;
PublishStatus("장면이 송출되었습니다.");
var message = _session?.HasPendingPlayCallbacks == true
? PendingPlayCompletionMessage
: "장면이 송출되었습니다.";
PublishStatus(message);
return result with { Message = message };
}
return result;
@@ -979,7 +926,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_onAirSceneName = SafeSceneName(resolved);
_preparedCue = null;
PublishStatus("다음 장면이 송출되었습니다.");
var message = _session?.HasPendingPlayCallbacks == true
? PendingPlayCompletionMessage
: "다음 장면이 송출되었습니다.";
PublishStatus(message);
return result with { Message = message };
}
return result;
@@ -1081,7 +1032,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_onAirSceneName = SafeSceneName(resolved);
_preparedCue = null;
PublishStatus("The on-air scene page was updated.");
var message = _session?.HasPendingPlayCallbacks == true
? PendingPlayCompletionMessage
: "The on-air scene page was updated.";
PublishStatus(message);
return result with { Message = message };
}
return result;
@@ -1131,11 +1086,19 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
cancellationToken).ConfigureAwait(false);
if (result.IsSuccess)
{
_preparedCue = null;
_onAirSceneName = null;
PublishStatus(_session?.HasPendingLifecycleCallbacks == true
? "TAKE OUT 명령이 수락되어 Tornado 완료 이벤트를 기다리고 있습니다."
: "장면 송출이 종료되었습니다.");
var takeOutCompletionPending =
_session?.HasPendingTakeOutCallbacks == true;
if (!takeOutCompletionPending)
{
_preparedCue = null;
_onAirSceneName = null;
}
var message = takeOutCompletionPending
? PendingTakeOutCompletionMessage
: "장면 송출이 종료되었습니다.";
PublishStatus(message);
return result with { Message = message };
}
return result;
@@ -1153,26 +1116,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
_processSnapshot = CaptureProcessSnapshot();
if (!IsProcessEligible(_processSnapshot))
{
if (_abandonOnTargetMismatch && _session is not null)
if (_session is not null || _onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
if (_session is not null)
{
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(operation);
}
}
_preparedCue = null;
_onAirSceneName = null;
_connectionState = PlayoutConnectionState.Reconnecting;
@@ -1182,34 +1131,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
if (HasConnectedProcessGenerationChanged(_processSnapshot))
{
if (_abandonOnTargetMismatch)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
if (_onAirSceneName is not null)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
{
return MarkOutcomeUnknown(operation);
}
_preparedCue = null;
_onAirSceneName = null;
ScheduleReconnect();
_connectionState = _connectionRequested
? PlayoutConnectionState.Reconnecting
: PlayoutConnectionState.Disconnected;
PublishStatus(ProcessChangedMessage);
return Result(
operation,
PlayoutResultCode.Unavailable,
ProcessChangedMessage);
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return MarkOutcomeUnknown(operation);
}
if (_session is null)
@@ -1222,6 +1145,17 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return MarkOutcomeUnknown(operation);
}
if (!IsKtapCommandChannelConfirmed(_session))
{
UpdateKtapHelloConfirmationState(_session!, initializeDeadline: false);
var confirmationMessage = CurrentMessage();
PublishStatus(confirmationMessage);
return Result(
operation,
PlayoutResultCode.Unavailable,
confirmationMessage);
}
if (_session?.HasPendingTakeOutCallbacks == true)
{
const string pendingTakeOutMessage =
@@ -1271,6 +1205,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return false;
}
var hadPendingTakeOutCallbacks = session.HasPendingTakeOutCallbacks;
try
{
await _dispatcher.InvokeAsync(
@@ -1280,6 +1215,17 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
_ => AbandonSessionInline(session),
() => AbandonSessionInline(session)).ConfigureAwait(false);
CaptureKtapEvidence(session);
UpdateKtapHelloConfirmationState(session, initializeDeadline: false);
if (hadPendingTakeOutCallbacks &&
!session.HasPendingTakeOutCallbacks)
{
// The SDK command was only accepted until OnCutOut/OnStopAll
// arrived. Keep PREPARED/PROGRAM truthful during that interval,
// then clear it exactly once the callback confirms completion.
_preparedCue = null;
_onAirSceneName = null;
}
return true;
}
catch
@@ -1430,6 +1376,16 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
return false;
}
if (_session is not null &&
!IsKtapCommandChannelConfirmed(_session))
{
// KTAPConnect already crossed the SDK boundary, but Tornado has not
// confirmed OnHello. Disconnect would be a speculative inverse command;
// abandon the local COM session without sending anything to the target.
await AbandonCurrentSessionAsync().ConfigureAwait(false);
return false;
}
if (_session is not null && !_launchAuthorization.AllowsSdkDisconnect)
{
await AbandonCurrentSessionAsync().ConfigureAwait(false);
@@ -1776,15 +1732,34 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
snapshot.TotalProcessCount == 1 &&
snapshot.EligibleProcessCount == 1 &&
snapshot.ProgramProcessCount == 0,
PlayoutMode.Live => snapshot.IdentityInspectionSucceeded && snapshot.AnyTornadoProcess,
PlayoutMode.Live =>
snapshot.IdentityInspectionSucceeded &&
(_options.DevelopmentLiveBootstrapApplied
? IsDevelopmentLiveProcessEligible(snapshot)
: snapshot.AnyTornadoProcess),
_ => false
};
private bool IsDevelopmentLiveProcessEligible(TornadoProcessSnapshot snapshot) =>
HasExactDevelopmentLoopbackEndpoint() &&
(snapshot.TotalProcessCount == 0 ||
(snapshot.TotalProcessCount == 1 &&
snapshot.EligibleProcessCount == 1 &&
snapshot.ProgramProcessCount == 1));
private bool HasConnectedProcessGenerationChanged(TornadoProcessSnapshot snapshot) =>
_session is not null &&
(_connectedProcessSnapshot is null ||
!_connectedProcessSnapshot.HasSameProcessGeneration(snapshot));
private bool HasExactDevelopmentLoopbackEndpoint() =>
_options.DevelopmentLiveBootstrapApplied &&
_options.Mode == PlayoutMode.Live &&
string.Equals(_options.Host, "127.0.0.1", StringComparison.Ordinal) &&
_options.Port == 30001 &&
_options.TcpMode == 1 &&
_options.ClientPort == 0;
private bool CanTakeIn() => _options.Mode switch
{
PlayoutMode.Test => true,
@@ -1851,7 +1826,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private PlayoutStatus BuildStatus(string message)
{
var connected = _session is not null && !_outcomeUnknown;
var connected =
IsKtapCommandChannelConfirmed(_session) &&
!_outcomeUnknown;
var commandAvailable = (_options.Mode == PlayoutMode.DryRun ||
(connected && IsProcessEligible(_processSnapshot))) &&
_session?.HasPendingTakeOutCallbacks != true;
@@ -1900,12 +1877,66 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
if (session.SupportsLifecycleCallbacks)
{
Volatile.Write(
ref _ktapHelloObservedState,
session.KtapHelloObserved == true ? 2 : 1);
if (session.KtapHelloObserved == true)
{
Volatile.Write(ref _ktapHelloObservedState, 2);
}
}
}
private void ResetKtapEvidenceForNewSessionAttempt()
{
Volatile.Write(
ref _lastKtapConnectState,
(int)PlayoutKtapConnectState.NotAttempted);
Volatile.Write(ref _ktapHelloObservedState, 0);
_ktapHelloConfirmationDeadlineUtc = null;
}
private static bool IsKtapCommandChannelConfirmed(IK3dSession? session) =>
session is not null &&
(!session.SupportsLifecycleCallbacks || session.KtapHelloObserved == true);
private void UpdateKtapHelloConfirmationState(
IK3dSession session,
bool initializeDeadline)
{
if (initializeDeadline)
{
// Hello evidence belongs to one SDK session. A confirmed previous
// session must not make a newly accepted-but-unconfirmed session look
// confirmed in status or diagnostics.
Volatile.Write(ref _ktapHelloObservedState, 0);
}
CaptureKtapEvidence(session);
if (!session.SupportsLifecycleCallbacks ||
session.KtapHelloObserved == true)
{
_ktapHelloConfirmationDeadlineUtc = null;
_connectionState = PlayoutConnectionState.Connected;
return;
}
if (initializeDeadline || _ktapHelloConfirmationDeadlineUtc is null)
{
_ktapHelloConfirmationDeadlineUtc =
_timeProvider.GetUtcNow() + _options.ConnectTimeout;
}
if (_timeProvider.GetUtcNow() >= _ktapHelloConfirmationDeadlineUtc)
{
// KTAPConnect already crossed the SDK boundary. A missing OnHello is
// fail-closed evidence, not permission to disconnect or retry an
// AcceptedUnconfirmed request whose server-side state is unknown.
Volatile.Write(ref _ktapHelloObservedState, 1);
_connectionState = PlayoutConnectionState.Faulted;
return;
}
_connectionState = PlayoutConnectionState.Connecting;
}
private void PreventLateKtapDispatchAndCapture(IK3dSession? session)
{
if (session is null)
@@ -1956,14 +1987,45 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
string.Equals(sceneName, _onAirSceneName, StringComparison.OrdinalIgnoreCase);
}
private string CurrentMessage() => _connectionState switch
private string CurrentMessage()
{
PlayoutConnectionState.Connected =>
"KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다.",
PlayoutConnectionState.Reconnecting => "Tornado 송출에 다시 연결하는 중입니다.",
PlayoutConnectionState.Faulted => "Tornado 송출 연결을 확인하세요.",
_ => InitialMessage(_options.Mode)
};
if (_session?.HasPendingTakeOutCallbacks == true)
{
return PendingTakeOutCompletionMessage;
}
if (_session?.HasPendingPlayCallbacks == true)
{
return PendingPlayCompletionMessage;
}
return _connectionState switch
{
PlayoutConnectionState.Connected =>
"Tornado 송출 연결 응답(OnHello)이 확인되었습니다.",
PlayoutConnectionState.Connecting or PlayoutConnectionState.Reconnecting =>
KtapHelloConfirmationPendingMessage,
PlayoutConnectionState.Faulted
when _session?.SupportsLifecycleCallbacks == true &&
_session.KtapHelloObserved != true =>
KtapHelloConfirmationTimedOutMessage,
PlayoutConnectionState.Faulted => "Tornado 송출 연결을 확인하세요.",
_ => InitialMessage(_options.Mode)
};
}
private const string KtapHelloConfirmationPendingMessage =
"KTAPConnect 요청이 수락되어 Tornado 연결 응답(OnHello)을 확인하는 중입니다.";
private const string KtapHelloConfirmationTimedOutMessage =
"Tornado 연결 응답(OnHello)이 확인되지 않아 송출 명령을 차단했습니다. " +
"PGM과 Network Monitoring을 확인한 뒤 앱을 다시 시작하세요.";
private const string PendingPlayCompletionMessage =
"송출 명령이 수락되어 Tornado 재생 완료 이벤트를 기다리고 있습니다.";
private const string PendingTakeOutCompletionMessage =
"TAKE OUT 명령이 수락되어 Tornado 완료 이벤트를 기다리고 있습니다.";
private static string InitialMessage(PlayoutMode mode) => mode switch
{
@@ -1980,6 +2042,28 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private string ProcessUnavailableMessage(TornadoProcessSnapshot snapshot)
{
if (_options.DevelopmentLiveBootstrapApplied)
{
if (!snapshot.IdentityInspectionSucceeded)
{
return "Tornado 프로세스·창 정보를 확인할 수 없어 Development Live 연결을 차단했습니다.";
}
if (snapshot.TotalProcessCount > 1)
{
return "Tornado 프로세스가 여러 개여서 Development Live 연결을 차단했습니다.";
}
if (snapshot.TotalProcessCount == 1 &&
(snapshot.EligibleProcessCount != 1 ||
snapshot.ProgramProcessCount != 1))
{
return "실행 중인 Tornado 창을 PGM/PROGRAM으로 식별할 수 없어 Development Live 연결을 차단했습니다.";
}
return "Development Live는 정확한 127.0.0.1:30001, TCP mode 1, client port 0 설정이 필요합니다.";
}
if (_options.Mode == PlayoutMode.Test)
{
if (snapshot.ProgramProcessCount > 0)

View File

@@ -219,7 +219,7 @@ test("workspace header keeps one concise title and the live native connection st
assert.doesNotMatch(markup, /class="playout-connection-copy"/);
assert.doesNotMatch(markup, /<strong>Tornado2<\/strong>/);
assert.match(markup,
/id="playout-connection-state"[^>]*role="status"[^>]*aria-label="Tornado2 연결 상태"[^>]*aria-live="polite"[^>]*aria-atomic="true">미연결<\/div>/);
/id="playout-connection-state"[^>]*role="status"[^>]*aria-label="송출 연결 상태"[^>]*aria-live="polite"[^>]*aria-atomic="true">미연결<\/div>/);
assert.match(styles,
/\.workspace-header::before,\s*\.playlist-heading::before\s*\{\s*display:\s*none;/);
assert.match(styles, /\.connection-state::before\s*\{/);
@@ -230,4 +230,20 @@ test("workspace header keeps one concise title and the live native connection st
/\.workspace-surface\s*\{[^}]*grid-template-rows:\s*66px minmax\(0, 1fr\)/);
assert.match(playout,
/const nextConnectionText = connectionLabel\(playout\);[\s\S]*?if \(connection\.textContent !== nextConnectionText\)[\s\S]*?connection\.textContent = nextConnectionText/);
assert.match(playout,
/playout\.connectionState === "connecting"[\s\S]*?playout\.connectionState === "reconnecting"[\s\S]*?return "연결 확인 중"/);
assert.match(playout,
/playout\.connectionState === "faulted"\) return "연결 확인 실패"/);
assert.match(playout,
/if \(playout\.isConnected\) return "K3D 송출 연결됨"/);
assert.doesNotMatch(playout,
/if \(playout\.isConnected\) return "Tornado2 연결됨"/);
assert.match(app,
/function operatorPlayoutConnectionLabel\(playout\)[\s\S]*?playout\.connectionState === "connecting"[\s\S]*?playout\.connectionState === "reconnecting"[\s\S]*?return "연결 확인 중"/);
assert.match(app,
/function operatorPlayoutConnectionLabel\(playout\)[\s\S]*?playout\.connectionState === "faulted"\) return "연결 확인 실패"/);
assert.match(app,
/if \(playout\.isConnected === true\) return "K3D 송출 연결됨"/);
assert.doesNotMatch(app,
/if \(playout\.isConnected === true\) return "Tornado2 연결됨"/);
});

View File

@@ -147,20 +147,233 @@ public sealed class LegacyPlayoutWorkflowTests
await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());
Assert.Equal(engineCallCount, engine.Calls.Count);
Assert.Equal(0, workflow.State.PageIndexZeroBased);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(2, workflow.State.PageCount);
Assert.True(workflow.State.IsLastPage);
Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
}
[Fact]
public async Task PageNext_DataFailureConsumesPageAndFollowingNextContinuesForward()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (11, ScenePageSize.Five)
});
provider.PageFailures[("5074", 1)] =
new LegacySceneDataException("page one is unavailable");
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"paged",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 3))
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
var engineCallCount = engine.Calls.Count;
await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());
Assert.Equal(engineCallCount, engine.Calls.Count);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.False(workflow.State.IsLastPage);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
provider.PageFailures.Remove(("5074", 1));
Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(2, workflow.State.PageIndexZeroBased);
Assert.True(workflow.State.IsLastPage);
Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
Assert.Equal(1, provider.Calls.Count(call => call == "5074:1"));
Assert.Equal("Next:5074:p2", engine.Calls[^1]);
}
[Fact]
public async Task PageNext_EngineRejectionConsumesPageAndFollowingNextContinuesForward()
{
var engine = new RecordingEngine();
engine.NextResults.Enqueue(PlayoutResultCode.Rejected);
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (11, ScenePageSize.Five)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"paged",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 3))
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
var rejected = await workflow.NextAsync();
Assert.Equal(PlayoutResultCode.Rejected, rejected.Code);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
Assert.Equal("Next:5074:p1", engine.Calls[^1]);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(2, workflow.State.PageIndexZeroBased);
Assert.Equal("Next:5074:p2", engine.Calls[^1]);
}
[Fact]
public async Task PlaylistNext_EngineRejectionConsumesRowAndFollowingNextContinuesForward()
{
var engine = new RecordingEngine();
engine.NextResults.Enqueue(PlayoutResultCode.Rejected);
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5088"] = (1, null),
["8061"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new("first", "5001"),
new("rejected", "5088"),
new("third", "8061")
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
var rejected = await workflow.NextAsync();
Assert.Equal(PlayoutResultCode.Rejected, rejected.Code);
Assert.Equal("rejected", workflow.State.CurrentEntryId);
Assert.Equal("first", workflow.State.OnAirEntryId);
Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);
Assert.Equal("Next:5088:p0", engine.Calls[^1]);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal("third", workflow.State.CurrentEntryId);
Assert.Equal("third", workflow.State.OnAirEntryId);
Assert.Equal("Next:8061:p0", engine.Calls[^1]);
}
[Fact]
public async Task LastPageDataFailureConsumesPageAndFollowingNextMovesToNextRow()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (6, ScenePageSize.Five),
["5001"] = (1, null)
});
provider.PageFailures[("5074", 1)] =
new LegacySceneDataException("last page is unavailable");
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"paged",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 2)),
new(
"ordinary",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled)
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.True(workflow.State.IsLastPage);
Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal("ordinary", workflow.State.CurrentEntryId);
Assert.Equal("ordinary", workflow.State.OnAirEntryId);
Assert.Equal("Next:5001:p0", engine.Calls[^1]);
Assert.Equal(1, provider.Calls.Count(call => call == "5074:1"));
}
[Fact]
public async Task PageNext_NonCancellationProviderFailureConsumesPage()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (11, ScenePageSize.Five)
});
provider.PageFailures[("5074", 1)] =
new InvalidOperationException("transient provider failure");
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"paged",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 3))
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
await Assert.ThrowsAsync<InvalidOperationException>(() => workflow.NextAsync());
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
provider.PageFailures.Remove(("5074", 1));
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(2, workflow.State.PageIndexZeroBased);
Assert.Equal(1, provider.Calls.Count(call => call == "5074:1"));
Assert.Equal("Next:5074:p2", engine.Calls[^1]);
}
[Fact]
public async Task PageNext_CancellationRestoresPageCursorForRetry()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (6, ScenePageSize.Five)
});
provider.PageFailures[("5074", 1)] =
new OperationCanceledException("page request canceled");
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"paged",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 2))
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => workflow.NextAsync());
Assert.Equal(0, workflow.State.PageIndexZeroBased);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
provider.PageFailures.Remove(("5074", 1));
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(2, provider.Calls.Count(call => call == "5074:1"));
}
[Fact]
public async Task ExplicitlyNonPagedOperatorEntryMovesToNextPlaylistRowDespitePagedProvider()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
var definitions = new Dictionary<string, (int, ScenePageSize?)>
{
["5074"] = (11, ScenePageSize.Five),
["5074"] = (6, ScenePageSize.Five),
["5001"] = (1, null)
});
};
var provider = new RecordingProvider(definitions);
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
@@ -356,6 +569,381 @@ public sealed class LegacyPlayoutWorkflowTests
Assert.Equal("Next:5088:p0", engine.Calls[^1]);
}
[Fact]
public async Task Next_WhenPagedMiddleTargetHasNoRows_ConsumesOneNextAndFollowingNextPlaysThirdCue()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (0, ScenePageSize.Five),
["5088"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"first",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled),
new(
"empty-paged",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 1)),
new(
"third",
"5088",
PageNavigation: LegacyPlaylistPageNavigation.Disabled)
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
provider.PlanCalls.Clear();
await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());
Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal("empty-paged", workflow.State.CurrentEntryId);
Assert.Equal(0, workflow.State.OnAirCueIndexZeroBased);
Assert.Equal("first", workflow.State.OnAirEntryId);
Assert.Equal("5001", workflow.State.OnAirCutCode);
Assert.True(workflow.State.IsNextCursorPageNavigationEnabled);
Assert.Equal(["5074"], provider.PlanCalls);
Assert.DoesNotContain(engine.Calls, call => call.StartsWith("Next:", StringComparison.Ordinal));
var result = await workflow.NextAsync();
Assert.True(result.IsSuccess);
Assert.Equal(2, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal("third", workflow.State.CurrentEntryId);
Assert.Equal(2, workflow.State.OnAirCueIndexZeroBased);
Assert.Equal("third", workflow.State.OnAirEntryId);
Assert.Equal("5088", workflow.State.OnAirCutCode);
Assert.False(workflow.State.IsNextCursorPageNavigationEnabled);
Assert.Equal(["5074"], provider.PlanCalls);
Assert.Equal("Next:5088:p0", engine.Calls[^1]);
}
[Fact]
public async Task Next_WhenPagedTargetIsValid_PreflightsOnlyThatTargetAndFreezesTheLatestPageCount()
{
var definitions = new Dictionary<string, (int Count, ScenePageSize? Size)>
{
["5001"] = (1, null),
["5074"] = (6, ScenePageSize.Five),
["5088"] = (25, ScenePageSize.Twelve)
};
var engine = new RecordingEngine();
var provider = new RecordingProvider(definitions);
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"first",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled),
new(
"paged-target",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 1)),
new(
"untouched-future",
"5088",
PageNavigation: new(ScenePageSize.Twelve, FrozenPageCount: 1))
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
provider.PlanCalls.Clear();
definitions["5074"] = (11, ScenePageSize.Five);
var result = await workflow.NextAsync();
Assert.True(result.IsSuccess);
Assert.Equal(["5074"], provider.PlanCalls);
Assert.Equal("paged-target", workflow.State.CurrentEntryId);
Assert.Equal("5074", workflow.State.OnAirCutCode);
Assert.Equal(0, workflow.State.PageIndexZeroBased);
Assert.Equal(3, workflow.State.PageCount);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
Assert.Equal("Next:5074:p0", engine.Calls[^1]);
}
[Fact]
public async Task Next_WhenPagedTargetEngineFails_DoesNotRetainTheRefreshedNavigationForRetry()
{
var engine = new RecordingEngine();
engine.NextResults.Enqueue(PlayoutResultCode.Failed);
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (11, ScenePageSize.Five)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"first",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled),
new(
"paged-target",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 1))
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
provider.PlanCalls.Clear();
provider.PlanInputFrozenPageCounts.Clear();
var failed = await workflow.NextAsync();
Assert.Equal(PlayoutResultCode.Failed, failed.Code);
Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal("first", workflow.State.CurrentEntryId);
Assert.Equal([1], provider.PlanInputFrozenPageCounts);
var retry = await workflow.NextAsync();
Assert.True(retry.IsSuccess);
Assert.Equal(["5074", "5074"], provider.PlanCalls);
Assert.Equal([1, 1], provider.PlanInputFrozenPageCounts);
Assert.Equal(1, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal("paged-target", workflow.State.CurrentEntryId);
Assert.Equal(3, workflow.State.PageCount);
Assert.Equal("Next:5074:p0", engine.Calls[^1]);
}
[Fact]
public async Task PageNext_AfterTargetPreflightReusesTheFrozenPlanWithoutAnotherPlanQuery()
{
var definitions = new Dictionary<string, (int Count, ScenePageSize? Size)>
{
["5001"] = (1, null),
["5074"] = (11, ScenePageSize.Five)
};
var engine = new RecordingEngine();
var provider = new RecordingProvider(definitions);
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"first",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled),
new(
"paged-target",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 1))
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
provider.PlanCalls.Clear();
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(["5074"], provider.PlanCalls);
Assert.Equal(3, workflow.State.PageCount);
definitions["5074"] = (16, ScenePageSize.Five);
var result = await workflow.NextAsync();
Assert.True(result.IsSuccess);
Assert.Equal(["5074"], provider.PlanCalls);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal(3, workflow.State.PageCount);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
Assert.Equal("Next:5074:p1", engine.Calls[^1]);
}
[Fact]
public async Task TakeSelectedWhileProgramRefreshesTheSelectedPagePlan()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (11, ScenePageSize.Five)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"first",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled),
new(
"selected-paged",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 1))
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
provider.PlanCalls.Clear();
var result = await workflow.TakeSelectedAsync(playlist, 1);
Assert.True(result.IsSuccess);
Assert.Equal(["5074"], provider.PlanCalls);
Assert.Equal("selected-paged", workflow.State.CurrentEntryId);
Assert.Equal("selected-paged", workflow.State.OnAirEntryId);
Assert.Equal(0, workflow.State.PageIndexZeroBased);
Assert.Equal(3, workflow.State.PageCount);
Assert.Equal(LegacyWorkflowNextKind.PageNext, workflow.State.NextKind);
}
[Fact]
public async Task ProviderDerivedPagePlanIsFrozenBeforeAdoptingTheLivePrefix()
{
var engine = new RecordingEngine();
var definitions = new Dictionary<string, (int Count, ScenePageSize? Size)>
{
["5074"] = (6, ScenePageSize.Five),
["5001"] = (1, null)
};
var provider = new RecordingProvider(definitions);
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] loadedPlaylist =
[
new("recovered-paged", "5074", PageNavigation: null),
new(
"ordinary",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled)
];
Assert.True((await workflow.TakeSelectedAsync(loadedPlaylist, 0)).IsSuccess);
Assert.Equal(2, workflow.State.PageCount);
LegacyPlaylistEntry[] livePlaylist =
[
loadedPlaylist[0] with
{
PageNavigation = new LegacyPlaylistPageNavigation(
ScenePageSize.Five,
FrozenPageCount: 2)
},
loadedPlaylist[1]
];
definitions["5074"] = (11, ScenePageSize.Five);
Assert.True((await workflow.RefreshOnAirAsync()).IsSuccess);
Assert.Equal(3, workflow.State.PageCount);
var result = await workflow.NextAsync(livePlaylist);
Assert.True(result.IsSuccess);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.Equal("Next:5074:p1", engine.Calls[^1]);
var updatedLivePlaylist = livePlaylist.ToArray();
updatedLivePlaylist[0] = updatedLivePlaylist[0] with
{
PageNavigation = new LegacyPlaylistPageNavigation(
ScenePageSize.Five,
FrozenPageCount: 3)
};
Assert.True((await workflow.NextAsync(updatedLivePlaylist)).IsSuccess);
Assert.Equal(2, workflow.State.PageIndexZeroBased);
Assert.Equal("Next:5074:p2", engine.Calls[^1]);
}
[Fact]
public async Task RecoveredPagedEntryKeepsPagedRefreshPolicyThroughItsLastPage()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int Count, ScenePageSize? Size)>
{
["5074"] = (6, ScenePageSize.Five),
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] recoveredPlaylist =
[
new("recovered-1-over-0", "5074", PageNavigation: null),
new(
"ordinary",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled)
];
Assert.True((await workflow.TakeSelectedAsync(recoveredPlaylist, 0)).IsSuccess);
Assert.True(workflow.State.IsPageNavigationEnabled);
Assert.True(workflow.State.IsNextCursorPageNavigationEnabled);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.True(workflow.State.IsLastPage);
Assert.Equal(LegacyWorkflowNextKind.PlaylistNext, workflow.State.NextKind);
Assert.True(workflow.State.IsPageNavigationEnabled);
Assert.True(workflow.State.IsNextCursorPageNavigationEnabled);
Assert.True((await workflow.NextAsync()).IsSuccess);
Assert.Equal("ordinary", workflow.State.CurrentEntryId);
Assert.False(workflow.State.IsPageNavigationEnabled);
Assert.False(workflow.State.IsNextCursorPageNavigationEnabled);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Next_WhenPagedTargetPlanFails_RestoresOnlyCancellation(
bool operationCanceled)
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null),
["5074"] = (11, ScenePageSize.Five),
["8061"] = (1, null)
});
provider.PagePlanFailures["5074"] = operationCanceled
? new OperationCanceledException("The test page plan was canceled.")
: new InvalidOperationException("The test page plan failed unexpectedly.");
var workflow = new LegacyPlayoutWorkflow(engine, provider);
LegacyPlaylistEntry[] playlist =
[
new(
"first",
"5001",
PageNavigation: LegacyPlaylistPageNavigation.Disabled),
new(
"paged-target",
"5074",
PageNavigation: new(ScenePageSize.Five, FrozenPageCount: 1)),
new(
"third",
"8061",
PageNavigation: LegacyPlaylistPageNavigation.Disabled)
];
Assert.True((await workflow.TakeSelectedAsync(playlist, 0)).IsSuccess);
provider.PlanCalls.Clear();
if (operationCanceled)
{
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => workflow.NextAsync());
}
else
{
await Assert.ThrowsAsync<InvalidOperationException>(() => workflow.NextAsync());
}
Assert.Equal(operationCanceled ? 0 : 1, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal(
operationCanceled ? "first" : "paged-target",
workflow.State.CurrentEntryId);
Assert.Equal(0, workflow.State.OnAirCueIndexZeroBased);
Assert.Equal("first", workflow.State.OnAirEntryId);
Assert.Equal("5001", workflow.State.OnAirCutCode);
Assert.Equal(["5074"], provider.PlanCalls);
Assert.DoesNotContain(engine.Calls, call => call.StartsWith("Next:", StringComparison.Ordinal));
provider.PagePlanFailures.Remove("5074");
var retry = await workflow.NextAsync();
Assert.True(retry.IsSuccess);
Assert.Equal(
operationCanceled ? new[] { "5074", "5074" } : new[] { "5074" },
provider.PlanCalls);
Assert.Equal(operationCanceled ? 1 : 2, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal(
operationCanceled ? "paged-target" : "third",
workflow.State.CurrentEntryId);
Assert.Equal(
operationCanceled ? "Next:5074:p0" : "Next:8061:p0",
engine.Calls[^1]);
}
[Fact]
public async Task Next_WhenOutcomeIsUnknown_DoesNotAdvanceTheProgressCursor()
{
@@ -553,7 +1141,9 @@ public sealed class LegacyPlayoutWorkflowTests
await Assert.ThrowsAsync<LegacySceneDataException>(() => workflow.NextAsync());
Assert.DoesNotContain(engine.Calls, call => call == "Next:other:p1");
Assert.Equal(0, workflow.State.PageIndexZeroBased);
Assert.Equal(1, workflow.State.PageIndexZeroBased);
Assert.True(workflow.State.IsLastPage);
Assert.Equal(LegacyWorkflowNextKind.EndOfPlaylist, workflow.State.NextKind);
}
[Fact]
@@ -576,6 +1166,42 @@ public sealed class LegacyPlayoutWorkflowTests
Assert.Equal("TakeOut:All", engine.Calls[^1]);
}
[Fact]
public async Task TakeOut_PendingCompletionRetainsTruthfulProgramStateUntilEngineClearsIt()
{
var engine = new RecordingEngine();
var provider = new RecordingProvider(new Dictionary<string, (int, ScenePageSize?)>
{
["5001"] = (1, null)
});
var workflow = new LegacyPlayoutWorkflow(engine, provider);
Assert.True((await workflow.PrepareAsync([new("one", "5001")], 0)).IsSuccess);
Assert.True((await workflow.TakeInAsync()).IsSuccess);
engine.Status = engine.Status with
{
IsTakeOutCompletionPending = true,
OnAirSceneName = "5001",
Sequence = 1
};
Assert.True((await workflow.TakeOutAsync(PlayoutTakeOutScope.All)).IsSuccess);
Assert.Equal("5001", workflow.State.OnAirCutCode);
Assert.Equal("one", workflow.State.OnAirEntryId);
Assert.Equal(0, workflow.State.CurrentCueIndexZeroBased);
workflow.ObserveEngineStatus(engine.Status with
{
IsTakeOutCompletionPending = false,
OnAirSceneName = null,
Sequence = 2
});
Assert.Null(workflow.State.OnAirCutCode);
Assert.Equal(-1, workflow.State.CurrentCueIndexZeroBased);
Assert.Equal(LegacyWorkflowNextKind.None, workflow.State.NextKind);
}
[Fact]
public async Task Prepare_SkipsDisabledRowsLikeTheLegacyMainForm()
{
@@ -1198,10 +1824,18 @@ public sealed class LegacyPlayoutWorkflowTests
public List<string> PlanCalls { get; } = [];
public List<int?> PlanInputFrozenPageCounts { get; } = [];
public List<string?> SelectionGraphicTypes { get; } = [];
public HashSet<string> UnplayableCutCodes { get; } = new(StringComparer.Ordinal);
public Dictionary<(string CutCode, int PageIndex), Exception> PageFailures { get; } =
[];
public Dictionary<string, Exception> PagePlanFailures { get; } =
new(StringComparer.Ordinal);
public Func<string, int, string>? SceneNameOverride { get; init; }
public string? BuilderKey { get; init; }
@@ -1216,6 +1850,13 @@ public sealed class LegacyPlayoutWorkflowTests
cancellationToken.ThrowIfCancellationRequested();
Calls.Add($"{entry.CutCode}:{pageIndexZeroBased}");
SelectionGraphicTypes.Add(entry.Selection?.GraphicType);
if (PageFailures.TryGetValue(
(entry.CutCode, pageIndexZeroBased),
out var pageFailure))
{
throw pageFailure;
}
if (UnplayableCutCodes.Contains(entry.CutCode))
{
throw new LegacySceneDataException("The selected playlist row is not playable.");
@@ -1240,6 +1881,12 @@ public sealed class LegacyPlayoutWorkflowTests
{
cancellationToken.ThrowIfCancellationRequested();
PlanCalls.Add(entry.CutCode);
PlanInputFrozenPageCounts.Add(entry.PageNavigation?.FrozenPageCount);
if (PagePlanFailures.TryGetValue(entry.CutCode, out var failure))
{
throw failure;
}
var definition = definitions[entry.CutCode];
var pageSize = definition.Size ?? throw new LegacySceneDataException(
"The test entry is not paged.");

View File

@@ -1,9 +1,104 @@
using MMoneyCoderSharp.Data;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyStockCutInteractionParityTests
{
public static TheoryData<
LegacyDomesticStockMarket,
int,
string,
string> DomesticStockCandleCases
{
get
{
var cases = new TheoryData<
LegacyDomesticStockMarket,
int,
string,
string>();
var cuts = new (int PhysicalIndex, string Alias, string Mode)[]
{
(14, "8035", "PRICE"),
(15, "8061", "PRICE"),
(16, "8040", "PRICE"),
(17, "8046", "PRICE"),
(18, "8051", "PRICE"),
(19, "8056", "PRICE"),
(20, "8035", "VOLUME"),
(21, "8061", "VOLUME"),
(22, "8040", "VOLUME"),
(23, "8046", "VOLUME"),
(24, "8051", "VOLUME"),
(25, "8056", "VOLUME"),
(26, "8061", "EXPECTED"),
(27, "8040", "EXPECTED"),
(28, "8046", "EXPECTED"),
(29, "8051", "EXPECTED"),
(30, "8056", "EXPECTED")
};
foreach (var market in new[]
{
LegacyDomesticStockMarket.Kospi,
LegacyDomesticStockMarket.Kosdaq
})
{
foreach (var cut in cuts)
{
cases.Add(market, cut.PhysicalIndex, cut.Alias, cut.Mode);
}
}
return cases;
}
}
[Theory]
[MemberData(nameof(DomesticStockCandleCases))]
public async Task DomesticStockCandlePointerDropCreatesCanonicalPlayoutSelection(
LegacyDomesticStockMarket market,
int physicalIndex,
string expectedAlias,
string expectedMode)
{
var stockName = market == LegacyDomesticStockMarket.Kospi
? "Samsung Electronics"
: "Kakao Games";
var stockCode = market == LegacyDomesticStockMarket.Kospi
? "005930"
: "293490";
var controller = new LegacyOperatorController(
new SingleStockLookup(market, stockName, stockCode));
await controller.SearchStocksAsync(stockName, LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(
physicalIndex,
control: false,
shift: false,
x: 2,
y: physicalIndex * 23,
timestampMilliseconds: 1_000);
var state = controller.DropSelectedCutsOnPlaylist();
var row = Assert.Single(state.Playlist);
var expectedSelection = new LegacySceneSelection(
market == LegacyDomesticStockMarket.Kospi
? "KOSPI_STOCK"
: "KOSDAQ_STOCK",
stockName,
string.Empty,
expectedMode,
stockCode);
Assert.Equal(expectedSelection, row.PlayoutSelection);
var entry = row.ToPlayoutEntry();
Assert.Equal(expectedAlias, entry.CutCode);
Assert.Equal(expectedSelection, entry.Selection);
}
[Fact]
public async Task ThreeOrderedProgramClicksSelectThenAppendExactlyOneSafeTailRow()
{
@@ -463,4 +558,25 @@ public sealed class LegacyStockCutInteractionParityTests
"Next stock(NXT)",
"A00001")]));
}
private sealed class SingleStockLookup : ILegacyStockLookup
{
private readonly LegacyStockIdentity _stock;
public SingleStockLookup(
LegacyDomesticStockMarket market,
string displayName,
string stockCode)
{
_stock = new LegacyStockIdentity(market, displayName, stockCode);
}
public Task<LegacyStockSearchData> SearchAsync(
string text,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
text,
[_stock.DisplayName],
[_stock]));
}
}

View File

@@ -133,6 +133,119 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Assert.Equal(new NamedPlaylistPageState(1, 1), stored.Page);
}
[Theory]
[InlineData(
LegacyDomesticStockMarket.Kospi,
"코스피",
"삼성전자",
"005930",
15,
"캔들그래프",
"PRICE")]
[InlineData(
LegacyDomesticStockMarket.Kosdaq,
"코스닥",
"카카오게임즈",
"293490",
15,
"캔들그래프",
"PRICE")]
[InlineData(
LegacyDomesticStockMarket.Kospi,
"코스피",
"삼성전자",
"005930",
21,
"캔들그래프(거래량)",
"VOLUME")]
[InlineData(
LegacyDomesticStockMarket.Kosdaq,
"코스닥",
"카카오게임즈",
"293490",
21,
"캔들그래프(거래량)",
"VOLUME")]
[InlineData(
LegacyDomesticStockMarket.Kospi,
"코스피",
"삼성전자",
"005930",
26,
"캔들그래프(예상체결가)",
"EXPECTED")]
[InlineData(
LegacyDomesticStockMarket.Kosdaq,
"코스닥",
"카카오게임즈",
"293490",
26,
"캔들그래프(예상체결가)",
"EXPECTED")]
public async Task DirectDomesticStockCandleSaveAndLoadRestoresCanonicalPlayoutSelection(
LegacyDomesticStockMarket market,
string storedMarket,
string stockName,
string stockCode,
int physicalIndex,
string visibleGraphicType,
string runtimeMode)
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = new LegacyOperatorController(
new FixedStockLookup(market, stockName, stockCode),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
controller.SetMovingAverages(ma5: false, ma20: false);
await controller.SearchStocksAsync(stockName, LegacySearchTrigger.Button);
controller.SelectStock(0);
controller.CutPointerDown(
physicalIndex,
control: false,
shift: false,
x: 2,
y: physicalIndex * 23,
timestampMilliseconds: 1_000);
var added = controller.DropSelectedCutsOnPlaylist();
var expectedCanonical = new LegacySceneSelection(
market == LegacyDomesticStockMarket.Kospi
? "KOSPI_STOCK"
: "KOSDAQ_STOCK",
stockName,
string.Empty,
runtimeMode,
stockCode);
Assert.Equal(expectedCanonical, Assert.Single(added.Playlist).PlayoutSelection);
var listed = await controller.RefreshNamedPlaylistsAsync();
var definitionId = Assert.Single(
listed.NamedPlaylist!.Definitions).DefinitionId;
controller.SelectNamedPlaylist(definitionId);
await controller.SaveCurrentNamedPlaylistAsync();
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.Equal(storedMarket, stored.Selection.GroupCode);
Assert.Equal(stockName, stored.Selection.Subject);
Assert.Equal(visibleGraphicType, stored.Selection.GraphicType);
Assert.Equal("5일", stored.Selection.Subtype);
Assert.Equal(stockCode, stored.Selection.DataCode);
Assert.Equal(new NamedPlaylistPageState(1, 1), stored.Page);
var loaded = await controller.LoadNamedPlaylistByIdAsync(definitionId);
Assert.True(loaded.CommandResult?.Succeeded);
var loadedRow = Assert.Single(loaded.Playlist);
Assert.Equal(storedMarket, loadedRow.MarketText);
Assert.Equal(stockName, loadedRow.StockName);
Assert.Equal(visibleGraphicType, loadedRow.GraphicType);
Assert.Equal("5일", loadedRow.Subtype);
Assert.Equal(stockCode, loadedRow.StockCode);
Assert.Equal("8061", loadedRow.SceneAlias);
Assert.Equal(expectedCanonical, loadedRow.PlayoutSelection);
var runtimeEntry = Assert.Single(controller.CreatePlayoutRequest().Playlist);
Assert.Equal("8061", runtimeEntry.CutCode);
Assert.Equal(expectedCanonical, runtimeEntry.Selection);
}
[Fact]
public async Task SaveFixedPagedActionPersistsInitialDatabasePageNBeforePlayout()
{
@@ -635,6 +748,31 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
saved.NamedPlaylist?.LastMutationOutcome);
}
[Fact]
public async Task SaveCurrent_PersistsTheConsumedPageProjection()
{
var persistence = new OperatorNamedPlaylistPersistence([]);
var controller = CreateController(persistence);
var action = LegacyFixedActionCatalog.Default
.GetActions(LegacyFixedMarket.Overseas)
.First(candidate => candidate.Available);
var added = controller.ActivateFixedAction(action.Id);
var row = Assert.Single(added.Playlist);
var listed = await controller.RefreshNamedPlaylistsAsync();
controller.SelectNamedPlaylist(
listed.NamedPlaylist!.Definitions.Single().DefinitionId);
controller.ReflectSuccessfulPlayout(
row.RowId,
pageIndexZeroBased: 1,
pageCount: 3,
synchronizeOperatorSelection: false);
await controller.SaveCurrentNamedPlaylistAsync();
var stored = Assert.Single(persistence.LastReplaceItems!);
Assert.Equal("2/3", stored.Page?.ToString());
}
[Theory]
[InlineData(ThemeMarket.Krx, " Edge Theme ", " Edge Theme ")]
[InlineData(ThemeMarket.Nxt, "Robotics(NXT)", "Robotics(NXT)(NXT)")]
@@ -955,6 +1093,27 @@ public sealed class LegacyNamedPlaylistOperatorIntegrationTests
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class FixedStockLookup : ILegacyStockLookup
{
private readonly LegacyStockIdentity _stock;
public FixedStockLookup(
LegacyDomesticStockMarket market,
string displayName,
string stockCode)
{
_stock = new LegacyStockIdentity(market, displayName, stockCode);
}
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(
query,
[_stock.DisplayName],
[_stock]));
}
private sealed class FixedTimeProvider(DateTimeOffset localNow) : TimeProvider
{
private static readonly TimeZoneInfo KoreaStandardTime = TimeZoneInfo.CreateCustomTimeZone(

View File

@@ -0,0 +1,130 @@
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
using MMoneyCoderSharp.Data;
namespace MBN_STOCK_WEBVIEW.LegacyApplication.Tests;
public sealed class LegacyNamedPlaylistPagedPlaceholderFixtureTests
{
[Fact]
public async Task Raw5074PlaceholdersBeforeSelectedIndexCandleReproduceWholeListPreflightHazard()
{
var persistence = new ReadOnlyNamedPlaylistPersistence(
[
new NamedPlaylistStoredItem(
0,
true,
new LegacySceneSelection(
"테마",
"=========================",
"5단 표그래프",
"테마-현재가",
string.Empty),
new NamedPlaylistPageState(1, 1)),
new NamedPlaylistStoredItem(
1,
true,
new LegacySceneSelection(
"테마",
"=========================",
"5단 표그래프",
"테마-현재가",
string.Empty),
new NamedPlaylistPageState(1, 1)),
new NamedPlaylistStoredItem(
2,
true,
new LegacySceneSelection(
"코스피",
"지수",
"캔들그래프(거래량)",
"5일",
string.Empty),
new NamedPlaylistPageState(1, 1))
]);
var controller = new LegacyOperatorController(
new UnusedStockLookup(),
namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController(persistence));
var listed = await controller.RefreshNamedPlaylistsAsync();
var loaded = await controller.LoadNamedPlaylistByIdAsync(
Assert.Single(listed.NamedPlaylist!.Definitions).DefinitionId);
var candle = loaded.Playlist[2];
controller.SetMovingAverages(ma5: false, ma20: false);
controller.SelectPlaylistRow(candle.RowId, control: false, shift: false);
var request = controller.CreatePlayoutRequest();
Assert.All(loaded.Playlist, row => Assert.True(row.IsSceneResolved));
Assert.All(request.Playlist.Take(2), entry =>
{
Assert.Equal("5074", entry.CutCode);
Assert.Equal("테마", entry.Selection?.GroupCode);
Assert.Equal(ScenePageSize.Five, entry.PageNavigation?.PageSize);
});
Assert.Equal("8061", request.Playlist[2].CutCode);
Assert.Equal(
new LegacySceneSelection(
"KOSPI_INDEX",
"INDEX",
string.Empty,
"VOLUME",
string.Empty),
request.Playlist[2].Selection);
Assert.False(request.Playlist[2].PageNavigation?.IsEnabled);
Assert.Equal(2, request.SelectedIndexZeroBased);
}
private sealed class UnusedStockLookup : ILegacyStockLookup
{
public Task<LegacyStockSearchData> SearchAsync(
string query,
CancellationToken cancellationToken = default) =>
Task.FromResult(new LegacyStockSearchData(query, [], []));
}
private sealed class ReadOnlyNamedPlaylistPersistence(
IReadOnlyList<NamedPlaylistStoredItem> items)
: INamedPlaylistPersistenceService
{
private const string ProgramCode = "00000004";
public bool CanMutate => false;
public Task<NamedPlaylistListResult> ListAsync(
int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions,
CancellationToken cancellationToken = default) =>
Task.FromResult(new NamedPlaylistListResult(
DateTimeOffset.UtcNow,
[new NamedPlaylistSummary(ProgramCode, "생쇼")],
false));
public Task<string> SuggestNextProgramCodeAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult("00000005");
public Task<NamedPlaylistDocument> LoadAsync(
string programCode,
CancellationToken cancellationToken = default) =>
Task.FromResult(new NamedPlaylistDocument(
new NamedPlaylistSummary(ProgramCode, "생쇼"),
DateTimeOffset.UtcNow,
items));
public Task CreateDefinitionAsync(
string programCode,
string title,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task ReplaceItemsAsync(
string programCode,
IReadOnlyList<NamedPlaylistStoredItem> replacement,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task DeleteAsync(
string programCode,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}

View File

@@ -73,35 +73,41 @@ public sealed class LegacyDevelopmentLiveStartupContractTests
[Fact]
public void AppAppliesRuntimeGateBeforeConstructingAnyRuntimeWindow()
{
var app = File.ReadAllText(Path.Combine(ParityRoot, "App.xaml.cs"));
var app = File.ReadAllText(Path.Combine(ParityRoot, "App.xaml.cs"))
.ReplaceLineEndings("\n");
var bootstrap = app.IndexOf(
"DevelopmentLiveLaunchBootstrap.TryApply(",
StringComparison.Ordinal);
var constructSetupWindow = app.IndexOf(
"new FirstRunSetupWindow()",
StringComparison.Ordinal);
var constructMainWindow = app.IndexOf(
"new MainWindow();",
StringComparison.Ordinal);
var sourceOnlyDirective = app.IndexOf(
"#if SOURCE_ONLY_RUNTIME",
StringComparison.Ordinal);
var fullRuntimeDirective = app.IndexOf(
"#else",
constructSetupWindow,
sourceOnlyDirective,
StringComparison.Ordinal);
var runtimeEndDirective = app.IndexOf(
"#endif",
var preflight = app.IndexOf(
"ResolveDevelopmentLiveConfigurationRecoveryMessage(developmentLive)",
fullRuntimeDirective,
StringComparison.Ordinal);
var constructMainWindow = app.IndexOf(
"? new MainWindow()",
preflight,
StringComparison.Ordinal);
var constructRecoveryWindow = app.IndexOf(
": new FirstRunSetupWindow(",
constructMainWindow,
StringComparison.Ordinal);
var runtimeEndDirective = app.IndexOf("#endif", constructRecoveryWindow,
StringComparison.Ordinal);
Assert.True(bootstrap >= 0);
Assert.True(sourceOnlyDirective >= 0);
Assert.True(constructSetupWindow > sourceOnlyDirective);
Assert.True(fullRuntimeDirective > constructSetupWindow);
Assert.True(fullRuntimeDirective > sourceOnlyDirective);
Assert.True(bootstrap > fullRuntimeDirective);
Assert.True(constructMainWindow > bootstrap);
Assert.True(runtimeEndDirective > constructMainWindow);
Assert.True(preflight > bootstrap);
Assert.True(constructMainWindow > preflight);
Assert.True(constructRecoveryWindow > constructMainWindow);
Assert.True(runtimeEndDirective > constructRecoveryWindow);
Assert.Contains("args.Arguments", app, StringComparison.Ordinal);
Assert.Contains("Environment.GetCommandLineArgs()", app, StringComparison.Ordinal);
Assert.Contains("ResolveLaunchArguments(", app, StringComparison.Ordinal);
@@ -110,12 +116,71 @@ public sealed class LegacyDevelopmentLiveStartupContractTests
app,
StringComparison.Ordinal);
Assert.Contains(
"IsDevelopmentLiveLaunch = developmentLive.IsApplied;",
"IsDevelopmentLiveLaunch =\n" +
" developmentLive.IsApplied &&\n" +
" configurationRecoveryMessage is null;",
app,
StringComparison.Ordinal);
Assert.DoesNotContain("#if DEBUG", app, StringComparison.Ordinal);
}
[Fact]
public void InvalidProtectedLiveProfileRoutesToExplicitRecoveryWithoutConstructingMainWindow()
{
var app = File.ReadAllText(Path.Combine(ParityRoot, "App.xaml.cs"))
.ReplaceLineEndings("\n");
Assert.Contains(
"_ = PlayoutOptionsLoader.LoadDevelopmentLive();",
app,
StringComparison.Ordinal);
Assert.Contains(
"catch (PlayoutConfigurationException)",
app,
StringComparison.Ordinal);
Assert.Contains(
"\"development-live-configuration-replacement-required\"",
app,
StringComparison.Ordinal);
Assert.Contains(
"버튼을 누르기 전에는 기존 Live 설정을 변경하지 않습니다.",
app,
StringComparison.Ordinal);
Assert.Contains(
"FirstRunSetupWindow.IsVerifiedSetupAvailable()",
app,
StringComparison.Ordinal);
Assert.Contains(
"liveConfigReplacementAvailable: recoverySetupAvailable",
app,
StringComparison.Ordinal);
Assert.Contains(
"현재 빌드에는 검증된 Git 소스 경로가 포함되어 있지 않아 자동 교체를 실행할 수 없습니다.",
app,
StringComparison.Ordinal);
var preflight = app.IndexOf(
"ResolveDevelopmentLiveConfigurationRecoveryMessage(developmentLive)",
StringComparison.Ordinal);
var windowChoice = app.IndexOf(
"_window = configurationRecoveryMessage is null",
preflight,
StringComparison.Ordinal);
var mainWindow = app.IndexOf(
"? new MainWindow()",
windowChoice,
StringComparison.Ordinal);
var recoveryWindow = app.IndexOf(
": new FirstRunSetupWindow(",
mainWindow,
StringComparison.Ordinal);
Assert.True(preflight >= 0);
Assert.True(windowChoice > preflight);
Assert.True(mainWindow > windowChoice);
Assert.True(recoveryWindow > mainWindow);
}
[Fact]
public void AppliedDevelopmentLiveUsesSelectedRuntimePathsAndSelectedResDatabaseAuthority()
{

View File

@@ -11,7 +11,7 @@ public sealed class LegacyFirstRunSetupNativeContractTests
"MBN_STOCK_WEBVIEW.LegacyParityApp");
[Fact]
public void SourceOnlyLaunchShowsOnlyTheNativeFirstRunWindow()
public void SourceOnlyLaunchUsesSetupAndFullRuntimeMayRecoverBeforeMainWindow()
{
var app = File.ReadAllText(Path.Combine(AppRoot, "App.xaml.cs"))
.ReplaceLineEndings("\n");
@@ -37,7 +37,7 @@ public sealed class LegacyFirstRunSetupNativeContractTests
Assert.Contains("DevelopmentLiveLaunchBootstrap.TryApply(", fullRuntime,
StringComparison.Ordinal);
Assert.Contains("new MainWindow()", fullRuntime, StringComparison.Ordinal);
Assert.DoesNotContain("new FirstRunSetupWindow()", fullRuntime,
Assert.Contains("new FirstRunSetupWindow(", fullRuntime,
StringComparison.Ordinal);
Assert.DoesNotContain(
"new FirstRunSetupWindow(App.IsDevelopmentLiveLaunch",
@@ -257,7 +257,78 @@ public sealed class LegacyFirstRunSetupNativeContractTests
}
[Fact]
public void SourceOnlyBuildEmbedsOnlyItsOwnClonePathForTheLocalBinding()
public void RecoveryPrefillsValidatedStoredFoldersAndRequiresExplicitReplacement()
{
var setup = File.ReadAllText(Path.Combine(
AppRoot,
"FirstRunSetupWindow.xaml.cs"))
.ReplaceLineEndings("\n");
Assert.Contains(
"RestoreStoredRuntimeFolders();",
setup,
StringComparison.Ordinal);
Assert.Contains(
"var loaded = new LegacyOperatorSettingsStore().Load();",
setup,
StringComparison.Ordinal);
Assert.Contains(
"loaded.Failure != LegacyOperatorSettingsStoreFailure.None",
setup,
StringComparison.Ordinal);
Assert.Contains(
"loaded.Settings.SceneDirectory",
setup,
StringComparison.Ordinal);
Assert.Contains(
"loaded.Settings.ResourceDirectory",
setup,
StringComparison.Ordinal);
Assert.Contains(
"_liveConfigReplacementAvailable =\n" +
" liveConfigReplacementAvailable &&\n" +
" IsVerifiedSetupAvailable();",
setup,
StringComparison.Ordinal);
Assert.Contains(
"liveConfigReplacementAvailable && _runtimeFoldersReady",
setup,
StringComparison.Ordinal);
Assert.Contains(
"await RunSetupFromUiAsync(replaceLiveConfig: true);",
setup,
StringComparison.Ordinal);
Assert.Contains(
"internal static bool IsVerifiedSetupAvailable() =>\n" +
" TryResolveRepositoryRoot(out _);",
setup,
StringComparison.Ordinal);
Assert.Contains(
"SingleOrDefault(attribute => string.Equals(",
setup,
StringComparison.Ordinal);
Assert.Contains(
"if (!File.Exists(project))",
setup,
StringComparison.Ordinal);
Assert.Contains(
"if (replaceLiveConfig)\n" +
" {\n" +
" AddArgument(\"-ReplaceLiveConfig\");",
setup,
StringComparison.Ordinal);
Assert.DoesNotContain(
"RunSetupFromUiAsync(replaceLiveConfig: true);",
setup[
setup.IndexOf("internal FirstRunSetupWindow(", StringComparison.Ordinal)..
setup.IndexOf(
"private async void OnChooseCutsClicked",
StringComparison.Ordinal)],
StringComparison.Ordinal);
}
[Fact]
public void DebugBuildEmbedsItsOwnClonePathForFirstRunAndRecovery()
{
var project = XDocument.Load(Path.Combine(
AppRoot,
@@ -266,14 +337,9 @@ public sealed class LegacyFirstRunSetupNativeContractTests
Assert.Equal("SourceRepositoryRoot", metadata.Attribute("Include")?.Value);
Assert.Equal("$(SourceRepositoryRoot)", metadata.Attribute("Value")?.Value);
Assert.Contains(
"'$(LegacyRuntimeAssetsEnabled)' != 'true'",
metadata.Attribute("Condition")?.Value,
StringComparison.Ordinal);
Assert.Contains(
Assert.Equal(
"'$(Configuration)' == 'Debug'",
metadata.Attribute("Condition")?.Value,
StringComparison.Ordinal);
metadata.Attribute("Condition")?.Value);
}
[Fact]

View File

@@ -292,6 +292,226 @@ public sealed class LegacyPlayoutNativeContractTests
AssertEveryFaultBranchQuarantines(loop, "PLAY_CALLBACK_TIMEOUT");
}
[Fact]
public void PendingHelloConfirmationIsNotStoredAsAStickyInitializationError()
{
var connect = Slice(
Playout,
"private async Task ConnectPlayoutAsync(",
"private void OnPlayoutStatusChanged(");
Assert.Contains(
"PlayoutConnectionState.Connecting or",
connect,
StringComparison.Ordinal);
Assert.Contains(
"PlayoutConnectionState.Reconnecting",
connect,
StringComparison.Ordinal);
Assert.Contains(
"_playoutInitializationError = null;",
connect,
StringComparison.Ordinal);
var handler = Slice(
Playout,
"private void OnPlayoutStatusChanged(",
"private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(");
Assert.Contains(
"args.Current.State == PlayoutConnectionState.Connected",
handler,
StringComparison.Ordinal);
Assert.Contains("args.Current.IsConnected", handler, StringComparison.Ordinal);
Assert.Contains(
"_playoutInitializationError = null;",
handler,
StringComparison.Ordinal);
Assert.DoesNotContain("ConnectAsync(", handler, StringComparison.Ordinal);
}
[Fact]
public void CompletionCallbacksReplaceOnlyTheMatchingPendingOperatorMessage()
{
var handler = Slice(
Playout,
"private void OnPlayoutStatusChanged(",
"private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(");
Assert.Contains(
"sender is not IPlayoutEngine statusSource",
handler,
StringComparison.Ordinal);
Assert.Contains(
"!ReferenceEquals(statusSource, _playoutEngine)",
handler,
StringComparison.Ordinal);
Assert.Contains(
"QueuePlayoutStatus(statusSource, args);",
handler,
StringComparison.Ordinal);
Assert.Contains(
"DispatcherQueue.TryEnqueue(() =>",
handler,
StringComparison.Ordinal);
Assert.Contains(
"snapshot.StatusKind == LegacyOperatorStatusKind.Information",
handler,
StringComparison.Ordinal);
Assert.Contains(
"snapshot.StatusMessage,\n expectedPendingMessage,\n" +
" StringComparison.Ordinal",
Normalize(handler),
StringComparison.Ordinal);
Assert.Contains(
"args.Previous.IsTakeOutCompletionPending",
handler,
StringComparison.Ordinal);
Assert.Contains(
"args.Current.State != PlayoutConnectionState.Connected",
handler,
StringComparison.Ordinal);
Assert.Contains("!args.Current.IsConnected", handler, StringComparison.Ordinal);
Assert.Contains(
"!args.Current.IsTakeOutCompletionPending",
handler,
StringComparison.Ordinal);
Assert.Contains(
"args.Previous.IsPlayCompletionPending",
handler,
StringComparison.Ordinal);
Assert.Contains(
"!args.Current.IsPlayCompletionPending",
handler,
StringComparison.Ordinal);
Assert.Contains(
"latestStatus.Sequence >= args.Current.Sequence",
handler,
StringComparison.Ordinal);
Assert.Contains(
"IsPlayoutCompletionStillConfirmed(completionKind, latestStatus)",
handler,
StringComparison.Ordinal);
Assert.Contains(
"PendingTakeOutOperatorMessage",
handler,
StringComparison.Ordinal);
Assert.Contains(
"PendingPlayOperatorMessage",
handler,
StringComparison.Ordinal);
Assert.Contains(
"_controller.ReportInformation(completedMessage)",
handler,
StringComparison.Ordinal);
Assert.Contains(
"CompletedTakeOutOperatorMessage",
handler,
StringComparison.Ordinal);
Assert.Contains(
"CompletedPlayOperatorMessage",
handler,
StringComparison.Ordinal);
var execute = Slice(
Playout,
"private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(",
"private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(");
var storedPending = RequiredIndex(
execute,
"var information = _controller.ReportInformation(result.Message);");
var reconciled = RequiredIndex(
execute,
"return ReconcileCompletedPlayoutMessage(",
storedPending);
Assert.True(storedPending < reconciled);
var reconcile = Slice(
Playout,
"private LegacyOperatorSnapshot ReconcileCompletedPlayoutMessage(",
"private enum PlayoutCompletionKind");
Assert.Contains(
"IsPlayoutCompletionStillConfirmed(completionKind, statusSource.Status)",
reconcile,
StringComparison.Ordinal);
Assert.Contains(
"PendingTakeOutOperatorMessage",
reconcile,
StringComparison.Ordinal);
Assert.Contains(
"PendingPlayOperatorMessage",
reconcile,
StringComparison.Ordinal);
Assert.Contains(
"CompletedTakeOutOperatorMessage",
reconcile,
StringComparison.Ordinal);
Assert.Contains(
"CompletedPlayOperatorMessage",
reconcile,
StringComparison.Ordinal);
}
[Fact]
public void ConsumedNextIsPersistedForProviderAndEngineFailures()
{
var execute = Slice(
Playout,
"private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(",
"private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(");
var failedResult = Slice(
execute,
"if (!result.IsSuccess)",
"if (command == LegacyOperatorPlayoutCommand.TakeOut)");
var sceneDataFailure = Slice(
execute,
"catch (LegacySceneDataException exception)",
"catch (Exception exception)");
var consumed = Slice(
Playout,
"private bool TryReportConsumedNext(",
"private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(");
Assert.Contains(
"result.Code == PlayoutResultCode.Rejected",
failedResult,
StringComparison.Ordinal);
Assert.Contains(
"TryReportConsumedNext(",
failedResult,
StringComparison.Ordinal);
Assert.Contains(
"TryReportConsumedNext(",
sceneDataFailure,
StringComparison.Ordinal);
Assert.Equal(
6,
execute.Split(
"TryReportConsumedNext(",
StringSplitOptions.None).Length - 1);
Assert.Contains(
"var consumedPlaylistRow =",
consumed,
StringComparison.Ordinal);
Assert.Contains(
"cursorPageIndexBefore != workflow.State.PageIndexZeroBased",
consumed,
StringComparison.Ordinal);
Assert.Contains(
"_controller.ReflectSuccessfulPlayout(",
consumed,
StringComparison.Ordinal);
Assert.Contains(
"synchronizeOperatorSelection: false",
consumed,
StringComparison.Ordinal);
Assert.Contains(
"_controller.ReportInformation(",
consumed,
StringComparison.Ordinal);
Assert.Contains(
"return _controller.ReportError(exception.Message);",
sceneDataFailure,
StringComparison.Ordinal);
}
[Fact]
public void ExternalIdleStatusResetsNormalRefreshState()
{
@@ -385,10 +605,45 @@ public sealed class LegacyPlayoutNativeContractTests
Playout,
"private async Task<LegacyOperatorSnapshot> ExecuteOperatorPlayoutAsync(",
"private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync(");
var captureNextComposition = RequiredIndex(
execute,
"if (command == LegacyOperatorPlayoutCommand.Next)");
var stopRefresh = RequiredIndex(execute, "StopRefreshLoop();");
var pendingTakeOutGuard = RequiredIndex(
execute,
"if (engine.Status.IsTakeOutCompletionPending)");
var pendingPlayGuard = RequiredIndex(
execute,
"engine.Status.IsPlayCompletionPending)");
var promote = RequiredIndex(execute, "ApplyStagedCompositionForNextCue();");
var dispatch = RequiredIndex(execute, "switch (command)");
Assert.True(stopRefresh < promote && promote < dispatch);
Assert.True(
captureNextComposition < stopRefresh &&
stopRefresh < pendingTakeOutGuard &&
stopRefresh < pendingPlayGuard &&
stopRefresh < promote &&
promote < dispatch);
Assert.Contains(
"workflow.State.IsNextCursorPageNavigationEnabled",
execute,
StringComparison.Ordinal);
Assert.Contains(
"activeCompositionBeforeNext = _compositionOptions!.Current;",
execute,
StringComparison.Ordinal);
Assert.DoesNotContain(
"Keep\n // the current scene's refresh epoch intact",
Normalize(execute),
StringComparison.Ordinal);
Assert.Contains(
"RestoreActiveCompositionAfterFailedNext(",
execute,
StringComparison.Ordinal);
Assert.True(
execute.Split(
"RestoreActiveCompositionAfterFailedNext(",
StringSplitOptions.None).Length - 1 >= 4,
"Known pre-dispatch NEXT failures must restore the previous composition.");
Assert.Contains("LegacyOperatorPlayoutCommand.Prepare or", execute,
StringComparison.Ordinal);
Assert.Contains("LegacyOperatorPlayoutCommand.TakeIn or", execute,

View File

@@ -558,6 +558,10 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
StringComparison.Ordinal);
Assert.Contains("reconnectEnabled = $false", initializer, StringComparison.Ordinal);
Assert.Contains(
"maximumAutomaticRefreshesPerTakeIn = $null",
initializer,
StringComparison.Ordinal);
Assert.DoesNotContain(
"maximumAutomaticRefreshesPerTakeIn = 0",
initializer,
StringComparison.Ordinal);
@@ -830,6 +834,14 @@ public sealed class LegacyRuntimeAssetDeploymentContractTests
StringComparison.Ordinal);
Assert.Contains("[switch] $ReplaceLiveConfig", initializer,
StringComparison.Ordinal);
Assert.Contains(
"maximumAutomaticRefreshesPerTakeIn = $null",
initializer,
StringComparison.Ordinal);
Assert.DoesNotContain(
"maximumAutomaticRefreshesPerTakeIn = 0",
initializer,
StringComparison.Ordinal);
Assert.Contains(
"-ConfigureDevelopmentLive requires the default Debug x64 build verification.",
initializer,

View File

@@ -321,7 +321,7 @@ public sealed class LegacyWorkspaceLayoutContractTests
Assert.DoesNotContain("<strong>Tornado2</strong>", header, StringComparison.Ordinal);
Assert.Matches(
@"id=""playout-connection-state""[^>]*role=""status""" +
@"[^>]*aria-label=""Tornado2 연결 상태""[^>]*aria-live=""polite""" +
@"[^>]*aria-label=""송출 연결 상태""[^>]*aria-live=""polite""" +
@"[^>]*aria-atomic=""true"">미연결</div>",
Markup);
Assert.Matches(

View File

@@ -55,7 +55,7 @@ internal sealed class FakeK3dSession : IK3dGuardedConnectSession
public bool SupportsLifecycleCallbacks { get; set; }
public bool? KtapHelloObserved { get; set; }
public bool? KtapHelloObserved { get; set; } = true;
public bool HasPendingLifecycleCallbacks { get; set; }

View File

@@ -150,6 +150,7 @@ public sealed class PlayoutOptionsLoaderTests
Assert.Null(options.LegacyBackgroundDirectory);
Assert.False(options.ReconnectEnabled);
Assert.Equal(0, options.MaximumReconnectAttempts);
Assert.Null(options.MaximumAutomaticRefreshesPerTakeIn);
var validated = ValidatedPlayoutOptions.Create(options);
Assert.Equal("127.0.0.1", validated.Host);
@@ -225,6 +226,55 @@ public sealed class PlayoutOptionsLoaderTests
StringComparison.Ordinal);
}
[Fact]
public void LoadDevelopmentLive_RejectsLegacyProfileThatDisablesAutomaticRefresh()
{
using var environment = ClearedEnvironment();
using var file = TemporaryJsonFile.Create(
DevelopmentLiveBaseJson().Replace(
"\"maximumAutomaticRefreshesPerTakeIn\": null",
"\"maximumAutomaticRefreshesPerTakeIn\": 0",
StringComparison.Ordinal));
var exception = Assert.Throws<PlayoutConfigurationException>(
() => PlayoutOptionsLoader.LoadDevelopmentLive(file.Path, "C:\\runtime"));
Assert.Contains(
"local configuration is invalid",
exception.Message,
StringComparison.Ordinal);
}
[Fact]
public void Validate_DevelopmentLiveRejectsAutomaticRefreshDisableAfterLoading()
{
using var environment = ClearedEnvironment();
using var file = TemporaryJsonFile.Create(DevelopmentLiveBaseJson());
var executableRoot = Path.Combine(
Path.GetTempPath(),
Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(executableRoot, "Cuts"));
try
{
var options = PlayoutOptionsLoader.LoadDevelopmentLive(
file.Path,
executableRoot);
options.MaximumAutomaticRefreshesPerTakeIn = 0;
var exception = Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(options));
Assert.Contains(
"protected startup contract",
exception.Message,
StringComparison.Ordinal);
}
finally
{
Directory.Delete(executableRoot, recursive: true);
}
}
[Fact]
public void Validate_DevelopmentLiveRequiresLiteralLoopbackAtFinalBoundary()
{
@@ -622,7 +672,7 @@ public sealed class PlayoutOptionsLoaderTests
"reconnectDelayMilliseconds": 1000,
"maximumReconnectAttempts": 0,
"reconnectEnabled": false,
"maximumAutomaticRefreshesPerTakeIn": 0
"maximumAutomaticRefreshesPerTakeIn": null
}
""";
}

View File

@@ -126,6 +126,216 @@ public sealed class TornadoPlayoutEngineTests
}
}
[Fact]
public async Task DevelopmentLiveLoopback_UsesOnHelloWithoutATornadoWindowProcess()
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
await using var engine = CreateEngine(
DevelopmentLiveOptions(scenes.Path),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(0, 0, 0)),
liveAuthorized: true);
var connect = await engine.ConnectAsync(CancellationToken.None);
var beforeHello = await engine.PrepareAsync(
Cue("live-scene"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, connect.Code);
Assert.Equal(PlayoutResultCode.Unavailable, beforeHello.Code);
Assert.Equal(PlayoutConnectionState.Connecting, engine.Status.State);
Assert.False(engine.Status.IsProcessRunning);
Assert.False(engine.Status.IsConnected);
Assert.Equal(1, sessionFactory.CreateCount);
Assert.DoesNotContain(
session.Calls,
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
session.KtapHelloObserved = true;
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.True(engine.Status.IsConnected);
Assert.True(engine.Status.IsCommandAvailable);
Assert.True(engine.Status.LiveTakeInAllowed);
Assert.False(engine.Status.IsProcessRunning);
Assert.True((await engine.PrepareAsync(
Cue("live-scene"),
CancellationToken.None)).IsSuccess);
}
[Theory]
[InlineData(1, 1, 1, true, null)]
[InlineData(1, 1, 0, false, "PGM/PROGRAM")]
[InlineData(1, 0, 1, false, "PGM/PROGRAM")]
[InlineData(2, 2, 2, false, "여러 개")]
[InlineData(2, 1, 1, false, "여러 개")]
public async Task DevelopmentLiveLoopback_WithTornadoProcesses_RequiresOneInspectablePgm(
int totalProcessCount,
int eligibleProcessCount,
int programProcessCount,
bool expectedSuccess,
string? expectedFailureText)
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory(() => new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = true
});
await using var engine = CreateEngine(
DevelopmentLiveOptions(scenes.Path),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(
totalProcessCount,
eligibleProcessCount,
programProcessCount)),
liveAuthorized: true);
var connect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(expectedSuccess, connect.IsSuccess);
Assert.Equal(expectedSuccess ? 1 : 0, sessionFactory.CreateCount);
Assert.Equal(expectedSuccess, engine.Status.IsConnected);
Assert.Equal(expectedSuccess, engine.Status.IsCommandAvailable);
Assert.Equal(expectedSuccess, engine.Status.LiveTakeInAllowed);
if (!expectedSuccess)
{
Assert.Contains(expectedFailureText!, connect.Message, StringComparison.Ordinal);
}
}
[Fact]
public async Task DevelopmentLiveLoopback_WithUninspectablePgm_FailsClosed()
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
var uninspectablePgm = new TornadoProcessSnapshot(1, 1, 1)
{
IdentityInspectionSucceeded = false
};
await using var engine = CreateEngine(
DevelopmentLiveOptions(scenes.Path),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(uninspectablePgm),
liveAuthorized: true);
var connect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, connect.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.False(engine.Status.IsConnected);
Assert.False(engine.Status.IsCommandAvailable);
Assert.False(engine.Status.LiveTakeInAllowed);
Assert.Contains("프로세스·창 정보를 확인할 수 없어", connect.Message, StringComparison.Ordinal);
}
[Theory]
[InlineData("::1", 30001, 0)]
[InlineData("127.0.0.1", 30002, 0)]
[InlineData("127.0.0.1", 30001, 30001)]
public async Task DevelopmentLiveLoopback_WithoutTornadoProcess_RequiresExactEndpoint(
string host,
int port,
int clientPort)
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var options = DevelopmentLiveOptions(scenes.Path);
options.Host = host;
options.Port = port;
options.ClientPort = clientPort;
var sessionFactory = new FakeK3dSessionFactory();
await using var engine = CreateEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(0, 0, 0)),
liveAuthorized: true);
var connect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, connect.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.False(engine.Status.IsConnected);
Assert.False(engine.Status.IsCommandAvailable);
Assert.False(engine.Status.LiveTakeInAllowed);
Assert.Contains("127.0.0.1:30001", connect.Message, StringComparison.Ordinal);
}
[Fact]
public async Task DevelopmentLiveLoopback_FailsClosedWhenATornadoProcessAppears()
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var noProcess = new TornadoProcessSnapshot(0, 0, 0);
var appearedProcess = DevelopmentPgmProcessSnapshot("appeared-generation");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = true
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
await using var engine = CreateEngine(
DevelopmentLiveOptions(scenes.Path),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(
noProcess,
noProcess,
appearedProcess),
liveAuthorized: true);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True(engine.Status.IsConnected);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.False(engine.Status.IsConnected);
Assert.False(engine.Status.IsCommandAvailable);
Assert.False(engine.Status.LiveTakeInAllowed);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.Equal(1, sessionFactory.CreateCount);
Assert.Contains("Abandon", session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
var blocked = await engine.PrepareAsync(
Cue("live-scene"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, blocked.Code);
Assert.DoesNotContain(
session.Calls,
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
}
[Fact]
public async Task OrdinaryLiveStillRequiresATornadoProcess()
{
using var scenes = TemporarySceneDirectory.Create("live-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
await using var engine = CreateEngine(
LiveOptions(scenes.Path, trustedLiveOutputEnabled: true),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(0, 0, 0)),
liveAuthorized: true);
var connect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, connect.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.False(engine.Status.IsConnected);
}
[Fact]
public async Task TestConnect_WhenKtapCallFails_ReportsAttemptedFailureEvidence()
{
@@ -967,7 +1177,7 @@ public sealed class TornadoPlayoutEngineTests
}
[Fact]
public async Task LastKtapEvidence_SurvivesLaterFailureBeforeAnotherDispatch()
public async Task ActivationFailureBeforeNewDispatch_DoesNotReusePreviousKtapEvidence()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession();
@@ -991,10 +1201,12 @@ public sealed class TornadoPlayoutEngineTests
Assert.Equal(PlayoutResultCode.Failed, secondConnect.Code);
Assert.Equal(PlayoutConnectionState.Faulted, engine.Status.State);
Assert.Equal(
PlayoutKtapConnectState.AcceptedUnconfirmed,
PlayoutKtapConnectState.NotAttempted,
engine.Status.LastKtapConnectState);
Assert.True(engine.Status.NetworkMonitoringRecordExpected);
Assert.True(engine.Status.NetworkMonitoringCheckRequired);
Assert.False(engine.Status.KtapConnectAttempted);
Assert.False(engine.Status.NetworkMonitoringRecordExpected);
Assert.False(engine.Status.NetworkMonitoringCheckRequired);
Assert.Null(engine.Status.KtapHelloObserved);
Assert.Equal(2, sessionFactory.CreateCount);
}
@@ -1179,7 +1391,7 @@ public sealed class TornadoPlayoutEngineTests
}
[Fact]
public async Task ChangedProcessGeneration_CleansOldSessionBeforeReconnect()
public async Task ChangedProcessGeneration_AbandonsWithoutCleanupOrReconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var calls = new ConcurrentQueue<string>();
@@ -1208,17 +1420,19 @@ public sealed class TornadoPlayoutEngineTests
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(2, sessionFactory.CreateCount);
Assert.Equal(1, sessionFactory.CreateCount);
Assert.Equal(
new[]
{
"first:Connect",
"first:Disconnect",
"first:Dispose",
"second:Connect"
"first:Abandon"
},
calls);
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.False(engine.Status.IsCommandAvailable);
Assert.DoesNotContain("second:Connect", calls);
Assert.DoesNotContain("first:Disconnect", calls);
Assert.DoesNotContain("first:Dispose", calls);
Assert.DoesNotContain("eligible-generation-a", engine.Status.Message, StringComparison.Ordinal);
Assert.DoesNotContain("eligible-generation-b", engine.Status.Message, StringComparison.Ordinal);
}
@@ -2081,8 +2295,13 @@ public sealed class TornadoPlayoutEngineTests
process,
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.False(engine.Status.KtapHelloObserved);
var connect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, connect.Code);
Assert.Equal(PlayoutConnectionState.Connecting, engine.Status.State);
Assert.False(engine.Status.IsConnected);
Assert.False(engine.Status.IsCommandAvailable);
Assert.Null(engine.Status.KtapHelloObserved);
Assert.Contains("연결 응답(OnHello)을 확인하는 중", engine.Status.Message);
session.KtapHelloObserved = true;
await engine.PollProcessOnceAsync(CancellationToken.None);
@@ -2091,6 +2310,313 @@ public sealed class TornadoPlayoutEngineTests
Assert.Contains("ProcessPendingCallbacks", session.Calls);
Assert.All(session.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.True(engine.Status.IsConnected);
Assert.True(engine.Status.IsCommandAvailable);
Assert.Contains("연결 응답(OnHello)이 확인되었습니다", engine.Status.Message);
}
[Fact]
public async Task NewLifecycleSession_DoesNotReusePreviousHelloEvidence()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var confirmedSession = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = true
};
var unconfirmedSession = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false
};
var created = 0;
var sessionFactory = new FakeK3dSessionFactory(
() => Interlocked.Increment(ref created) == 1
? confirmedSession
: unconfirmedSession);
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True(engine.Status.KtapHelloObserved);
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
var reconnect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, reconnect.Code);
Assert.Equal(PlayoutConnectionState.Connecting, engine.Status.State);
Assert.False(engine.Status.IsConnected);
Assert.Null(engine.Status.KtapHelloObserved);
Assert.Equal(2, sessionFactory.CreateCount);
await engine.DisposeAsync();
Assert.Contains("Abandon", unconfirmedSession.Calls);
Assert.DoesNotContain("Disconnect", unconfirmedSession.Calls);
}
[Fact]
public async Task FailedNewSessionAttempt_DoesNotExposePreviousHelloEvidence()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var confirmedSession = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = true
};
var failedSession = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false,
ConnectAction = _ => throw new InvalidOperationException("new session failed")
};
var created = 0;
var sessionFactory = new FakeK3dSessionFactory(
() => Interlocked.Increment(ref created) == 1
? confirmedSession
: failedSession);
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True(engine.Status.KtapHelloObserved);
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
var reconnect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Failed, reconnect.Code);
Assert.Equal(PlayoutConnectionState.Faulted, engine.Status.State);
Assert.Equal(
PlayoutKtapConnectState.Failed,
engine.Status.LastKtapConnectState);
Assert.True(engine.Status.KtapConnectAttempted);
Assert.Null(engine.Status.KtapHelloObserved);
Assert.Null(engine.Status.NetworkMonitoringRecordExpected);
Assert.Equal(2, sessionFactory.CreateCount);
}
[Theory]
[InlineData(PlayoutMode.Test)]
[InlineData(PlayoutMode.Live)]
public async Task LifecycleConnection_BlocksSceneCommandsUntilHelloWithoutRepeatingKtap(
PlayoutMode mode)
{
var sceneName = mode == PlayoutMode.Live ? "live-scene" : "test-scene";
using var scenes = TemporarySceneDirectory.Create($"{sceneName}.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
var options = mode == PlayoutMode.Live
? LiveOptions(scenes.Path, trustedLiveOutputEnabled: true)
: TestOptions(scenes.Path, sceneName);
await using var engine = CreateEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: mode == PlayoutMode.Live);
var connect = await engine.ConnectAsync(CancellationToken.None);
var prepareBeforeHello = await engine.PrepareAsync(
Cue(sceneName),
CancellationToken.None);
var repeatedConnect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Unavailable, connect.Code);
Assert.Equal(PlayoutResultCode.Unavailable, prepareBeforeHello.Code);
Assert.Equal(PlayoutResultCode.Unavailable, repeatedConnect.Code);
Assert.Contains("OnHello", prepareBeforeHello.Message, StringComparison.Ordinal);
Assert.Equal(PlayoutConnectionState.Connecting, engine.Status.State);
Assert.False(engine.Status.IsConnected);
Assert.False(engine.Status.IsCommandAvailable);
Assert.False(engine.Status.LiveTakeInAllowed);
Assert.Equal(1, sessionFactory.CreateCount);
Assert.Equal(1, session.KtapDispatchCount);
Assert.Equal(1, session.Calls.Count(call => call == "Connect"));
Assert.DoesNotContain(
session.Calls,
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
Assert.DoesNotContain("Disconnect", session.Calls);
session.KtapHelloObserved = true;
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.True(engine.Status.IsConnected);
Assert.True(engine.Status.IsCommandAvailable);
Assert.True(engine.Status.LiveTakeInAllowed);
Assert.True((await engine.PrepareAsync(
Cue(sceneName),
CancellationToken.None)).IsSuccess);
Assert.Equal(1, session.Calls.Count(
call => call == $"Prepare:{sceneName}"));
Assert.Equal(1, session.KtapDispatchCount);
}
[Fact]
public async Task HelloConfirmationTimeout_FaultsClosedWithoutDisconnectOrRetry_AndLateHelloRecovers()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var clock = new ManualGateTimeProvider();
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
var options = TestOptions(scenes.Path, "test-scene");
options.ConnectTimeoutMilliseconds = 100;
await using var engine = CreateEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false,
timeProvider: clock);
Assert.Equal(
PlayoutResultCode.Unavailable,
(await engine.ConnectAsync(CancellationToken.None)).Code);
clock.Advance(TimeSpan.FromMilliseconds(100));
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(PlayoutConnectionState.Faulted, engine.Status.State);
Assert.False(engine.Status.IsConnected);
Assert.False(engine.Status.IsCommandAvailable);
Assert.False(engine.Status.KtapHelloObserved);
Assert.Contains("송출 명령을 차단", engine.Status.Message, StringComparison.Ordinal);
Assert.Contains("Network Monitoring", engine.Status.Message, StringComparison.Ordinal);
Assert.Equal(
PlayoutResultCode.Unavailable,
(await engine.ConnectAsync(CancellationToken.None)).Code);
Assert.Equal(
PlayoutResultCode.Unavailable,
(await engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None)).Code);
Assert.Equal(1, sessionFactory.CreateCount);
Assert.Equal(1, session.KtapDispatchCount);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Abandon", session.Calls);
Assert.DoesNotContain(
session.Calls,
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
session.KtapHelloObserved = true;
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State);
Assert.True(engine.Status.IsConnected);
Assert.True(engine.Status.IsCommandAvailable);
Assert.True(engine.Status.KtapHelloObserved);
Assert.Equal(1, session.KtapDispatchCount);
}
[Fact]
public async Task DisconnectBeforeHello_AbandonsWithoutDispatchingSdkDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false
};
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.Equal(
PlayoutResultCode.Unavailable,
(await engine.ConnectAsync(CancellationToken.None)).Code);
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
await engine.DisposeAsync();
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
Assert.Contains("Abandon", session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State);
}
[Fact]
public async Task DisposeBeforeHello_AbandonsWithoutDispatchingSdkDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false
};
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.Equal(
PlayoutResultCode.Unavailable,
(await engine.ConnectAsync(CancellationToken.None)).Code);
await engine.DisposeAsync();
Assert.Contains("Abandon", session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State);
}
[Fact]
public async Task ProcessLossBeforeHello_AbandonsWithoutDispatchingSdkDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
SupportsLifecycleCallbacks = true,
KtapHelloObserved = false
};
var eligible = new TornadoProcessSnapshot(1, 1, 0);
var process = new FakeTornadoProcessProbe(
eligible,
eligible,
new TornadoProcessSnapshot(1, 0, 1));
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
process,
liveAuthorized: false);
Assert.Equal(
PlayoutResultCode.Unavailable,
(await engine.ConnectAsync(CancellationToken.None)).Code);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.Contains("Abandon", session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
await engine.DisposeAsync();
Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State);
}
[Fact]
@@ -2235,10 +2761,16 @@ public sealed class TornadoPlayoutEngineTests
engine.StatusChanged += (_, args) =>
observedPendingStates.Add(args.Current.IsPlayCompletionPending);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
var takeIn = await engine.TakeInAsync(CancellationToken.None);
Assert.True(takeIn.IsSuccess);
Assert.Contains("재생 완료 이벤트를 기다리고 있습니다", takeIn.Message);
Assert.Contains("재생 완료 이벤트를 기다리고 있습니다", engine.Status.Message);
Assert.True(engine.Status.IsPlayCompletionPending);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.True(engine.Status.IsPlayCompletionPending);
Assert.Contains("재생 완료 이벤트를 기다리고 있습니다", engine.Status.Message);
Assert.Equal([true, true], observedPendingStates);
callbackReady = true;
@@ -2306,9 +2838,13 @@ public sealed class TornadoPlayoutEngineTests
Assert.DoesNotContain("Prepare:5006", session.Calls);
callbackReady = true;
Assert.True((await engine.NextAsync(
var completedFirstNext = await engine.NextAsync(
Cue("5006"),
CancellationToken.None)).IsSuccess);
CancellationToken.None);
Assert.True(completedFirstNext.IsSuccess);
Assert.Contains(
"재생 완료 이벤트를 기다리고 있습니다",
completedFirstNext.Message);
Assert.True(session.HasPendingPlayCallbacks);
Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5006"));
@@ -2489,9 +3025,17 @@ public sealed class TornadoPlayoutEngineTests
Assert.True((await engine.PrepareAsync(
Cue("5001"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeOutAsync(
var takeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None)).IsSuccess);
CancellationToken.None);
Assert.True(takeOut.IsSuccess);
Assert.Contains("완료 이벤트를 기다리고 있습니다", takeOut.Message);
Assert.Contains("완료 이벤트를 기다리고 있습니다", engine.Status.Message);
Assert.Equal("5001", engine.Status.PreparedSceneName);
Assert.Null(engine.Status.OnAirSceneName);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Contains("완료 이벤트를 기다리고 있습니다", engine.Status.Message);
Assert.True(engine.Status.IsTakeOutCompletionPending);
Assert.False(engine.Status.IsCommandAvailable);
@@ -2511,6 +3055,8 @@ public sealed class TornadoPlayoutEngineTests
Assert.False(engine.Status.IsTakeOutCompletionPending);
Assert.True(engine.Status.IsCommandAvailable);
Assert.Null(engine.Status.PreparedSceneName);
Assert.Null(engine.Status.OnAirSceneName);
Assert.True((await engine.PrepareAsync(
Cue("5006"),
CancellationToken.None)).IsSuccess);
@@ -2915,7 +3461,8 @@ public sealed class TornadoPlayoutEngineTests
bool abandonOnTargetMismatch = false,
Action? beforeSdkDispatchClaim = null,
FakeLiveAuthorization? liveAuthorization = null,
PlayoutLaunchAuthorization? launchAuthorization = null)
PlayoutLaunchAuthorization? launchAuthorization = null,
TimeProvider? timeProvider = null)
{
var options = ValidatedPlayoutOptions.Create(rawOptions);
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
@@ -2928,7 +3475,7 @@ public sealed class TornadoPlayoutEngineTests
processProbe,
dispatcher,
liveAuthorization ?? new FakeLiveAuthorization(liveAuthorized),
TimeProvider.System,
timeProvider ?? TimeProvider.System,
startMonitor: false,
finalConnectSafetyCheck: finalConnectSafetyCheck,
abandonOnTargetMismatch: abandonOnTargetMismatch,
@@ -2969,6 +3516,25 @@ public sealed class TornadoPlayoutEngineTests
MaximumReconnectAttempts = 3
};
private static PlayoutOptions DevelopmentLiveOptions(string sceneDirectory) => new()
{
DevelopmentLiveBootstrapApplied = true,
DevelopmentLiveExecutableSceneDirectory = sceneDirectory,
Mode = PlayoutMode.Live,
Host = "127.0.0.1",
Port = 30001,
TcpMode = 1,
ClientPort = 0,
OutputChannel = null,
LayoutIndex = 10,
SceneDirectory = sceneDirectory,
TestSceneAllowlist = ["live-scene"],
TrustedLiveOutputEnabled = true,
ReconnectEnabled = false,
MaximumReconnectAttempts = 0,
MaximumAutomaticRefreshesPerTakeIn = null
};
private static PlayoutOptions GateAOptions(string sceneDirectory) => new()
{
Mode = PlayoutMode.Live,
@@ -3006,4 +3572,11 @@ public sealed class TornadoPlayoutEngineTests
{
EligibleProcessGeneration = new TornadoProcessGeneration(generation)
};
private static TornadoProcessSnapshot DevelopmentPgmProcessSnapshot(string generation) =>
new(1, 1, 1)
{
EligibleProcessGeneration = new TornadoProcessGeneration(generation),
ProgramProcessGeneration = new TornadoProcessGeneration(generation)
};
}

View File

@@ -10,19 +10,26 @@ const source = fs.readFileSync(path.join(
"src",
"MBN_STOCK_WEBVIEW.LegacyParityApp",
"MainWindow.Playout.cs"), "utf8");
const preflight = source.slice(
source.indexOf("private async Task<LegacyOperatorPlayoutRequest> CreateFreshPagedPlayoutRequestAsync("),
source.indexOf("private async Task<LegacyOperatorSnapshot> ExecuteGateAPrepareAsync("));
test("first PREPARE and TAKE IN freeze fresh PageN plans for every paged row", () => {
test("first PREPARE and TAKE IN preflight only the selected forward-enabled PageN row", () => {
assert.ok((source.match(/CreateFreshPagedPlayoutRequestAsync\(/g) || []).length >= 3);
assert.match(source,
/Where\(static entry => entry\.PageNavigation\?\.PageSize\.HasValue == true\)/u);
assert.match(source, /PreflightPagePlansAsync\(/u);
assert.match(source,
assert.match(preflight,
/Skip\(Math\.Max\(0, request\.SelectedIndexZeroBased\)\)[\s\S]*FirstOrDefault\(static entry => entry\.IsEnabled\)/u);
assert.match(preflight,
/PreflightPagePlansAsync\([\s\S]*Array\.AsReadOnly\(new\[\] \{ targetEntry \}\)/u);
assert.doesNotMatch(preflight, /foreach \(var entry in/u);
assert.doesNotMatch(preflight, /catch \(LegacySceneDataException\)/u);
assert.match(preflight,
/ReflectSuccessfulPlayout\([\s\S]*plan\.EntryId,[\s\S]*pageIndexZeroBased: 0,[\s\S]*plan\.PageCount,[\s\S]*synchronizeOperatorSelection: false\)/u);
});
test("fresh PageN wiring rejects empty or mismatched plans before vendor dispatch", () => {
assert.match(source, /plans\.Count != pagedEntries\.Length/u);
assert.match(source, /plan\.PageSize != \(int\)expectedPageSize/u);
assert.match(source, /plan\.PageCount is < 1 or > ScenePaging\.MaximumPageCount/u);
assert.match(source, /workflow\.State\.CurrentEntryId is not null/u);
test("the selected PageN preflight is strictly validated before vendor dispatch", () => {
assert.match(preflight, /plans\.Count != 1/u);
assert.match(preflight, /!string\.Equals\(plan\.EntryId, targetEntry\.EntryId, StringComparison\.Ordinal\)/u);
assert.match(preflight, /plan\.PageSize != \(int\)expectedPageSize/u);
assert.match(preflight, /plan\.PageCount is < 1 or > ScenePaging\.MaximumPageCount/u);
assert.match(preflight, /workflow\.State\.CurrentEntryId is not null/u);
});