fix: restore legacy playout parity and live startup
This commit is contained in:
@@ -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>'
|
||||
|
||||
@@ -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를 사용한다.
|
||||
|
||||
@@ -205,7 +205,7 @@ $playout = [ordered]@{
|
||||
reconnectDelayMilliseconds = 1000
|
||||
maximumReconnectAttempts = 0
|
||||
reconnectEnabled = $false
|
||||
maximumAutomaticRefreshesPerTakeIn = 0
|
||||
maximumAutomaticRefreshesPerTakeIn = $null
|
||||
}
|
||||
|
||||
$authorization = [ordered]@{
|
||||
|
||||
@@ -1450,7 +1450,7 @@ function Get-DevelopmentLivePreflight {
|
||||
reconnectDelayMilliseconds = 1000
|
||||
maximumReconnectAttempts = 0
|
||||
reconnectEnabled = $false
|
||||
maximumAutomaticRefreshesPerTakeIn = 0
|
||||
maximumAutomaticRefreshesPerTakeIn = $null
|
||||
}
|
||||
$authorization = [ordered]@{
|
||||
schemaVersion = 1
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "미연결";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 "미연결";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 연결됨"/);
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user