diff --git a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml index f075829..c49dfed 100644 --- a/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml +++ b/Tornado3_2026Election/Controls/ChannelSchedulePanel.xaml @@ -264,7 +264,8 @@ Background="#101C2E" BorderBrush="{StaticResource ControlRoomPanelStrokeBrush}" BorderThickness="1" - CornerRadius="8"> + CornerRadius="8" + Visibility="{x:Bind ViewModel.CutDebugPanelVisibility, Mode=OneWay}"> diff --git a/Tornado3_2026Election/Domain/CutDebugSettings.cs b/Tornado3_2026Election/Domain/CutDebugSettings.cs index 799971d..042bf07 100644 --- a/Tornado3_2026Election/Domain/CutDebugSettings.cs +++ b/Tornado3_2026Election/Domain/CutDebugSettings.cs @@ -4,6 +4,7 @@ namespace Tornado3_2026Election.Domain; public sealed class CutDebugSettings : ObservableObject { + private bool _isFeatureEnabled = true; private bool _isEnabled; private bool _applyTextValues = true; private bool _applyImageValues = true; @@ -14,6 +15,18 @@ public sealed class CutDebugSettings : ObservableObject private bool _applyPartyPlateStyleColors = true; private bool _applyVoteRateStyleColors = true; + public bool IsFeatureEnabled + { + get => _isFeatureEnabled; + set + { + if (SetProperty(ref _isFeatureEnabled, value)) + { + OnPropertyChanged(nameof(IsEffectiveEnabled), nameof(Summary)); + } + } + } + public bool IsEnabled { get => _isEnabled; @@ -21,11 +34,13 @@ public sealed class CutDebugSettings : ObservableObject { if (SetProperty(ref _isEnabled, value)) { - OnPropertyChanged(nameof(Summary)); + OnPropertyChanged(nameof(IsEffectiveEnabled), nameof(Summary)); } } } + public bool IsEffectiveEnabled => IsFeatureEnabled && IsEnabled; + public bool ApplyTextValues { get => _applyTextValues; @@ -129,7 +144,7 @@ public sealed class CutDebugSettings : ObservableObject public CutDebugSettingsSnapshot CreateSnapshot() { return new CutDebugSettingsSnapshot( - IsEnabled, + IsEffectiveEnabled, ApplyTextValues, ApplyImageValues, ApplyVisibilityValues, diff --git a/Tornado3_2026Election/MainWindow.xaml b/Tornado3_2026Election/MainWindow.xaml index 2eac14b..b6adac1 100644 --- a/Tornado3_2026Election/MainWindow.xaml +++ b/Tornado3_2026Election/MainWindow.xaml @@ -1005,6 +1005,10 @@ + _settingsByChannel = new(); private readonly Dictionary _templateStates = new(StringComparer.Ordinal); + private bool _isDebugFeatureEnabled = true; public CutDebugStateStore() { @@ -23,6 +24,20 @@ public sealed class CutDebugStateStore 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) { var templateKey = BuildTemplateKey(channel, formatId); diff --git a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs index 616f36c..040176f 100644 --- a/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs +++ b/Tornado3_2026Election/Services/KarismaTornado3Adapter.cs @@ -203,7 +203,9 @@ public sealed class KarismaTornado3Adapter : ITornado3Adapter, IDisposable var historicalWinnerVisibilityUpdates = BuildHistoricalWinnerVisibilityUpdates(template, cut, snapshot, sceneVariables); var careerPromiseVisibilityUpdates = BuildCareerPromiseVisibilityUpdates(template, cut, snapshot, sceneVariables); 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 filteredCounterNumberKeys = FilterCounterNumberKeyUpdates(counterNumberKeys, cutDebug, templateDebug); var filteredStyleColorUpdates = FilterStyleColorUpdates(styleColorUpdates, cutDebug, templateDebug); diff --git a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs index e6ca087..ef11a35 100644 --- a/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs +++ b/Tornado3_2026Election/ViewModels/ChannelScheduleViewModel.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media; using Tornado3_2026Election.Common; using Tornado3_2026Election.Domain; @@ -80,7 +81,7 @@ public sealed class ChannelScheduleViewModel : ObservableObject _engine.QueueChanged += (_, _) => RefreshSummary(); _adapter.StateChanged += (_, _) => RefreshSummary(); _adapter.ConnectionChanged += (_, _) => RefreshSummary(); - CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary)); + CutDebug.PropertyChanged += (_, _) => OnPropertyChanged(nameof(CutDebugSummary), nameof(CutDebugPanelVisibility)); _data.PropertyChanged += Data_PropertyChanged; Queue.CollectionChanged += Queue_CollectionChanged; @@ -263,6 +264,8 @@ public sealed class ChannelScheduleViewModel : ObservableObject public string CutDebugSummary => CutDebug.Summary; + public Visibility CutDebugPanelVisibility => CutDebug.IsFeatureEnabled ? Visibility.Visible : Visibility.Collapsed; + public ObservableCollection CutDebugItems => _selectedCutDebugTemplate?.Items ?? _emptyCutDebugItems; public int CutDebugItemCount => CutDebugItems.Count; diff --git a/Tornado3_2026Election/ViewModels/DataViewModel.cs b/Tornado3_2026Election/ViewModels/DataViewModel.cs index 1c00a4f..97b13e6 100644 --- a/Tornado3_2026Election/ViewModels/DataViewModel.cs +++ b/Tornado3_2026Election/ViewModels/DataViewModel.cs @@ -996,18 +996,11 @@ public sealed class DataViewModel : ObservableObject, IDataRefreshGate, IDisposa 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.CountedVotes <= 0 || snapshot.CountedRate <= 0) + if (snapshot.CountedVotes <= 0 && snapshot.CountedRate <= 0) { - errorMessage = "개표 데이터가 0이라 송출할 수 없습니다."; + errorMessage = "개표수와 개표율이 모두 0이라 송출할 수 없습니다."; return false; } diff --git a/Tornado3_2026Election/ViewModels/MainViewModel.cs b/Tornado3_2026Election/ViewModels/MainViewModel.cs index 8f11042..d4175d5 100644 --- a/Tornado3_2026Election/ViewModels/MainViewModel.cs +++ b/Tornado3_2026Election/ViewModels/MainViewModel.cs @@ -87,9 +87,10 @@ public sealed class MainViewModel : ObservableObject _selectedCutListFilterOption = CutListFilterOptions[0]; _selectedCutListCategoryOption = CutListCategoryOptions[0]; + _cutDebugStateStore = new CutDebugStateStore(); + _cutDebugStateStore.SetDebugFeatureEnabled(Settings.IsDebugFeaturesEnabled); Settings.PropertyChanged += Settings_PropertyChanged; Data.PropertyChanged += Data_PropertyChanged; - _cutDebugStateStore = new CutDebugStateStore(); _sharedTornadoAdapter = KarismaTornado3Adapter.CreateOrFallback(_logService, () => Settings.ImageRootPath, _cutDebugStateStore); NormalChannel = CreateChannelViewModel(BroadcastChannel.Normal, "노멀", _sharedTornadoAdapter); @@ -700,6 +701,11 @@ public sealed class MainViewModel : ObservableObject 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)) { var selectedStationProfile = Settings.BuildSelectedStationProfile(); @@ -801,6 +807,8 @@ public sealed class MainViewModel : ObservableObject private void ApplyState(AppState state) { + Settings.IsDebugFeaturesEnabled = state.IsDebugFeaturesEnabled; + if (RestoreSelection.RestoreStations) { Settings.SelectedStationId = state.SelectedStationId; @@ -947,6 +955,7 @@ public sealed class MainViewModel : ObservableObject WindowWidth = _windowWidth ?? 0, WindowHeight = _windowHeight ?? 0, IsWindowMaximized = _isWindowMaximized, + IsDebugFeaturesEnabled = Settings.IsDebugFeaturesEnabled, OperationMode = OperationMode.ToString(), BroadcastPhase = Data.BroadcastPhase.ToString(), IsPollingEnabled = Data.IsPollingEnabled, diff --git a/Tornado3_2026Election/ViewModels/SettingsViewModel.cs b/Tornado3_2026Election/ViewModels/SettingsViewModel.cs index fd2c6d1..1ff47e1 100644 --- a/Tornado3_2026Election/ViewModels/SettingsViewModel.cs +++ b/Tornado3_2026Election/ViewModels/SettingsViewModel.cs @@ -11,6 +11,7 @@ public sealed class SettingsViewModel : ObservableObject { private string _selectedStationId = "KNN"; private string _imageRootPath = TornadoPathResolver.GetDefaultT3CutPath(); + private bool _isDebugFeaturesEnabled = true; private readonly IReadOnlyList> _videoWallLayoutOptions = [ new SelectionOption(VideoWallLayoutPreset.Auto, "자동"), @@ -73,6 +74,12 @@ public sealed class SettingsViewModel : ObservableObject set => SetProperty(ref _imageRootPath, TornadoPathResolver.NormalizeConfiguredPath(value)); } + public bool IsDebugFeaturesEnabled + { + get => _isDebugFeaturesEnabled; + set => SetProperty(ref _isDebugFeaturesEnabled, value); + } + public StationFilterItemViewModel SelectedStation => Stations.FirstOrDefault(station => station.Id == SelectedStationId) ?? Stations[0]; diff --git a/tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs b/tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs new file mode 100644 index 0000000..5c8f042 --- /dev/null +++ b/tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs @@ -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 RegionAliases = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["서울"] = "서울", + ["서울특별시"] = "서울", + ["부산"] = "부산", + ["부산광역시"] = "부산", + ["대구"] = "대구", + ["대구광역시"] = "대구", + ["인천"] = "인천", + ["인천광역시"] = "인천", + ["광주"] = "광주", + ["광주광역시"] = "광주", + ["대전"] = "대전", + ["대전광역시"] = "대전", + ["울산"] = "울산", + ["울산광역시"] = "울산", + ["세종"] = "세종", + ["세종특별자치시"] = "세종", + ["경기"] = "경기", + ["경기도"] = "경기", + ["강원"] = "강원", + ["강원도"] = "강원", + ["강원특별자치도"] = "강원", + ["충북"] = "충북", + ["충청북도"] = "충북", + ["충남"] = "충남", + ["충청남도"] = "충남", + ["전북"] = "전북", + ["전라북도"] = "전북", + ["전북특별자치도"] = "전북", + ["전남"] = "전남", + ["전라남도"] = "전남", + ["경북"] = "경북", + ["경상북도"] = "경북", + ["경남"] = "경남", + ["경상남도"] = "경남", + ["제주"] = "제주", + ["제주도"] = "제주", + ["제주특별자치도"] = "제주" + }; + + public static async Task 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>(StringComparer.Ordinal); + var results = new List(); + 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> GetDistrictsAsync( + SbsElectionApiClient apiClient, + IDictionary> 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 ResolveTargets( + IReadOnlyList districts, + BroadcastStationProfile station, + CurrentApiCutDiagnosticsOptions options) + { + IEnumerable targets = options.RegionScope switch + { + "all" => districts, + _ => ResolveStationTargets(districts, station) + }; + + if (options.MaxRegions > 0) + { + targets = targets.Take(options.MaxRegions); + } + + return targets; + } + + private static IEnumerable ResolveStationTargets( + IReadOnlyList 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(), + 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 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 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" + }; + } + } +} diff --git a/tools/KarismaTcpProbe/KarismaTcpProbe.csproj b/tools/KarismaTcpProbe/KarismaTcpProbe.csproj index f5e8720..c98fbaf 100644 --- a/tools/KarismaTcpProbe/KarismaTcpProbe.csproj +++ b/tools/KarismaTcpProbe/KarismaTcpProbe.csproj @@ -52,6 +52,8 @@ + + diff --git a/tools/KarismaTcpProbe/Program.cs b/tools/KarismaTcpProbe/Program.cs index bc32fd9..b4a2faf 100644 --- a/tools/KarismaTcpProbe/Program.cs +++ b/tools/KarismaTcpProbe/Program.cs @@ -318,6 +318,12 @@ if (args.Length > 0 && string.Equals(args[0], "--validate-live-cuts", StringComp 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)) { Environment.ExitCode = await LiveCutValidation.RunCutDebugSweepAsync(args[1..]).ConfigureAwait(false);