기존 커밋
This commit is contained in:
@@ -264,7 +264,8 @@
|
|||||||
Background="#101C2E"
|
Background="#101C2E"
|
||||||
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
CornerRadius="8">
|
CornerRadius="8"
|
||||||
|
Visibility="{x:Bind ViewModel.CutDebugPanelVisibility, Mode=OneWay}">
|
||||||
<StackPanel Spacing="12">
|
<StackPanel Spacing="12">
|
||||||
<Grid ColumnSpacing="12">
|
<Grid ColumnSpacing="12">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Tornado3_2026Election.Domain;
|
|||||||
|
|
||||||
public sealed class CutDebugSettings : ObservableObject
|
public sealed class CutDebugSettings : ObservableObject
|
||||||
{
|
{
|
||||||
|
private bool _isFeatureEnabled = true;
|
||||||
private bool _isEnabled;
|
private bool _isEnabled;
|
||||||
private bool _applyTextValues = true;
|
private bool _applyTextValues = true;
|
||||||
private bool _applyImageValues = true;
|
private bool _applyImageValues = true;
|
||||||
@@ -14,6 +15,18 @@ public sealed class CutDebugSettings : ObservableObject
|
|||||||
private bool _applyPartyPlateStyleColors = true;
|
private bool _applyPartyPlateStyleColors = true;
|
||||||
private bool _applyVoteRateStyleColors = true;
|
private bool _applyVoteRateStyleColors = true;
|
||||||
|
|
||||||
|
public bool IsFeatureEnabled
|
||||||
|
{
|
||||||
|
get => _isFeatureEnabled;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isFeatureEnabled, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsEffectiveEnabled), nameof(Summary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsEnabled
|
public bool IsEnabled
|
||||||
{
|
{
|
||||||
get => _isEnabled;
|
get => _isEnabled;
|
||||||
@@ -21,11 +34,13 @@ public sealed class CutDebugSettings : ObservableObject
|
|||||||
{
|
{
|
||||||
if (SetProperty(ref _isEnabled, value))
|
if (SetProperty(ref _isEnabled, value))
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(Summary));
|
OnPropertyChanged(nameof(IsEffectiveEnabled), nameof(Summary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsEffectiveEnabled => IsFeatureEnabled && IsEnabled;
|
||||||
|
|
||||||
public bool ApplyTextValues
|
public bool ApplyTextValues
|
||||||
{
|
{
|
||||||
get => _applyTextValues;
|
get => _applyTextValues;
|
||||||
@@ -129,7 +144,7 @@ public sealed class CutDebugSettings : ObservableObject
|
|||||||
public CutDebugSettingsSnapshot CreateSnapshot()
|
public CutDebugSettingsSnapshot CreateSnapshot()
|
||||||
{
|
{
|
||||||
return new CutDebugSettingsSnapshot(
|
return new CutDebugSettingsSnapshot(
|
||||||
IsEnabled,
|
IsEffectiveEnabled,
|
||||||
ApplyTextValues,
|
ApplyTextValues,
|
||||||
ApplyImageValues,
|
ApplyImageValues,
|
||||||
ApplyVisibilityValues,
|
ApplyVisibilityValues,
|
||||||
|
|||||||
@@ -1005,6 +1005,10 @@
|
|||||||
<CheckBox Content="스케줄 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreSchedules, Mode=TwoWay}" />
|
<CheckBox Content="스케줄 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreSchedules, Mode=TwoWay}" />
|
||||||
<CheckBox Content="방송사 설정 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStations, Mode=TwoWay}" />
|
<CheckBox Content="방송사 설정 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStations, Mode=TwoWay}" />
|
||||||
<CheckBox Content="상태값 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStatusValues, Mode=TwoWay}" />
|
<CheckBox Content="상태값 복원" IsChecked="{x:Bind ViewModel.RestoreSelection.RestoreStatusValues, Mode=TwoWay}" />
|
||||||
|
<ToggleSwitch Header="디버그 기능"
|
||||||
|
IsOn="{x:Bind ViewModel.Settings.IsDebugFeaturesEnabled, Mode=TwoWay}"
|
||||||
|
OffContent="OFF"
|
||||||
|
OnContent="ON" />
|
||||||
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
<ToggleSwitch Header="API 자동 갱신" IsOn="{x:Bind ViewModel.Data.IsPollingEnabled, Mode=TwoWay}" />
|
||||||
<NumberBox Header="API 갱신 주기(초)"
|
<NumberBox Header="API 갱신 주기(초)"
|
||||||
Minimum="3"
|
Minimum="3"
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ public sealed class AppState
|
|||||||
|
|
||||||
public bool IsWindowMaximized { get; set; }
|
public bool IsWindowMaximized { get; set; }
|
||||||
|
|
||||||
|
public bool IsDebugFeaturesEnabled { get; set; } = true;
|
||||||
|
|
||||||
public bool IsPollingEnabled { get; set; } = true;
|
public bool IsPollingEnabled { get; set; } = true;
|
||||||
|
|
||||||
public int PollingIntervalSeconds { get; set; } = 60;
|
public int PollingIntervalSeconds { get; set; } = 60;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public sealed class CutDebugStateStore
|
|||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
private readonly Dictionary<BroadcastChannel, CutDebugSettings> _settingsByChannel = new();
|
private readonly Dictionary<BroadcastChannel, CutDebugSettings> _settingsByChannel = new();
|
||||||
private readonly Dictionary<string, CutDebugTemplateState> _templateStates = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, CutDebugTemplateState> _templateStates = new(StringComparer.Ordinal);
|
||||||
|
private bool _isDebugFeatureEnabled = true;
|
||||||
|
|
||||||
public CutDebugStateStore()
|
public CutDebugStateStore()
|
||||||
{
|
{
|
||||||
@@ -23,6 +24,20 @@ public sealed class CutDebugStateStore
|
|||||||
return _settingsByChannel[channel];
|
return _settingsByChannel[channel];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetDebugFeatureEnabled(bool isEnabled)
|
||||||
|
{
|
||||||
|
if (_isDebugFeatureEnabled == isEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDebugFeatureEnabled = isEnabled;
|
||||||
|
foreach (var settings in _settingsByChannel.Values)
|
||||||
|
{
|
||||||
|
settings.IsFeatureEnabled = isEnabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public CutDebugTemplateState GetTemplate(BroadcastChannel channel, string formatId, string displayName)
|
public CutDebugTemplateState GetTemplate(BroadcastChannel channel, string formatId, string displayName)
|
||||||
{
|
{
|
||||||
var templateKey = BuildTemplateKey(channel, formatId);
|
var templateKey = BuildTemplateKey(channel, formatId);
|
||||||
|
|||||||
@@ -203,7 +203,9 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable
|
|||||||
var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables);
|
var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables);
|
||||||
var careerPromiseVisibilityUpdates = BuildCareerPromiseVisibilityUpdates(template, cut, snapshot, sceneVariables);
|
var careerPromiseVisibilityUpdates = BuildCareerPromiseVisibilityUpdates(template, cut, snapshot, sceneVariables);
|
||||||
var cutDebug = _cutDebugStateStore.Get(channel).CreateSnapshot();
|
var cutDebug = _cutDebugStateStore.Get(channel).CreateSnapshot();
|
||||||
var templateDebug = _cutDebugStateStore.FindTemplate(channel, template.Id);
|
var templateDebug = cutDebug.IsEnabled
|
||||||
|
? _cutDebugStateStore.FindTemplate(channel, template.Id)
|
||||||
|
: null;
|
||||||
var filteredValues = FilterObjectValues(values, sceneVariables, cutDebug, templateDebug);
|
var filteredValues = FilterObjectValues(values, sceneVariables, cutDebug, templateDebug);
|
||||||
var filteredCounterNumberKeys = FilterCounterNumberKeyUpdates(counterNumberKeys, cutDebug, templateDebug);
|
var filteredCounterNumberKeys = FilterCounterNumberKeyUpdates(counterNumberKeys, cutDebug, templateDebug);
|
||||||
var filteredStyleColorUpdates = FilterStyleColorUpdates(styleColorUpdates, cutDebug, templateDebug);
|
var filteredStyleColorUpdates = FilterStyleColorUpdates(styleColorUpdates, cutDebug, templateDebug);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.Linq;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Media;
|
using Microsoft.UI.Xaml.Media;
|
||||||
using Tornado3_2026Election.Common;
|
using Tornado3_2026Election.Common;
|
||||||
using Tornado3_2026Election.Domain;
|
using Tornado3_2026Election.Domain;
|
||||||
@@ -80,7 +81,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
_engine.QueueChanged += (_, _) => RefreshSummary();
|
_engine.QueueChanged += (_, _) => RefreshSummary();
|
||||||
_adapter.StateChanged += (_, _) => RefreshSummary();
|
_adapter.StateChanged += (_, _) => RefreshSummary();
|
||||||
_adapter.ConnectionChanged += (_, _) => RefreshSummary();
|
_adapter.ConnectionChanged += (_, _) => RefreshSummary();
|
||||||
CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary));
|
CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary), nameof(CutDebugPanelVisibility));
|
||||||
_data.PropertyChanged += Data_PropertyChanged;
|
_data.PropertyChanged += Data_PropertyChanged;
|
||||||
Queue.CollectionChanged += Queue_CollectionChanged;
|
Queue.CollectionChanged += Queue_CollectionChanged;
|
||||||
|
|
||||||
@@ -263,6 +264,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject
|
|||||||
|
|
||||||
public string CutDebugSummary => CutDebug.Summary;
|
public string CutDebugSummary => CutDebug.Summary;
|
||||||
|
|
||||||
|
public Visibility CutDebugPanelVisibility => CutDebug.IsFeatureEnabled ? Visibility.Visible : Visibility.Collapsed;
|
||||||
|
|
||||||
public ObservableCollection<CutDebugItemState> CutDebugItems => _selectedCutDebugTemplate?.Items ?? _emptyCutDebugItems;
|
public ObservableCollection<CutDebugItemState> CutDebugItems => _selectedCutDebugTemplate?.Items ?? _emptyCutDebugItems;
|
||||||
|
|
||||||
public int CutDebugItemCount => CutDebugItems.Count;
|
public int CutDebugItemCount => CutDebugItems.Count;
|
||||||
|
|||||||
@@ -996,18 +996,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var requiredCandidateCount = ResolveRequiredCandidateCount(template);
|
|
||||||
if (requiredCandidateCount > 0 && validCandidates.Length < requiredCandidateCount)
|
|
||||||
{
|
|
||||||
errorMessage = $"'{template.Name}' 컷은 후보 {requiredCandidateCount}명 이상이 필요합니다.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.BroadcastPhase == BroadcastPhase.Counting)
|
if (snapshot.BroadcastPhase == BroadcastPhase.Counting)
|
||||||
{
|
{
|
||||||
if (snapshot.CountedVotes <= 0 || snapshot.CountedRate <= 0)
|
if (snapshot.CountedVotes <= 0 && snapshot.CountedRate <= 0)
|
||||||
{
|
{
|
||||||
errorMessage = "개표 데이터가 0이라 송출할 수 없습니다.";
|
errorMessage = "개표수와 개표율이 모두 0이라 송출할 수 없습니다.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,9 +87,10 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
_selectedCutListFilterOption = CutListFilterOptions[0];
|
_selectedCutListFilterOption = CutListFilterOptions[0];
|
||||||
_selectedCutListCategoryOption = CutListCategoryOptions[0];
|
_selectedCutListCategoryOption = CutListCategoryOptions[0];
|
||||||
|
|
||||||
|
_cutDebugStateStore = new CutDebugStateStore();
|
||||||
|
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
|
||||||
Settings.PropertyChanged += Settings_PropertyChanged;
|
Settings.PropertyChanged += Settings_PropertyChanged;
|
||||||
Data.PropertyChanged += Data_PropertyChanged;
|
Data.PropertyChanged += Data_PropertyChanged;
|
||||||
_cutDebugStateStore = new CutDebugStateStore();
|
|
||||||
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath, _cutDebugStateStore);
|
_sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath, _cutDebugStateStore);
|
||||||
|
|
||||||
NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter);
|
NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter);
|
||||||
@@ -700,6 +701,11 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs args)
|
private void Settings_PropertyChanged(object? sender, PropertyChangedEventArgs args)
|
||||||
{
|
{
|
||||||
|
if (args.PropertyName is nameof(SettingsViewModel.IsDebugFeaturesEnabled))
|
||||||
|
{
|
||||||
|
_cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.PropertyName is nameof(SettingsViewModel.SelectedStation) or nameof(SettingsViewModel.SelectedStationId))
|
if (args.PropertyName is nameof(SettingsViewModel.SelectedStation) or nameof(SettingsViewModel.SelectedStationId))
|
||||||
{
|
{
|
||||||
var selectedStationProfile = Settings.BuildSelectedStationProfile();
|
var selectedStationProfile = Settings.BuildSelectedStationProfile();
|
||||||
@@ -801,6 +807,8 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
|
|
||||||
private void ApplyState(AppState state)
|
private void ApplyState(AppState state)
|
||||||
{
|
{
|
||||||
|
Settings.IsDebugFeaturesEnabled = state.IsDebugFeaturesEnabled;
|
||||||
|
|
||||||
if (RestoreSelection.RestoreStations)
|
if (RestoreSelection.RestoreStations)
|
||||||
{
|
{
|
||||||
Settings.SelectedStationId = state.SelectedStationId;
|
Settings.SelectedStationId = state.SelectedStationId;
|
||||||
@@ -947,6 +955,7 @@ public sealed class MainViewModel : ObservableObject
|
|||||||
WindowWidth = _windowWidth ?? 0,
|
WindowWidth = _windowWidth ?? 0,
|
||||||
WindowHeight = _windowHeight ?? 0,
|
WindowHeight = _windowHeight ?? 0,
|
||||||
IsWindowMaximized = _isWindowMaximized,
|
IsWindowMaximized = _isWindowMaximized,
|
||||||
|
IsDebugFeaturesEnabled = Settings.IsDebugFeaturesEnabled,
|
||||||
OperationMode = OperationMode.ToString(),
|
OperationMode = OperationMode.ToString(),
|
||||||
BroadcastPhase = Data.BroadcastPhase.ToString(),
|
BroadcastPhase = Data.BroadcastPhase.ToString(),
|
||||||
IsPollingEnabled = Data.IsPollingEnabled,
|
IsPollingEnabled = Data.IsPollingEnabled,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public sealed class SettingsViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private string _selectedStationId = "KNN";
|
private string _selectedStationId = "KNN";
|
||||||
private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
|
private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
|
private bool _isDebugFeaturesEnabled = true;
|
||||||
private readonly IReadOnlyList<SelectionOption<VideoWallLayoutPreset>> _videoWallLayoutOptions =
|
private readonly IReadOnlyList<SelectionOption<VideoWallLayoutPreset>> _videoWallLayoutOptions =
|
||||||
[
|
[
|
||||||
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Auto, "자동"),
|
new SelectionOption<VideoWallLayoutPreset>(VideoWallLayoutPreset.Auto, "자동"),
|
||||||
@@ -73,6 +74,12 @@ public sealed class SettingsViewModel : ObservableObject
|
|||||||
set => SetProperty(ref _imageRootPath, TornadoPathResolver.NormalizeConfiguredPath(value));
|
set => SetProperty(ref _imageRootPath, TornadoPathResolver.NormalizeConfiguredPath(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsDebugFeaturesEnabled
|
||||||
|
{
|
||||||
|
get => _isDebugFeaturesEnabled;
|
||||||
|
set => SetProperty(ref _isDebugFeaturesEnabled, value);
|
||||||
|
}
|
||||||
|
|
||||||
public StationFilterItemViewModel SelectedStation
|
public StationFilterItemViewModel SelectedStation
|
||||||
=> Stations.FirstOrDefault(station => station.Id == SelectedStationId) ?? Stations[0];
|
=> Stations.FirstOrDefault(station => station.Id == SelectedStationId) ?? Stations[0];
|
||||||
|
|
||||||
|
|||||||
755
tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs
Normal file
755
tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Tornado3_2026Election.Domain;
|
||||||
|
using Tornado3_2026Election.Services;
|
||||||
|
|
||||||
|
internal static class CurrentApiCutDiagnostics
|
||||||
|
{
|
||||||
|
private static readonly Regex TopRankSlotCountPattern = new(@"1-(\d+)위", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex PeopleSlotCountPattern = new(@"(\d+)인", RegexOptions.Compiled);
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> RegionAliases = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["서울"] = "서울",
|
||||||
|
["서울특별시"] = "서울",
|
||||||
|
["부산"] = "부산",
|
||||||
|
["부산광역시"] = "부산",
|
||||||
|
["대구"] = "대구",
|
||||||
|
["대구광역시"] = "대구",
|
||||||
|
["인천"] = "인천",
|
||||||
|
["인천광역시"] = "인천",
|
||||||
|
["광주"] = "광주",
|
||||||
|
["광주광역시"] = "광주",
|
||||||
|
["대전"] = "대전",
|
||||||
|
["대전광역시"] = "대전",
|
||||||
|
["울산"] = "울산",
|
||||||
|
["울산광역시"] = "울산",
|
||||||
|
["세종"] = "세종",
|
||||||
|
["세종특별자치시"] = "세종",
|
||||||
|
["경기"] = "경기",
|
||||||
|
["경기도"] = "경기",
|
||||||
|
["강원"] = "강원",
|
||||||
|
["강원도"] = "강원",
|
||||||
|
["강원특별자치도"] = "강원",
|
||||||
|
["충북"] = "충북",
|
||||||
|
["충청북도"] = "충북",
|
||||||
|
["충남"] = "충남",
|
||||||
|
["충청남도"] = "충남",
|
||||||
|
["전북"] = "전북",
|
||||||
|
["전라북도"] = "전북",
|
||||||
|
["전북특별자치도"] = "전북",
|
||||||
|
["전남"] = "전남",
|
||||||
|
["전라남도"] = "전남",
|
||||||
|
["경북"] = "경북",
|
||||||
|
["경상북도"] = "경북",
|
||||||
|
["경남"] = "경남",
|
||||||
|
["경상남도"] = "경남",
|
||||||
|
["제주"] = "제주",
|
||||||
|
["제주도"] = "제주",
|
||||||
|
["제주특별자치도"] = "제주"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task<int> RunAsync(string[] args)
|
||||||
|
{
|
||||||
|
var options = CurrentApiCutDiagnosticsOptions.Parse(args);
|
||||||
|
Directory.CreateDirectory(options.OutputPath);
|
||||||
|
|
||||||
|
Console.WriteLine("Current API cut diagnostics starting.");
|
||||||
|
Console.WriteLine($"- Phase: {options.Phase}");
|
||||||
|
Console.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}");
|
||||||
|
Console.WriteLine($"- Region Scope: {options.RegionScope}");
|
||||||
|
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
|
||||||
|
Console.WriteLine($"- Simulated Sends: {(options.SimulateSend ? options.SendLimit.ToString() : "off")}");
|
||||||
|
Console.WriteLine($"- Output: {options.OutputPath}");
|
||||||
|
|
||||||
|
var stationCatalog = new StationCatalogService().GetAll();
|
||||||
|
var stations = options.AllStations
|
||||||
|
? stationCatalog
|
||||||
|
: stationCatalog
|
||||||
|
.Where(station => string.Equals(station.Id, options.StationId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (stations.Count == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"No station matched '{options.StationId}'.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var formats = new FormatCatalogService(options.ImageRootPath)
|
||||||
|
.GetAll()
|
||||||
|
.Where(template => options.IncludeVideoWall || template.RecommendedChannel != BroadcastChannel.VideoWall)
|
||||||
|
.Where(template => string.IsNullOrWhiteSpace(options.Filter) ||
|
||||||
|
template.Id.Contains(options.Filter, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
template.Name.Contains(options.Filter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (options.TemplateLimit is int templateLimit && templateLimit > 0)
|
||||||
|
{
|
||||||
|
formats = formats.Take(templateLimit).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
var phase = options.Phase == BroadcastPhase.PreElection
|
||||||
|
? BroadcastPhase.PreElection
|
||||||
|
: BroadcastPhase.Counting;
|
||||||
|
formats = formats
|
||||||
|
.Where(template => template.IsAvailableInPhase(phase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
Console.WriteLine($"- Templates: {formats.Length}");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
using var apiClient = new SbsElectionApiClient();
|
||||||
|
var logService = new LogService();
|
||||||
|
var adapter = options.SimulateSend ? new MockTornado3Adapter(logService) : null;
|
||||||
|
var districtCache = new Dictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>>(StringComparer.Ordinal);
|
||||||
|
var results = new List<CurrentApiCutDiagnosticResult>();
|
||||||
|
var simulatedSendCount = 0;
|
||||||
|
|
||||||
|
foreach (var station in stations)
|
||||||
|
{
|
||||||
|
foreach (var template in formats)
|
||||||
|
{
|
||||||
|
var electionType = ResolveScheduleElectionType(template.Name, phase, options.DefaultElectionType);
|
||||||
|
var districts = await GetDistrictsAsync(apiClient, districtCache, electionType).ConfigureAwait(false);
|
||||||
|
var targets = ResolveTargets(districts, station, options)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (targets.Length == 0)
|
||||||
|
{
|
||||||
|
results.Add(CurrentApiCutDiagnosticResult.NoTarget(station, template, phase, electionType));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
var result = new CurrentApiCutDiagnosticResult
|
||||||
|
{
|
||||||
|
Station = station.Id,
|
||||||
|
Channel = template.RecommendedChannel.ToString(),
|
||||||
|
TemplateId = template.Id,
|
||||||
|
TemplateName = template.Name,
|
||||||
|
Phase = phase.ToString(),
|
||||||
|
ElectionType = electionType,
|
||||||
|
Region = target.DisplayName,
|
||||||
|
DistrictCode = target.DistrictCode,
|
||||||
|
Status = "unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var refreshResult = await apiClient
|
||||||
|
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
var snapshot = CreateSnapshot(phase, electionType, refreshResult);
|
||||||
|
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
|
||||||
|
|
||||||
|
if (!ValidateSnapshotForFormat(template, snapshot, out var validationError, out var warning))
|
||||||
|
{
|
||||||
|
result.Status = "validation-failed";
|
||||||
|
result.Detail = validationError;
|
||||||
|
result.Warning = warning;
|
||||||
|
}
|
||||||
|
else if (adapter is not null && simulatedSendCount < options.SendLimit)
|
||||||
|
{
|
||||||
|
await SimulateSendAsync(adapter, station, template, snapshot, options.ImageRootPath).ConfigureAwait(false);
|
||||||
|
simulatedSendCount++;
|
||||||
|
result.Status = "sent-mock";
|
||||||
|
result.Detail = "validated and mock send completed";
|
||||||
|
result.Warning = warning;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Status = "valid";
|
||||||
|
result.Detail = adapter is null ? "validated" : "validated; send limit reached";
|
||||||
|
result.Warning = warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Status = "api-or-send-failed";
|
||||||
|
result.Detail = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteReports(options, results);
|
||||||
|
PrintSummary(results, options.OutputPath);
|
||||||
|
|
||||||
|
return results.Any(result => result.Status is "validation-failed" or "api-or-send-failed" or "no-target")
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
|
||||||
|
SbsElectionApiClient apiClient,
|
||||||
|
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
|
||||||
|
string electionType)
|
||||||
|
{
|
||||||
|
if (!districtCache.TryGetValue(electionType, out var districts))
|
||||||
|
{
|
||||||
|
districts = await apiClient.GetDistrictOptionsAsync(electionType, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
districtCache[electionType] = districts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return districts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<SbsElectionApiClient.DistrictSelectionOption> ResolveTargets(
|
||||||
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts,
|
||||||
|
BroadcastStationProfile station,
|
||||||
|
CurrentApiCutDiagnosticsOptions options)
|
||||||
|
{
|
||||||
|
IEnumerable<SbsElectionApiClient.DistrictSelectionOption> targets = options.RegionScope switch
|
||||||
|
{
|
||||||
|
"all" => districts,
|
||||||
|
_ => ResolveStationTargets(districts, station)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.MaxRegions > 0)
|
||||||
|
{
|
||||||
|
targets = targets.Take(options.MaxRegions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<SbsElectionApiClient.DistrictSelectionOption> ResolveStationTargets(
|
||||||
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts,
|
||||||
|
BroadcastStationProfile station)
|
||||||
|
{
|
||||||
|
var configuredRegions = station.RegionFilters
|
||||||
|
.Select(NormalizeRegion)
|
||||||
|
.Where(region => !string.IsNullOrWhiteSpace(region))
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (configuredRegions.Count == 0)
|
||||||
|
{
|
||||||
|
return districts;
|
||||||
|
}
|
||||||
|
|
||||||
|
return districts.Where(district => configuredRegions.Contains(NormalizeRegion(district.RegionName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ElectionDataSnapshot CreateSnapshot(
|
||||||
|
BroadcastPhase phase,
|
||||||
|
string electionType,
|
||||||
|
SbsElectionApiClient.SbsElectionRefreshResult refreshResult)
|
||||||
|
{
|
||||||
|
var districtName = string.IsNullOrWhiteSpace(refreshResult.DistrictName)
|
||||||
|
? refreshResult.ElectionDistrictName
|
||||||
|
: refreshResult.DistrictName;
|
||||||
|
var regionName = string.IsNullOrWhiteSpace(refreshResult.RegionName)
|
||||||
|
? districtName
|
||||||
|
: refreshResult.RegionName;
|
||||||
|
var electionDistrictName = string.IsNullOrWhiteSpace(refreshResult.ElectionDistrictName)
|
||||||
|
? districtName
|
||||||
|
: refreshResult.ElectionDistrictName;
|
||||||
|
|
||||||
|
return new ElectionDataSnapshot
|
||||||
|
{
|
||||||
|
BroadcastPhase = phase,
|
||||||
|
ElectionType = electionType,
|
||||||
|
DistrictName = districtName ?? string.Empty,
|
||||||
|
DistrictCode = refreshResult.DistrictCode ?? string.Empty,
|
||||||
|
RegionName = regionName ?? string.Empty,
|
||||||
|
ElectionDistrictName = electionDistrictName ?? string.Empty,
|
||||||
|
Candidates = refreshResult.Candidates ?? Array.Empty<CandidateEntry>(),
|
||||||
|
TotalExpectedVotes = refreshResult.TotalExpectedVotes,
|
||||||
|
TurnoutVotes = refreshResult.TurnoutVotes,
|
||||||
|
CountedVotesFromApi = refreshResult.CountedVotes,
|
||||||
|
RemainingVotesFromApi = refreshResult.RemainingVotes,
|
||||||
|
CountedRateFromApi = refreshResult.CountedRate,
|
||||||
|
ReceivedAt = refreshResult.ReceivedAt == default ? DateTimeOffset.Now : refreshResult.ReceivedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SimulateSendAsync(
|
||||||
|
ITornado3Adapter adapter,
|
||||||
|
BroadcastStationProfile station,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
string imageRootPath)
|
||||||
|
{
|
||||||
|
foreach (var cut in template.Cuts)
|
||||||
|
{
|
||||||
|
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, imageRootPath, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
await adapter.OutAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValidateSnapshotForFormat(
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
out string errorMessage,
|
||||||
|
out string warning)
|
||||||
|
{
|
||||||
|
warning = string.Empty;
|
||||||
|
|
||||||
|
if (IsTurnoutTemplate(template) &&
|
||||||
|
(snapshot.TurnoutVotes <= 0 || snapshot.TurnoutRate <= 0))
|
||||||
|
{
|
||||||
|
errorMessage = "turnout votes/rate is zero";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template.RequiresCandidateData)
|
||||||
|
{
|
||||||
|
errorMessage = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var validCandidates = snapshot.Candidates
|
||||||
|
.Where(IsCandidateReadyForBroadcast)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (validCandidates.Length == 0)
|
||||||
|
{
|
||||||
|
errorMessage = "candidate list is empty";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validCandidates.Length != snapshot.Candidates.Count)
|
||||||
|
{
|
||||||
|
errorMessage = "required candidate fields are blank";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiredCandidateCount = ResolveRequiredCandidateCount(template);
|
||||||
|
if (requiredCandidateCount > 0 && validCandidates.Length < requiredCandidateCount)
|
||||||
|
{
|
||||||
|
warning = $"template-slot-count-{requiredCandidateCount}-with-{validCandidates.Length}-candidates";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.BroadcastPhase == BroadcastPhase.Counting)
|
||||||
|
{
|
||||||
|
if (snapshot.CountedVotes <= 0 && snapshot.CountedRate <= 0)
|
||||||
|
{
|
||||||
|
errorMessage = "counted votes and counted rate are both zero";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.CountedVotes > 0 && snapshot.CountedRate <= 0)
|
||||||
|
{
|
||||||
|
warning = JoinWarning(warning, "counted-rate-zero-with-positive-counted-votes");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validCandidates.Any(candidate => candidate.VoteCount > 0 || candidate.VoteRate > 0))
|
||||||
|
{
|
||||||
|
errorMessage = "candidate vote data is empty";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.RequiresImage && snapshot.Candidates.Any(candidate => !candidate.HasImage))
|
||||||
|
{
|
||||||
|
errorMessage = "candidate image is required but missing";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string JoinWarning(string current, string next)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(current))
|
||||||
|
{
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{current}; {next}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsCandidateReadyForBroadcast(CandidateEntry candidate)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(candidate.Name)
|
||||||
|
&& !string.IsNullOrWhiteSpace(candidate.Party)
|
||||||
|
&& !string.IsNullOrWhiteSpace(candidate.CandidateCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTurnoutTemplate(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
return template.Name.Contains("투표율", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveRequiredCandidateCount(FormatTemplateDefinition template)
|
||||||
|
{
|
||||||
|
foreach (var source in new[] { template.Cuts.FirstOrDefault()?.Name, template.Name, template.Id })
|
||||||
|
{
|
||||||
|
var count = ResolveRequiredCandidateCount(source);
|
||||||
|
if (count > 0)
|
||||||
|
{
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveRequiredCandidateCount(string? source)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(source))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceName = Path.GetFileNameWithoutExtension(
|
||||||
|
source
|
||||||
|
.Replace('/', Path.DirectorySeparatorChar)
|
||||||
|
.Replace('\\', Path.DirectorySeparatorChar));
|
||||||
|
|
||||||
|
var topRankMatch = TopRankSlotCountPattern.Match(sourceName);
|
||||||
|
if (topRankMatch.Success &&
|
||||||
|
int.TryParse(topRankMatch.Groups[1].Value, out var topRankSlotCount) &&
|
||||||
|
topRankSlotCount > 0)
|
||||||
|
{
|
||||||
|
return topRankSlotCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
var peopleMatch = PeopleSlotCountPattern.Match(sourceName);
|
||||||
|
if (peopleMatch.Success &&
|
||||||
|
int.TryParse(peopleMatch.Groups[1].Value, out var peopleSlotCount) &&
|
||||||
|
peopleSlotCount > 0)
|
||||||
|
{
|
||||||
|
return peopleSlotCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceName.StartsWith("1위_", StringComparison.Ordinal) ||
|
||||||
|
sourceName.Contains("이시각1위", StringComparison.Ordinal) ||
|
||||||
|
sourceName.StartsWith("당선_", StringComparison.Ordinal) ||
|
||||||
|
sourceName.StartsWith("경력_", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceName.Contains("접전", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveScheduleElectionType(string? formatName, BroadcastPhase phase, string defaultElectionType)
|
||||||
|
{
|
||||||
|
var resolvedFormatName = formatName ?? string.Empty;
|
||||||
|
if (resolvedFormatName.Contains("교육감", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "교육감";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal) ||
|
||||||
|
resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "기초단체장";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal) ||
|
||||||
|
resolvedFormatName.Contains("광역의원", StringComparison.Ordinal) ||
|
||||||
|
resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return "광역단체장";
|
||||||
|
}
|
||||||
|
|
||||||
|
return phase == BroadcastPhase.PreElection ? "광역단체장" : defaultElectionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeRegion(string? regionName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(regionName))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = regionName.Trim();
|
||||||
|
return RegionAliases.TryGetValue(trimmed, out var normalized)
|
||||||
|
? normalized
|
||||||
|
: trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PopulateDataFields(
|
||||||
|
CurrentApiCutDiagnosticResult result,
|
||||||
|
ElectionDataSnapshot snapshot,
|
||||||
|
string sourcePath)
|
||||||
|
{
|
||||||
|
result.SourcePath = sourcePath;
|
||||||
|
result.CandidateCount = snapshot.Candidates.Count;
|
||||||
|
result.PositiveCandidateVoteCount = snapshot.Candidates.Count(candidate => candidate.VoteCount > 0 || candidate.VoteRate > 0);
|
||||||
|
result.CountedVotes = snapshot.CountedVotes;
|
||||||
|
result.CountedRate = snapshot.CountedRate;
|
||||||
|
result.TurnoutVotes = snapshot.TurnoutVotes;
|
||||||
|
result.TurnoutRate = snapshot.TurnoutRate;
|
||||||
|
result.Leader = snapshot.Candidates
|
||||||
|
.OrderByDescending(candidate => candidate.VoteCount)
|
||||||
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
||||||
|
.FirstOrDefault()?.Name ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteReports(
|
||||||
|
CurrentApiCutDiagnosticsOptions options,
|
||||||
|
IReadOnlyList<CurrentApiCutDiagnosticResult> results)
|
||||||
|
{
|
||||||
|
var jsonPath = Path.Combine(options.OutputPath, "current-api-cut-diagnostics.json");
|
||||||
|
File.WriteAllText(
|
||||||
|
jsonPath,
|
||||||
|
JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }));
|
||||||
|
|
||||||
|
var summaryPath = Path.Combine(options.OutputPath, "summary.md");
|
||||||
|
using var writer = new StreamWriter(summaryPath);
|
||||||
|
writer.WriteLine("# Current API Cut Diagnostics");
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.WriteLine($"- Phase: {options.Phase}");
|
||||||
|
writer.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}");
|
||||||
|
writer.WriteLine($"- Results: {results.Count}");
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.WriteLine("## Status Counts");
|
||||||
|
writer.WriteLine();
|
||||||
|
foreach (var group in results.GroupBy(result => result.Status).OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
writer.WriteLine($"- {group.Key}: {group.Count()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var warningGroups = results
|
||||||
|
.Where(result => !string.IsNullOrWhiteSpace(result.Warning))
|
||||||
|
.GroupBy(result => result.Warning)
|
||||||
|
.OrderByDescending(group => group.Count())
|
||||||
|
.ToArray();
|
||||||
|
if (warningGroups.Length > 0)
|
||||||
|
{
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.WriteLine("## Warning Counts");
|
||||||
|
writer.WriteLine();
|
||||||
|
foreach (var group in warningGroups)
|
||||||
|
{
|
||||||
|
writer.WriteLine($"- {group.Key}: {group.Count()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failures = results
|
||||||
|
.Where(result => result.Status is "validation-failed" or "api-or-send-failed" or "no-target")
|
||||||
|
.Take(60)
|
||||||
|
.ToArray();
|
||||||
|
if (failures.Length > 0)
|
||||||
|
{
|
||||||
|
writer.WriteLine();
|
||||||
|
writer.WriteLine("## Failures");
|
||||||
|
writer.WriteLine();
|
||||||
|
foreach (var failure in failures)
|
||||||
|
{
|
||||||
|
writer.WriteLine($"- [{failure.Status}] {failure.Station} {failure.TemplateName} / {failure.Region}: {failure.Detail}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void PrintSummary(IReadOnlyList<CurrentApiCutDiagnosticResult> results, string outputPath)
|
||||||
|
{
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Summary");
|
||||||
|
foreach (var group in results.GroupBy(result => result.Status).OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"- {group.Key}: {group.Count()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var warnings = results.Count(result => !string.IsNullOrWhiteSpace(result.Warning));
|
||||||
|
Console.WriteLine($"- warnings: {warnings}");
|
||||||
|
Console.WriteLine($"- report: {Path.Combine(outputPath, "summary.md")}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CurrentApiCutDiagnosticsOptions
|
||||||
|
{
|
||||||
|
public BroadcastPhase Phase { get; init; } = BroadcastPhase.Counting;
|
||||||
|
|
||||||
|
public string StationId { get; init; } = "KNN";
|
||||||
|
|
||||||
|
public bool AllStations { get; init; }
|
||||||
|
|
||||||
|
public string RegionScope { get; init; } = "station";
|
||||||
|
|
||||||
|
public int MaxRegions { get; init; }
|
||||||
|
|
||||||
|
public bool IncludeVideoWall { get; init; }
|
||||||
|
|
||||||
|
public int? TemplateLimit { get; init; }
|
||||||
|
|
||||||
|
public string Filter { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public bool SimulateSend { get; init; } = true;
|
||||||
|
|
||||||
|
public int SendLimit { get; init; } = 24;
|
||||||
|
|
||||||
|
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
|
|
||||||
|
public string OutputPath { get; init; } = Path.Combine(
|
||||||
|
"artifacts",
|
||||||
|
"current-api-cut-diagnostics",
|
||||||
|
DateTime.Now.ToString("yyyyMMdd_HHmmss"));
|
||||||
|
|
||||||
|
public string DefaultElectionType { get; init; } = "광역단체장";
|
||||||
|
|
||||||
|
public static CurrentApiCutDiagnosticsOptions Parse(string[] args)
|
||||||
|
{
|
||||||
|
var phase = BroadcastPhase.Counting;
|
||||||
|
var stationId = "KNN";
|
||||||
|
var allStations = false;
|
||||||
|
var regionScope = "station";
|
||||||
|
var maxRegions = 0;
|
||||||
|
var includeVideoWall = false;
|
||||||
|
int? templateLimit = null;
|
||||||
|
var filter = string.Empty;
|
||||||
|
var simulateSend = true;
|
||||||
|
var sendLimit = 24;
|
||||||
|
var imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
|
||||||
|
var outputPath = Path.Combine(
|
||||||
|
"artifacts",
|
||||||
|
"current-api-cut-diagnostics",
|
||||||
|
DateTime.Now.ToString("yyyyMMdd_HHmmss"));
|
||||||
|
var defaultElectionType = "광역단체장";
|
||||||
|
|
||||||
|
for (var index = 0; index < args.Length; index++)
|
||||||
|
{
|
||||||
|
var arg = args[index];
|
||||||
|
string NextValue() => index + 1 < args.Length ? args[++index] : string.Empty;
|
||||||
|
|
||||||
|
switch (arg.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "--phase":
|
||||||
|
phase = ParsePhase(NextValue());
|
||||||
|
break;
|
||||||
|
case "--station":
|
||||||
|
stationId = NextValue();
|
||||||
|
break;
|
||||||
|
case "--all-stations":
|
||||||
|
allStations = true;
|
||||||
|
break;
|
||||||
|
case "--region-scope":
|
||||||
|
regionScope = NextValue().ToLowerInvariant() == "all" ? "all" : "station";
|
||||||
|
break;
|
||||||
|
case "--max-regions":
|
||||||
|
int.TryParse(NextValue(), out maxRegions);
|
||||||
|
break;
|
||||||
|
case "--include-video-wall":
|
||||||
|
includeVideoWall = true;
|
||||||
|
break;
|
||||||
|
case "--limit":
|
||||||
|
if (int.TryParse(NextValue(), out var parsedLimit))
|
||||||
|
{
|
||||||
|
templateLimit = parsedLimit;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "--filter":
|
||||||
|
filter = NextValue();
|
||||||
|
break;
|
||||||
|
case "--no-send":
|
||||||
|
simulateSend = false;
|
||||||
|
break;
|
||||||
|
case "--send-limit":
|
||||||
|
if (int.TryParse(NextValue(), out var parsedSendLimit))
|
||||||
|
{
|
||||||
|
sendLimit = Math.Max(0, parsedSendLimit);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "--image-root":
|
||||||
|
imageRootPath = TornadoPathResolver.NormalizeConfiguredPath(NextValue());
|
||||||
|
break;
|
||||||
|
case "--output":
|
||||||
|
outputPath = NextValue();
|
||||||
|
break;
|
||||||
|
case "--election-type":
|
||||||
|
defaultElectionType = NextValue();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CurrentApiCutDiagnosticsOptions
|
||||||
|
{
|
||||||
|
Phase = phase,
|
||||||
|
StationId = stationId,
|
||||||
|
AllStations = allStations,
|
||||||
|
RegionScope = regionScope,
|
||||||
|
MaxRegions = Math.Max(0, maxRegions),
|
||||||
|
IncludeVideoWall = includeVideoWall,
|
||||||
|
TemplateLimit = templateLimit,
|
||||||
|
Filter = filter,
|
||||||
|
SimulateSend = simulateSend,
|
||||||
|
SendLimit = sendLimit,
|
||||||
|
ImageRootPath = imageRootPath,
|
||||||
|
OutputPath = outputPath,
|
||||||
|
DefaultElectionType = defaultElectionType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BroadcastPhase ParsePhase(string value)
|
||||||
|
{
|
||||||
|
return value.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"pre" or "pre-election" or "preelection" => BroadcastPhase.PreElection,
|
||||||
|
_ => BroadcastPhase.Counting
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CurrentApiCutDiagnosticResult
|
||||||
|
{
|
||||||
|
public string Station { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Channel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string TemplateId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string TemplateName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Phase { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ElectionType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Region { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string DistrictCode { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Detail { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Warning { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string SourcePath { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int CandidateCount { get; set; }
|
||||||
|
|
||||||
|
public int PositiveCandidateVoteCount { get; set; }
|
||||||
|
|
||||||
|
public int CountedVotes { get; set; }
|
||||||
|
|
||||||
|
public double CountedRate { get; set; }
|
||||||
|
|
||||||
|
public int TurnoutVotes { get; set; }
|
||||||
|
|
||||||
|
public double TurnoutRate { get; set; }
|
||||||
|
|
||||||
|
public string Leader { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public static CurrentApiCutDiagnosticResult NoTarget(
|
||||||
|
BroadcastStationProfile station,
|
||||||
|
FormatTemplateDefinition template,
|
||||||
|
BroadcastPhase phase,
|
||||||
|
string electionType)
|
||||||
|
{
|
||||||
|
return new CurrentApiCutDiagnosticResult
|
||||||
|
{
|
||||||
|
Station = station.Id,
|
||||||
|
Channel = template.RecommendedChannel.ToString(),
|
||||||
|
TemplateId = template.Id,
|
||||||
|
TemplateName = template.Name,
|
||||||
|
Phase = phase.ToString(),
|
||||||
|
ElectionType = electionType,
|
||||||
|
Status = "no-target",
|
||||||
|
Detail = "no matching schedule regions"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@
|
|||||||
<Compile Include="..\..\Tornado3_2026Election\Services\LogService.cs" Link="AppSource\Services\LogService.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\LogService.cs" Link="AppSource\Services\LogService.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\MockTornado3Adapter.cs" Link="AppSource\Services\MockTornado3Adapter.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\MockTornado3Adapter.cs" Link="AppSource\Services\MockTornado3Adapter.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\PartyColorCatalog.cs" Link="AppSource\Services\PartyColorCatalog.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\PartyColorCatalog.cs" Link="AppSource\Services\PartyColorCatalog.cs" />
|
||||||
|
<Compile Include="..\..\Tornado3_2026Election\Services\SbsElectionApiClient.cs" Link="AppSource\Services\SbsElectionApiClient.cs" />
|
||||||
|
<Compile Include="..\..\Tornado3_2026Election\Services\StationCatalogService.cs" Link="AppSource\Services\StationCatalogService.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoManager.cs" Link="AppSource\Services\TornadoManager.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoManager.cs" Link="AppSource\Services\TornadoManager.cs" />
|
||||||
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoPathResolver.cs" Link="AppSource\Services\TornadoPathResolver.cs" />
|
<Compile Include="..\..\Tornado3_2026Election\Services\TornadoPathResolver.cs" Link="AppSource\Services\TornadoPathResolver.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -318,6 +318,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.Length > 0 && string.Equals(args[0], "--validate-current-api-cuts", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Environment.ExitCode = await CurrentApiCutDiagnostics.RunAsync(args[1..]).ConfigureAwait(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (args.Length > 0 && string.Equals(args[0], "--sweep-cut-debug", StringComparison.OrdinalIgnoreCase))
|
if (args.Length > 0 && string.Equals(args[0], "--sweep-cut-debug", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
Environment.ExitCode = await LiveCutValidation.RunCutDebugSweepAsync(args[1..]).ConfigureAwait(false);
|
Environment.ExitCode = await LiveCutValidation.RunCutDebugSweepAsync(args[1..]).ConfigureAwait(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user