fix: complete legacy operator parity workflows

This commit is contained in:
2026-07-23 00:54:31 +09:00
parent 939c252d23
commit cf117de144
25 changed files with 2430 additions and 223 deletions

View File

@@ -686,7 +686,8 @@ public sealed partial class MainWindow
// independent of the scheduler remaining active between refreshes. All
// uncertain/pending/busy states below remain fail-closed.
var isSafeCutTailAppend = intent is
LegacyCutPointerDownIntent { AllowAppend: true };
LegacyCutPointerDownIntent { AllowAppend: true } or
LegacyDropSelectedCutsIntent;
// MainForm kept its Space and "all" enabled-state gestures available while
// PROGRAM was active. They only change the native operator playlist; NEXT
// stops the refresh loop before the workflow adopts those flags. Therefore
@@ -694,8 +695,26 @@ public sealed partial class MainWindow
var isSafeProgramEnabledStateMutation = intent is
LegacySetAllPlaylistEnabledIntent or
LegacyToggleActivePlaylistEnabledIntent;
// Deleting selected rows is also independent of the refresh scheduler, but
// only after the exact current row and every selected index are checked by
// CanDeleteSelectedPlaylistTail below. The one-slot ordered UI gate keeps
// this request behind its preceding row selection without allowing repeats.
var isSafeProgramTailDelete = intent is
LegacyDeleteSelectedPlaylistRowsIntent;
// PList did not stop or replace the active scene when it created, saved,
// overwrote, or deleted a named DB playlist. These writes only persist a
// snapshot/definition and are serialized with the automatic refresh by the
// shared intent and database gates. Loading remains an idle-only structural
// replacement below.
var isSafeNamedPlaylistPersistenceMutation = intent is
LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent;
var refreshAllowsMutation = isSafeCutTailAppend ||
isSafeProgramEnabledStateMutation ||
isSafeProgramTailDelete ||
isSafeNamedPlaylistPersistenceMutation ||
(!refresh.IsActive && (_refreshTask is null || _refreshTask.IsCompleted));
var runtimeAcceptsIndependentMutation = Volatile.Read(ref _playoutBusy) == 0 &&
Volatile.Read(ref _playoutQuarantined) == 0 &&
@@ -711,6 +730,14 @@ public sealed partial class MainWindow
return false;
}
if (isSafeCutTailAppend &&
!CanAppendCutToPlaylistTail(status, workflow))
{
rejectionMessage =
"PROGRAM 상태에서는 현재 송출 행을 확인할 수 있을 때만 새 컷을 대기열 끝에 추가할 수 있습니다.";
return false;
}
if (RequiresIdlePlaylistMutation(intent) && !IsPlaylistMutationIdle(status, workflow))
{
rejectionMessage = intent is
@@ -740,6 +767,39 @@ public sealed partial class MainWindow
return true;
}
private bool CanAppendCutToPlaylistTail(
PlayoutStatus status,
LegacyPlayoutWorkflowState workflow)
{
if (IsPlaylistMutationIdle(status, workflow))
{
return true;
}
// SetPlayList in MainForm always inserted after RowCount. Keep that PROGRAM
// behavior only while the native playlist still has one exact on-air anchor;
// the newly appended row is then guaranteed to be in the future tail.
if (string.IsNullOrWhiteSpace(status.OnAirSceneName) ||
string.IsNullOrWhiteSpace(workflow.OnAirCutCode) ||
string.IsNullOrWhiteSpace(workflow.CurrentEntryId) ||
workflow.CurrentCueIndexZeroBased < 0)
{
return false;
}
var playlist = _controller.Current.Playlist;
var currentIndex = workflow.CurrentCueIndexZeroBased;
return currentIndex < playlist.Count &&
string.Equals(
playlist[currentIndex].RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal) &&
playlist.Count(row => string.Equals(
row.RowId,
workflow.CurrentEntryId,
StringComparison.Ordinal)) == 1;
}
private static bool RequiresIdlePlaylistMutation(LegacyUiIntent intent) => intent is
LegacySetPlaylistEnabledIntent or
LegacyMoveSelectedPlaylistRowsIntent or

View File

