Files
Tornado3_2026Election/tools/KarismaTcpProbe/CurrentApiCutDiagnostics.cs
2026-05-05 00:50:11 +09:00

1393 lines
52 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
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($"- Send Mode: {ResolveSendModeLabel(options)}");
Console.WriteLine($"- Scene Capture: {(options.CaptureSceneImages ? "on" : "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))
.Where(template => string.IsNullOrWhiteSpace(options.ExcludeFilter) ||
(!template.Id.Contains(options.ExcludeFilter, StringComparison.OrdinalIgnoreCase) &&
!template.Name.Contains(options.ExcludeFilter, 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 preElectionHistoryService = new PreElectionHistoryService(logService);
ITornado3Adapter? adapter;
try
{
adapter = CreateSendAdapter(options, logService);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return 1;
}
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);
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts;
try
{
districts = await GetDistrictsAsync(apiClient, districtCache, electionType, station).ConfigureAwait(false);
}
catch (Exception ex)
{
results.Add(CurrentApiCutDiagnosticResult.DistrictLoadFailed(station, template, phase, electionType, ex.Message));
continue;
}
var targets = ResolveTargets(districts, station, options)
.ToArray();
if (targets.Length == 0)
{
results.Add(CurrentApiCutDiagnosticResult.NoTarget(station, template, phase, electionType));
continue;
}
if (ShouldUseAggregateTurnoutSnapshot(template, phase, electionType))
{
var result = new CurrentApiCutDiagnosticResult
{
Station = station.Id,
Channel = template.RecommendedChannel.ToString(),
TemplateId = template.Id,
TemplateName = template.Name,
Phase = phase.ToString(),
ElectionType = electionType,
Region = string.Join(", ", targets.Select(target => target.DisplayName)),
DistrictCode = string.Join(",", targets.Select(target => target.DistrictCode)),
Status = "unknown"
};
try
{
var snapshot = await CreateAggregateTurnoutSnapshotAsync(
apiClient,
electionType,
districts,
targets,
template,
CancellationToken.None)
.ConfigureAwait(false);
PopulateDataFields(result, snapshot, "GET /tupyo aggregate overview");
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, result).ConfigureAwait(false);
simulatedSendCount++;
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
result.Detail = options.LiveSend
? "validated and live send completed"
: "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);
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
{
ElectionDataSnapshot snapshot;
if (UsesStoredPreElectionHistory(template))
{
snapshot = CreateStoredPreElectionHistorySnapshot(
phase,
electionType,
target,
preElectionHistoryService);
PopulateDataFields(result, snapshot, "stored pre-election history");
}
else
{
var refreshResult = await apiClient
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
.ConfigureAwait(false);
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, result).ConfigureAwait(false);
simulatedSendCount++;
result.Status = options.LiveSend ? "sent-live" : "sent-mock";
result.Detail = options.LiveSend
? "validated and live send completed"
: "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);
}
}
}
if (adapter is IDisposable disposable)
{
disposable.Dispose();
}
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 ITornado3Adapter? CreateSendAdapter(CurrentApiCutDiagnosticsOptions options, LogService logService)
{
if (!options.SimulateSend)
{
return null;
}
if (!options.LiveSend)
{
return new MockTornado3Adapter(logService);
}
var cutDebugStateStore = new CutDebugStateStore();
if (!KarismaTornado3Adapter.TryCreate(logService, () => options.ImageRootPath, cutDebugStateStore, out var adapter) ||
!adapter.IsLiveCg)
{
throw new InvalidOperationException("Karisma adapter is not available. Live send cannot continue.");
}
return adapter;
}
private static string ResolveSendModeLabel(CurrentApiCutDiagnosticsOptions options)
{
if (!options.SimulateSend)
{
return "off";
}
return options.LiveSend
? $"live ({options.SendLimit})"
: $"mock ({options.SendLimit})";
}
private static async Task<ElectionDataSnapshot> CreateAggregateTurnoutSnapshotAsync(
SbsElectionApiClient apiClient,
string electionType,
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> allDistricts,
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> selectedDistricts,
FormatTemplateDefinition template,
CancellationToken cancellationToken)
{
var overview = await apiClient
.GetTurnoutOverviewAsync(electionType, allDistricts, cancellationToken)
.ConfigureAwait(false);
var primaryDistrict = selectedDistricts.FirstOrDefault();
var primaryItem = FindTurnoutOverviewItem(overview.Items, primaryDistrict);
var includeNationalSlot = IsBottomTurnoutBoardTemplate(template);
var maxRegionalSlots = includeNationalSlot ? 4 : 7;
var turnoutBoardSlots = new List<TurnoutBoardSlotEntry>();
if (includeNationalSlot)
{
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(1, "전국", overview.NationalTurnoutRate, true));
}
var nextSlot = turnoutBoardSlots.Count + 1;
foreach (var district in selectedDistricts.Take(maxRegionalSlots))
{
var item = FindTurnoutOverviewItem(overview.Items, district);
if (item is null || item.TurnoutVotes <= 0 || item.TurnoutRate <= 0)
{
continue;
}
turnoutBoardSlots.Add(new TurnoutBoardSlotEntry(
nextSlot++,
ResolveTurnoutBoardDistrictLabel(electionType, item, district),
item.TurnoutRate,
RegionLabel: ResolveTurnoutBoardRegionLabel(item, district)));
}
if (turnoutBoardSlots.Count == (includeNationalSlot ? 1 : 0))
{
throw new InvalidOperationException("No positive turnout board slots were available.");
}
var regionName = primaryItem?.RegionName ?? primaryDistrict?.RegionName ?? string.Empty;
var districtName = primaryItem?.DisplayName ?? primaryDistrict?.DisplayName ?? regionName;
var electionDistrictName = ResolveTurnoutElectionDistrictName(
electionType,
primaryItem,
primaryDistrict,
regionName,
districtName);
var totalExpectedVotes = includeNationalSlot
? overview.TotalExpectedVotes
: primaryItem?.TotalExpectedVotes ?? 0;
var turnoutVotes = includeNationalSlot
? overview.TurnoutVotes
: primaryItem?.TurnoutVotes ?? 0;
return new ElectionDataSnapshot
{
BroadcastPhase = BroadcastPhase.PreElection,
ElectionType = electionType,
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
DistrictCode = primaryItem?.DistrictCode ?? primaryDistrict?.DistrictCode ?? string.Empty,
RegionName = regionName,
ElectionDistrictName = electionDistrictName,
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = Math.Max(0, totalExpectedVotes),
TurnoutVotes = Math.Max(0, turnoutVotes),
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
TurnoutBoardSlots = turnoutBoardSlots,
NationalTurnoutRateOverride = overview.NationalTurnoutRate
};
}
private static bool ShouldUseAggregateTurnoutSnapshot(
FormatTemplateDefinition template,
BroadcastPhase phase,
string electionType)
{
return phase == BroadcastPhase.PreElection &&
SupportsPreElectionTurnout(electionType) &&
(IsBottomTurnoutBoardTemplate(template) || IsRegionalTurnoutBoardTemplate(template));
}
private static bool IsBottomTurnoutBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Bottom &&
(string.Equals(template.Name, "사전투표율", StringComparison.Ordinal) ||
string.Equals(template.Name, "투표율", StringComparison.Ordinal));
}
private static bool IsRegionalTurnoutBoardTemplate(FormatTemplateDefinition template)
{
return template.RecommendedChannel == BroadcastChannel.Normal &&
string.Equals(template.Name, "투표율_선거구별 사전", StringComparison.Ordinal);
}
private static SbsElectionApiClient.TurnoutOverviewItem? FindTurnoutOverviewItem(
IReadOnlyList<SbsElectionApiClient.TurnoutOverviewItem> items,
SbsElectionApiClient.DistrictSelectionOption? district)
{
if (district is null || items.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(district.DistrictCode))
{
var matchedByCode = items.FirstOrDefault(item =>
string.Equals(item.DistrictCode, district.DistrictCode, StringComparison.OrdinalIgnoreCase));
if (matchedByCode is not null)
{
return matchedByCode;
}
}
return items.FirstOrDefault(item =>
string.Equals(item.RegionName, district.RegionName, StringComparison.Ordinal) ||
string.Equals(item.DisplayName, district.DisplayName, StringComparison.Ordinal) ||
string.Equals(item.DistrictName, district.DistrictName, StringComparison.Ordinal));
}
private static string ResolveTurnoutElectionDistrictName(
string electionType,
SbsElectionApiClient.TurnoutOverviewItem? item,
SbsElectionApiClient.DistrictSelectionOption? district,
string regionName,
string districtName)
{
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
{
return FirstNonWhiteSpace(
item?.DistrictName,
district?.DistrictName,
districtName,
regionName);
}
return string.IsNullOrWhiteSpace(regionName) ? districtName : regionName;
}
private static string ResolveTurnoutBoardDistrictLabel(
string electionType,
SbsElectionApiClient.TurnoutOverviewItem item,
SbsElectionApiClient.DistrictSelectionOption district)
{
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
{
return FirstNonWhiteSpace(
item.DistrictName,
district.DistrictName,
item.DisplayName,
district.DisplayName,
item.RegionName,
district.RegionName);
}
return ResolveTurnoutBoardRegionLabel(item, district);
}
private static string ResolveTurnoutBoardRegionLabel(
SbsElectionApiClient.TurnoutOverviewItem item,
SbsElectionApiClient.DistrictSelectionOption district)
{
return FirstNonWhiteSpace(
item.RegionName,
district.RegionName,
item.DisplayName,
district.DisplayName);
}
private static string FirstNonWhiteSpace(params string?[] values)
{
foreach (var value in values)
{
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return string.Empty;
}
private static async Task<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
SbsElectionApiClient apiClient,
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
string electionType,
BroadcastStationProfile station)
{
var cacheKey = $"{electionType}|{string.Join(",", station.RegionFilters)}";
if (!districtCache.TryGetValue(cacheKey, out var districts))
{
districts = await apiClient
.GetDistrictOptionsAsync(electionType, station.RegionFilters, CancellationToken.None)
.ConfigureAwait(false);
districtCache[cacheKey] = 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 ElectionDataSnapshot CreateStoredPreElectionHistorySnapshot(
BroadcastPhase phase,
string electionType,
SbsElectionApiClient.DistrictSelectionOption target,
PreElectionHistoryService preElectionHistoryService)
{
var regionName = target.RegionName ?? string.Empty;
var districtName = !string.IsNullOrWhiteSpace(target.DistrictName)
? target.DistrictName
: !string.IsNullOrWhiteSpace(target.DisplayName)
? target.DisplayName
: regionName;
var history = preElectionHistoryService.ResolveHistory(electionType, regionName, districtName);
return new ElectionDataSnapshot
{
BroadcastPhase = phase,
ElectionType = electionType,
DistrictName = districtName,
DistrictCode = target.DistrictCode,
RegionName = regionName,
ElectionDistrictName = ResolveHistoricalElectionDistrictName(electionType, regionName, districtName),
Candidates = Array.Empty<CandidateEntry>(),
TotalExpectedVotes = 0,
TurnoutVotes = 0,
CountedVotesFromApi = null,
RemainingVotesFromApi = null,
CountedRateFromApi = null,
ReceivedAt = DateTimeOffset.Now,
HistoricalTurnoutHistory = history?.TurnoutHistory.OrderBy(entry => entry.Year).ToArray()
?? Array.Empty<PreElectionHistoricalTurnoutEntry>(),
HistoricalWinnerHistory = history?.WinnerHistory.OrderBy(entry => entry.ElectionOrder).ToArray()
?? Array.Empty<PreElectionHistoricalWinnerEntry>()
};
}
private static string ResolveHistoricalElectionDistrictName(
string electionType,
string regionName,
string districtName)
{
if (string.Equals(electionType, "기초단체장", StringComparison.Ordinal))
{
return string.IsNullOrWhiteSpace(districtName) ? regionName : districtName;
}
return string.IsNullOrWhiteSpace(regionName) ? districtName : regionName;
}
private static async Task SimulateSendAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
CurrentApiCutDiagnosticsOptions options,
CurrentApiCutDiagnosticResult result)
{
foreach (var cut in template.Cuts)
{
Exception? lastException = null;
for (var attempt = 1; attempt <= 3; attempt++)
{
try
{
await SendSingleCutAsync(adapter, station, template, cut, snapshot, options, result).ConfigureAwait(false);
lastException = null;
break;
}
catch (Exception ex) when (attempt < 3)
{
lastException = ex;
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
await Task.Delay(750, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
lastException = ex;
}
}
if (lastException is not null)
{
throw lastException;
}
}
}
private static async Task SendSingleCutAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
CurrentApiCutDiagnosticsOptions options,
CurrentApiCutDiagnosticResult result)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "connect");
try
{
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, options.ImageRootPath, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "apply");
await adapter.PrepareAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "prepare");
await adapter.TakeAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "take");
await CaptureSceneImageIfRequestedAsync(adapter, template, cut, options, result).ConfigureAwait(false);
}
finally
{
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
if (adapter.IsLiveCg)
{
await Task.Delay(250, CancellationToken.None).ConfigureAwait(false);
}
}
}
private static async Task TryOutAsync(ITornado3Adapter adapter, BroadcastChannel channel)
{
try
{
await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "out");
}
catch
{
if (!adapter.IsLiveCg)
{
throw;
}
}
}
private static async Task CaptureSceneImageIfRequestedAsync(
ITornado3Adapter adapter,
FormatTemplateDefinition template,
FormatCutDefinition cut,
CurrentApiCutDiagnosticsOptions options,
CurrentApiCutDiagnosticResult result)
{
if (!options.CaptureSceneImages || adapter is not KarismaTornado3Adapter karismaAdapter)
{
return;
}
var captureDirectory = Path.Combine(options.OutputPath, "captures");
Directory.CreateDirectory(captureDirectory);
var districtToken = result.DistrictCode.Replace(",", "-", StringComparison.Ordinal);
if (districtToken.Length > 40)
{
districtToken = districtToken[..40];
}
var fileStem = SanitizeFileName(
$"{result.Station}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}");
var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}.png"));
var (width, height) = ResolveSceneCaptureSize(template);
await karismaAdapter.SavePendingSceneImageAsync(
template.RecommendedChannel,
outputPath,
width,
height,
frame: -1,
CancellationToken.None)
.ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "capture");
result.CapturePath = outputPath;
result.CaptureHash = ComputeSha256(outputPath);
result.CaptureBytes = new FileInfo(outputPath).Length;
}
private static (int Width, int Height) ResolveSceneCaptureSize(FormatTemplateDefinition template)
{
var sourceWidth = template.SceneWidth.GetValueOrDefault(1920);
var sourceHeight = template.SceneHeight.GetValueOrDefault(1080);
if (sourceWidth <= 0 || sourceHeight <= 0)
{
return (1280, 720);
}
const int maxWidth = 1280;
if (sourceWidth <= maxWidth)
{
return (sourceWidth, sourceHeight);
}
var scale = maxWidth / (double)sourceWidth;
return (maxWidth, Math.Max(1, (int)Math.Round(sourceHeight * scale, MidpointRounding.AwayFromZero)));
}
private static string ComputeSha256(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexString(SHA256.HashData(stream));
}
private static string SanitizeFileName(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = new string(value.Select(character => invalidChars.Contains(character) ? '_' : character).ToArray()).Trim();
if (sanitized.Length > 80)
{
sanitized = sanitized[..80];
}
return string.IsNullOrWhiteSpace(sanitized) ? "capture.png" : sanitized;
}
private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action)
{
if (adapter.State == TornadoConnectionState.Error)
{
throw new InvalidOperationException($"Karisma live send failed during {action}.");
}
}
private static bool ValidateSnapshotForFormat(
FormatTemplateDefinition template,
ElectionDataSnapshot snapshot,
out string errorMessage,
out string warning)
{
warning = string.Empty;
if (ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name))
{
if (snapshot.HistoricalTurnoutHistory.Count == 0)
{
errorMessage = "historical turnout data is empty";
return false;
}
errorMessage = string.Empty;
return true;
}
if (ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name))
{
if (snapshot.HistoricalWinnerHistory.Count == 0)
{
errorMessage = "historical winner data is empty";
return false;
}
errorMessage = string.Empty;
return true;
}
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 bool UsesStoredPreElectionHistory(FormatTemplateDefinition template)
{
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) ||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name);
}
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))
{
return "기초의원";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal))
{
return "기초단체장";
}
if (resolvedFormatName.Contains("광역의원", StringComparison.Ordinal))
{
return "광역의원";
}
if (resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
{
return "국회의원";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal))
{
return "광역단체장";
}
if (phase == BroadcastPhase.PreElection)
{
return SupportsPreElectionTurnout(defaultElectionType)
? defaultElectionType
: "광역단체장";
}
return defaultElectionType;
}
private static bool SupportsPreElectionTurnout(string? electionType)
{
return string.Equals(electionType, "광역단체장", StringComparison.Ordinal) ||
string.Equals(electionType, "교육감", StringComparison.Ordinal) ||
string.Equals(electionType, "기초단체장", StringComparison.Ordinal);
}
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 string ExcludeFilter { get; init; } = string.Empty;
public bool SimulateSend { get; init; } = true;
public bool LiveSend { get; init; }
public bool CaptureSceneImages { get; init; }
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 excludeFilter = string.Empty;
var simulateSend = true;
var liveSend = false;
var captureSceneImages = false;
var sendLimit = 24;
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 "--exclude-filter":
excludeFilter = NextValue();
break;
case "--no-send":
simulateSend = false;
liveSend = false;
break;
case "--live-send":
simulateSend = true;
liveSend = true;
break;
case "--capture-scene-images":
captureSceneImages = true;
break;
case "--send-limit":
if (int.TryParse(NextValue(), out var parsedSendLimit))
{
sendLimit = Math.Max(0, parsedSendLimit);
}
break;
case "--image-root":
_ = 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,
ExcludeFilter = excludeFilter,
SimulateSend = simulateSend,
LiveSend = liveSend,
CaptureSceneImages = captureSceneImages,
SendLimit = sendLimit,
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
OutputPath = Path.GetFullPath(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 string CapturePath { get; set; } = string.Empty;
public string CaptureHash { get; set; } = string.Empty;
public long CaptureBytes { get; set; }
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"
};
}
public static CurrentApiCutDiagnosticResult DistrictLoadFailed(
BroadcastStationProfile station,
FormatTemplateDefinition template,
BroadcastPhase phase,
string electionType,
string detail)
{
return new CurrentApiCutDiagnosticResult
{
Station = station.Id,
Channel = template.RecommendedChannel.ToString(),
TemplateId = template.Id,
TemplateName = template.Name,
Phase = phase.ToString(),
ElectionType = electionType,
Status = "api-or-send-failed",
Detail = detail
};
}
}
}