feat: harden isolated Tornado test workflow
This commit is contained in:
103
Web/app.js
103
Web/app.js
@@ -1,6 +1,9 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const playoutSafety = globalThis.MbnPlayoutSafety;
|
||||
if (!playoutSafety) throw new Error("Playout safety module is unavailable.");
|
||||
|
||||
const catalog = [
|
||||
{ code: "5001", title: "1열판 · 기본", detail: "종목 현재가 / 등락", category: "plate", market: "kospi" },
|
||||
{ code: "5006", title: "1열판 · 시가", detail: "시가 정보", category: "plate", market: "kosdaq" },
|
||||
@@ -39,9 +42,14 @@
|
||||
playout: {
|
||||
mode: "unknown",
|
||||
engineState: "IDLE",
|
||||
connectionState: "unavailable",
|
||||
processDetected: false,
|
||||
connected: false,
|
||||
commandAvailable: false,
|
||||
liveTakeInAllowed: false,
|
||||
nativeOutcomeUnknown: false,
|
||||
operationTimeoutMs: 5000,
|
||||
responseTimeoutMs: 10000,
|
||||
message: "송출 어댑터 상태를 확인하고 있습니다.",
|
||||
preparedCode: null,
|
||||
onAirCode: null,
|
||||
@@ -352,7 +360,9 @@
|
||||
}
|
||||
|
||||
function playoutDisplayState() {
|
||||
return isDryRunMode() ? "DRY RUN" : state.playout.engineState;
|
||||
if (isDryRunMode()) return "DRY RUN";
|
||||
if (isTestMode() && state.playout.engineState === "PROGRAM") return "TEST ON AIR";
|
||||
return state.playout.engineState;
|
||||
}
|
||||
|
||||
function setStatusDot(element, status) {
|
||||
@@ -379,22 +389,40 @@
|
||||
const liveMode = isLiveMode();
|
||||
const testMode = isTestMode();
|
||||
const bridgeReady = Boolean(nativeBridge);
|
||||
const outcomeUnknown = state.playout.error?.outcomeUnknown === true;
|
||||
const commandReady = bridgeReady && !pending && !outcomeUnknown && (state.playout.connected || dryRun);
|
||||
const outcomeUnknown = state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true;
|
||||
const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable;
|
||||
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
|
||||
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
|
||||
const onAir = state.playout.engineState === "PROGRAM" || Boolean(state.playout.onAirCode);
|
||||
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
|
||||
|
||||
elements.playoutState.textContent = displayState;
|
||||
elements.playoutState.classList.remove("neutral", "live", "dry-run", "error");
|
||||
elements.playoutState.classList.remove("neutral", "live", "test-on-air", "dry-run", "error");
|
||||
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
|
||||
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
|
||||
if (displayState === "TEST ON AIR") elements.playoutState.classList.add("test-on-air");
|
||||
if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");
|
||||
|
||||
elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
|
||||
setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
|
||||
elements.playoutConnectionState.textContent = state.playout.connected ? "연결됨" : "미연결";
|
||||
setStatusDot(elements.playoutConnectionDot, state.playout.connected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
|
||||
const transitionalConnection = ["connecting", "reconnecting", "disconnecting"].includes(state.playout.connectionState);
|
||||
const connectionLabels = {
|
||||
"disabled": "Disabled",
|
||||
"dry-run-ready": "DryRunReady",
|
||||
"disconnected": "Disconnected",
|
||||
"connecting": "Connecting",
|
||||
"connected": "Connected",
|
||||
"reconnecting": "Reconnecting",
|
||||
"disconnecting": "Disconnecting",
|
||||
"faulted": "Faulted",
|
||||
"outcome-unknown": "OutcomeUnknown",
|
||||
"disposed": "Disposed",
|
||||
"unavailable": "Unavailable"
|
||||
};
|
||||
elements.playoutConnectionState.textContent = connectionLabels[state.playout.connectionState] || "Unavailable";
|
||||
setStatusDot(elements.playoutConnectionDot, state.playout.connected
|
||||
? "healthy"
|
||||
: (transitionalConnection ? "warn" : (bridgeReady ? "error" : "unconfigured")));
|
||||
|
||||
const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
|
||||
safetyContainer.classList.remove("live-allowed", "live-locked");
|
||||
@@ -423,10 +451,14 @@
|
||||
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
|
||||
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${changedLabel}`;
|
||||
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
|
||||
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error));
|
||||
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown);
|
||||
|
||||
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
|
||||
if (dryRun) {
|
||||
if (state.playout.nativeOutcomeUnknown) {
|
||||
elements.playoutSummary.classList.add("error");
|
||||
elements.playoutSummaryBadge.textContent = "UNKNOWN";
|
||||
elements.playoutSummaryDetail.textContent = "출력 상태 확인 후 앱 재시작 필요";
|
||||
} else if (dryRun) {
|
||||
elements.playoutSummary.classList.add("dry-run");
|
||||
elements.playoutSummaryBadge.textContent = "DRY RUN";
|
||||
elements.playoutSummaryDetail.textContent = bridgeReady ? "안전 모드 · PROGRAM 출력 차단" : "브라우저 미리보기 전용";
|
||||
@@ -446,7 +478,7 @@
|
||||
|
||||
elements.prepareButton.disabled = !commandReady || !currentCue;
|
||||
elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe;
|
||||
elements.nextButton.disabled = !commandReady || state.playlist.length === 0;
|
||||
elements.nextButton.disabled = !commandReady || !onAir || state.playlist.length === 0;
|
||||
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
|
||||
for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
|
||||
button.setAttribute("aria-busy", String(Boolean(pending)));
|
||||
@@ -454,7 +486,9 @@
|
||||
|
||||
elements.playoutError.hidden = !state.playout.error;
|
||||
elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
|
||||
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown ? "확인 후 잠금 해제" : "닫기";
|
||||
document.querySelector("#clearPlayoutErrorButton").textContent = state.playout.nativeOutcomeUnknown
|
||||
? "닫기 · 잠금 유지"
|
||||
: (outcomeUnknown ? "확인 후 잠금 해제" : "닫기");
|
||||
}
|
||||
|
||||
function clearPlayoutError(render = true) {
|
||||
@@ -496,6 +530,25 @@
|
||||
return;
|
||||
}
|
||||
if (state.playout.pending) return;
|
||||
const blockReason = playoutSafety.commandBlockReason({
|
||||
pending: state.playout.pending,
|
||||
outcomeUnknown: state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true,
|
||||
commandAvailable: state.playout.commandAvailable,
|
||||
engineState: state.playout.engineState,
|
||||
preparedCode: state.playout.preparedCode,
|
||||
onAirCode: state.playout.onAirCode
|
||||
}, command);
|
||||
if (blockReason) {
|
||||
const message = {
|
||||
"outcome-unknown": "출력 상태가 불명확합니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
|
||||
"unavailable": "Tornado 송출 명령을 사용할 수 없는 상태입니다.",
|
||||
"next-requires-on-air": "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.",
|
||||
"take-in-requires-prepare": "먼저 PREPARE를 완료하세요.",
|
||||
"nothing-to-take-out": "종료할 송출 장면이 없습니다."
|
||||
}[blockReason] || "다른 송출 명령을 처리하고 있습니다.";
|
||||
showToast(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveCommand(command);
|
||||
if (!resolved) {
|
||||
@@ -518,7 +571,9 @@
|
||||
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
|
||||
|
||||
const payload = { requestId, command };
|
||||
if (resolved.cue) payload.cue = resolved.cue;
|
||||
if (resolved.cue && (command === "prepare" || command === "next")) {
|
||||
payload.cue = { code: resolved.cue.code };
|
||||
}
|
||||
postNative("playout-command", payload);
|
||||
|
||||
pending.timeoutId = setTimeout(() => {
|
||||
@@ -527,12 +582,12 @@
|
||||
showPlayoutError({
|
||||
code: "WEB_TIMEOUT",
|
||||
message: "송출 엔진 응답 시간이 초과되었습니다.",
|
||||
retryable: true,
|
||||
retryable: false,
|
||||
outcomeUnknown: true
|
||||
});
|
||||
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
|
||||
requestPlayoutStatus();
|
||||
}, 15000);
|
||||
}, state.playout.responseTimeoutMs);
|
||||
}
|
||||
|
||||
function requestPlayoutStatus() {
|
||||
@@ -556,9 +611,17 @@
|
||||
|
||||
state.playout.mode = normalizePlayoutMode(payload.mode);
|
||||
state.playout.engineState = normalizePlayoutState(payload.state);
|
||||
state.playout.connectionState = playoutSafety.normalizeConnectionState(payload.connectionState);
|
||||
state.playout.processDetected = payload.processDetected === true;
|
||||
state.playout.connected = payload.connected === true;
|
||||
state.playout.commandAvailable = payload.commandAvailable === true;
|
||||
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
|
||||
state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true ||
|
||||
state.playout.connectionState === "outcome-unknown";
|
||||
state.playout.operationTimeoutMs = Number.isFinite(Number(payload.operationTimeoutMilliseconds))
|
||||
? Number(payload.operationTimeoutMilliseconds)
|
||||
: state.playout.operationTimeoutMs;
|
||||
state.playout.responseTimeoutMs = playoutSafety.responseTimeoutFromNative(payload.operationTimeoutMilliseconds);
|
||||
state.playout.message = typeof payload.message === "string" && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: "Tornado 송출 상태를 수신했습니다.";
|
||||
@@ -569,8 +632,17 @@
|
||||
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
|
||||
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
|
||||
|
||||
if (state.playout.nativeOutcomeUnknown && state.playout.error?.outcomeUnknown !== true) {
|
||||
state.playout.error = {
|
||||
code: "OUTCOME_UNKNOWN",
|
||||
message: "송출 결과를 확인할 수 없습니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
|
||||
retryable: false,
|
||||
outcomeUnknown: true
|
||||
};
|
||||
}
|
||||
|
||||
renderPlayout();
|
||||
const signature = [state.playout.mode, state.playout.engineState, state.playout.processDetected, state.playout.connected, state.playout.liveTakeInAllowed].join("|");
|
||||
const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.commandAvailable, state.playout.liveTakeInAllowed, state.playout.nativeOutcomeUnknown].join("|");
|
||||
if (signature !== state.playout.lastSignature) {
|
||||
addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
|
||||
state.playout.lastSignature = signature;
|
||||
@@ -579,7 +651,7 @@
|
||||
|
||||
function finishPendingCommand(payload) {
|
||||
const pending = state.playout.pending;
|
||||
if (!pending || payload?.requestId !== pending.requestId || payload?.command !== pending.command) return null;
|
||||
if (!playoutSafety.matchesPendingCommand(pending, payload)) return null;
|
||||
clearTimeout(pending.timeoutId);
|
||||
state.playout.pending = null;
|
||||
return pending;
|
||||
@@ -625,6 +697,7 @@
|
||||
function handlePlayoutCommandError(payload) {
|
||||
const pending = finishPendingCommand(payload);
|
||||
if (!pending) return;
|
||||
if (payload.outcomeUnknown === true) state.playout.nativeOutcomeUnknown = true;
|
||||
showPlayoutError({
|
||||
code: payload.code,
|
||||
message: payload.message,
|
||||
|
||||
Reference in New Issue
Block a user