@@ -26,6 +26,8 @@ public sealed partial class MainWindow : Window
private readonly SemaphoreSlim _intentGate = new(1, 1);
private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1);
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
private readonly SemaphoreSlim _orderedPlaylistTailMutationIntentGate = new(1, 1);
private readonly SemaphoreSlim _orderedNamedPlaylistIntentGate = new(1, 1);
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
private readonly WindowSubclassProcedure _windowSubclassProcedure;
private readonly LegacyOperatorSettingsStore _operatorSettingsStore;
@@ -446,6 +448,8 @@ public sealed partial class MainWindow : Window
var gateEntered = false;
var orderedLocalSelectionIntentEntered = false;
var orderedPlayoutIntentEntered = false;
var orderedPlaylistTailMutationIntentEntered = false;
var orderedNamedPlaylistIntentEntered = false;
var databaseGateEntered = false;
var dispatchStarted = false;
try
@@ -468,6 +472,25 @@ public sealed partial class MainWindow : Window
gateEntered = true;
}
}
else if (IsOrderedPlaylistTailMutationIntent(intent))
{
// A Delete click follows the row-selection message in the same
// WinForms UI queue. Keep exactly one delete behind that selection
// and an in-progress refresh; repeated clicks must not accumulate
// and delete a different row after the first mutation completes.
orderedPlaylistTailMutationIntentEntered =
await _orderedPlaylistTailMutationIntentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
if (orderedPlaylistTailMutationIntentEntered)
{
await _orderedLocalSelectionIntentGate.WaitAsync(
_lifetimeCancellation.Token);
orderedLocalSelectionIntentEntered = true;
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
}
else if (IsQueuedLocalSelectionIntent(intent))
{
// WinForms handled these gestures on its one UI message queue. Keep
@@ -479,6 +502,23 @@ public sealed partial class MainWindow : Window
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
else if (IsNamedPlaylistModalIntent(intent))
{
// MainForm processed its modal PList requests on the same UI queue
// as the timer refresh. Preserve one pending user request, including
// the acknowledgement produced by a completed write, behind an
// in-progress refresh. Zero-timeout admission still prevents repeated
// clicks from building an unbounded database-write queue.
orderedNamedPlaylistIntentEntered =
await _orderedNamedPlaylistIntentGate.WaitAsync(
0,
_lifetimeCancellation.Token);
if (orderedNamedPlaylistIntentEntered)
{
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
gateEntered = true;
}
}
else
{
// Keep the zero-timeout admission used by database and edit intents;
@@ -498,6 +538,13 @@ public sealed partial class MainWindow : Window
intent,
"다른 요청을 처리 중이어서 설정을 변경하지 않았습니다."));
}
else if (intent is LegacyDismissDialogIntent)
{
// A second acknowledgement while the one pending dismissal owns
// the slot is a harmless duplicate. Do not replace the success
// dialog with a synthetic busy error.
PostState(_controller.Current);
}
else
{
PostState(IsNamedPlaylistModalIntent(intent)
@@ -739,6 +786,16 @@ public sealed partial class MainWindow : Window
{
_orderedPlayoutIntentGate.Release();
}
if (orderedPlaylistTailMutationIntentEntered)
{
_orderedPlaylistTailMutationIntentGate.Release();
}
if (orderedNamedPlaylistIntentEntered)
{
_orderedNamedPlaylistIntentGate.Release();
}
}
}
@@ -756,11 +813,15 @@ public sealed partial class MainWindow : Window
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
private static bool IsOrderedPlaylistTailMutationIntent(LegacyUiIntent intent) =>
intent is LegacyDeleteSelectedPlaylistRowsIntent;
private static bool IsQueuedLocalSelectionIntent(LegacyUiIntent intent) => intent is
LegacySelectStockIntent or
LegacySelectOperatorCatalogStockIntent or
LegacyActivateOperatorCatalogStockIntent or
LegacyCutPointerDownIntent or
LegacyDropSelectedCutsIntent or
LegacyCutKeyDownIntent or
LegacyClearCutSelectionIntent or
LegacySelectPlaylistRowIntent or
@@ -780,7 +841,8 @@ public sealed partial class MainWindow : Window
LegacyCreateNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistIntent or
LegacySaveCurrentNamedPlaylistToIntent or
LegacyDeleteSelectedNamedPlaylistIntent;
LegacyDeleteSelectedNamedPlaylistIntent or
LegacyDismissDialogIntent;
private LegacyOperatorSnapshot ReportIntentFailure(
LegacyUiIntent intent,

View File

@@ -4378,6 +4378,11 @@
}
namedPlaylistConfirmationModal.hidden = false;
lockNamedPlaylistControls();
// The confirmation can open immediately after a correlated DB refresh or
// selection receipt. Re-establish its own controls here so a transient
// busy render cannot leave the affirmative action stale-disabled.
namedPlaylistConfirmationYes.disabled = false;
namedPlaylistConfirmationNo.disabled = false;
syncModalFocus();
return true;
}
@@ -4496,8 +4501,11 @@
namedPlaylistDeleteButton.disabled = modalLocked || named.canDelete !== true;
namedPlaylistConfirmButton.disabled = modalLocked ||
(namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true);
namedPlaylistConfirmationYes.disabled = operatorMutationLocked || uiBusy ||
pendingNamedPlaylistCommand !== null ||
// A PROGRAM auto-refresh can begin between pointer-down and pointer-up.
// Keep the already-open confirmation clickable through unrelated busy
// state; the native ordered gate serializes the request and performs the
// authoritative mutation-safety check before any database write.
namedPlaylistConfirmationYes.disabled = pendingNamedPlaylistCommand !== null ||
!!(namedPlaylistState && namedPlaylistState.isWriteInProgress === true);
if (deferredSelectionAction) {
runNamedPlaylistDoubleClick(
@@ -4921,7 +4929,10 @@
dialogCaption.textContent = state.dialog.caption || "MmoneyCoder";
dialogMessage.textContent = state.dialog.message;
dialogOk.textContent = legacyDialogIsConfirmation ? "예" : "확인";
dialogOk.disabled = state.isBusy === true;
// A one-button result alert is safe to acknowledge while an unrelated
// refresh is finishing. Disabling it between pointer-down and pointer-up
// swallowed real operator clicks; native dispatch now queues one dismissal.
dialogOk.disabled = legacyDialogIsConfirmation && state.isBusy === true;
dialogNo.hidden = !legacyDialogIsConfirmation;
dialogNo.disabled = state.isBusy === true;
if (dialog.hidden) rememberModalOpener(legacyAlertDialog);