2864 lines
112 KiB
C#
2864 lines
112 KiB
C#
using System.Diagnostics;
|
|
using System.Drawing;
|
|
using System.Drawing.Imaging;
|
|
using System.Globalization;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
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 const string PanseSummaryCandidateCodePrefix = "PANSE:";
|
|
private const string CouncilSeatDistrictCandidateCodePrefix = "SEAT:D:";
|
|
private const string PanseDemocraticPartyLabel = "더불어민주당";
|
|
private const string PansePeoplePowerPartyLabel = "국민의힘";
|
|
private const string PanseOtherPartyLabel = "무·기타";
|
|
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($"- Mixed Preview Capture: {(options.CaptureMixedPreviewImages ? "on" : "off")}");
|
|
Console.WriteLine($"- PGM Capture: {(options.CapturePgmImages ? "on" : "off")}");
|
|
Console.WriteLine($"- Keep On Air Between Sends: {(options.KeepOnAirBetweenSends ? "yes" : "no")}");
|
|
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);
|
|
var sceneVariableCatalog = KarismaSceneVariableCatalog.Load(logService);
|
|
ITornado3Adapter? adapter;
|
|
try
|
|
{
|
|
adapter = CreateSendAdapter(options, logService);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine(ex.Message);
|
|
return 1;
|
|
}
|
|
|
|
var pgmWindow = options.CapturePgmImages ? TryFindPgmWindow() : null;
|
|
if (options.CapturePgmImages && pgmWindow is null)
|
|
{
|
|
Console.WriteLine("PGM window was not found. Start Tornado3 PGM before using --capture-pgm-images.");
|
|
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, phase, options.DefaultElectionType);
|
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts;
|
|
try
|
|
{
|
|
districts = await GetDistrictsAsync(
|
|
apiClient,
|
|
districtCache,
|
|
electionType,
|
|
station,
|
|
options.RegionScope == "all" || IsNormalPanseMapTemplate(template))
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
results.Add(CurrentApiCutDiagnosticResult.DistrictLoadFailed(station, template, phase, electionType, ex.Message));
|
|
continue;
|
|
}
|
|
|
|
var targets = IsNormalPanseMapTemplate(template)
|
|
? districts.ToArray()
|
|
: ResolveTargets(districts, station, options).ToArray();
|
|
|
|
if (targets.Length == 0)
|
|
{
|
|
results.Add(CurrentApiCutDiagnosticResult.NoTarget(station, template, phase, electionType));
|
|
continue;
|
|
}
|
|
|
|
if (ShouldUseAggregateCurrentLeaderSnapshot(template, phase))
|
|
{
|
|
foreach (var targetGroup in ResolveCurrentLeaderGroups(template, targets))
|
|
{
|
|
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(", ", targetGroup.Select(target => target.DisplayName)),
|
|
DistrictCode = string.Join(",", targetGroup.Select(target => target.DistrictCode)),
|
|
Status = "unknown"
|
|
};
|
|
|
|
try
|
|
{
|
|
var snapshot = await CreateAggregatePanseSnapshotAsync(
|
|
apiClient,
|
|
electionType,
|
|
targetGroup,
|
|
template,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
PopulateDataFields(result, snapshot, "GET /gaepyo aggregate current leader");
|
|
|
|
var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template);
|
|
if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, 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, pgmWindow).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;
|
|
}
|
|
|
|
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");
|
|
|
|
var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template);
|
|
if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, 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, pgmWindow).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;
|
|
}
|
|
|
|
if (ShouldUseAggregateCouncilSeatSnapshot(template, phase))
|
|
{
|
|
foreach (var targetGroup in ResolveAggregateCouncilSeatGroups(targets))
|
|
{
|
|
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(", ", targetGroup.Select(target => target.DisplayName)),
|
|
DistrictCode = string.Join(",", targetGroup.Select(target => target.DistrictCode)),
|
|
Status = "unknown"
|
|
};
|
|
|
|
try
|
|
{
|
|
var snapshot = await CreateAggregateCouncilSeatSnapshotAsync(
|
|
apiClient,
|
|
electionType,
|
|
targetGroup,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
PopulateDataFields(result, snapshot, "GET /gaepyo aggregate council seats");
|
|
|
|
var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template);
|
|
if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, 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, pgmWindow).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;
|
|
}
|
|
|
|
if (ShouldUseAggregatePanseSnapshot(template, phase))
|
|
{
|
|
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 CreateAggregatePanseSnapshotAsync(
|
|
apiClient,
|
|
electionType,
|
|
targets,
|
|
template,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
PopulateDataFields(result, snapshot, "GET /gaepyo aggregate panse");
|
|
|
|
var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template);
|
|
if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, 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, pgmWindow).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 if (ShouldUseSingleTurnoutOverviewSnapshot(template, phase, electionType))
|
|
{
|
|
snapshot = await CreateSingleTurnoutOverviewSnapshotAsync(
|
|
apiClient,
|
|
electionType,
|
|
districts,
|
|
target,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
PopulateDataFields(result, snapshot, "GET /tupyo overview");
|
|
}
|
|
else
|
|
{
|
|
var refreshPhase = ResolveScheduleRefreshPhase(template, phase);
|
|
var refreshResult = await apiClient
|
|
.RefreshAsync(refreshPhase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
snapshot = CreateSnapshot(refreshPhase, electionType, refreshResult);
|
|
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
|
|
}
|
|
|
|
var sceneVariables = ResolveSceneVariablesForTemplate(sceneVariableCatalog, options.ImageRootPath, template);
|
|
if (!ValidateSnapshotForFormat(template, snapshot, sceneVariables, 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, pgmWindow).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 not null && options.KeepOnAirBetweenSends)
|
|
{
|
|
await TryOutAllAsync(adapter).ConfigureAwait(false);
|
|
}
|
|
|
|
if (adapter is IDisposable disposable)
|
|
{
|
|
disposable.Dispose();
|
|
}
|
|
|
|
WriteKarismaLog(options, logService);
|
|
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
|
|
: IsTopTurnoutDistrictBoardTemplate(template)
|
|
? 3
|
|
: 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 async Task<ElectionDataSnapshot> CreateAggregatePanseSnapshotAsync(
|
|
SbsElectionApiClient apiClient,
|
|
string electionType,
|
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> selectedDistricts,
|
|
FormatTemplateDefinition template,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var refreshResults = await apiClient
|
|
.GetCountingSnapshotsAsync(electionType, selectedDistricts, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (refreshResults.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("No counting data was available for panse aggregation.");
|
|
}
|
|
|
|
var candidates = IsCurrentLeaderTemplate(template) || IsNormalPanseMapTemplate(template)
|
|
? BuildRegionalLeaderCandidates(refreshResults)
|
|
: IsPanseEducationTemplate(template)
|
|
? BuildEducationPanseSummaryCandidates(refreshResults)
|
|
: BuildPartyPanseSummaryCandidates(electionType, refreshResults);
|
|
if (candidates.Length == 0)
|
|
{
|
|
throw new InvalidOperationException("No candidates were available for panse aggregation.");
|
|
}
|
|
|
|
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
|
|
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
|
|
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
|
|
var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0));
|
|
var countedRate = totalVotes <= 0
|
|
? refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max()
|
|
: Math.Round(countedVotes * 100d / totalVotes, 1, MidpointRounding.AwayFromZero);
|
|
var primaryDistrict = selectedDistricts.FirstOrDefault();
|
|
var regionName = ResolveAggregateRegionLabel(selectedDistricts);
|
|
var districtName = selectedDistricts.Count == 1
|
|
? primaryDistrict?.DisplayName ?? regionName
|
|
: regionName;
|
|
|
|
return new ElectionDataSnapshot
|
|
{
|
|
BroadcastPhase = BroadcastPhase.Counting,
|
|
ElectionType = electionType,
|
|
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
|
|
DistrictCode = selectedDistricts.Count == 1 ? primaryDistrict?.DistrictCode ?? string.Empty : string.Empty,
|
|
RegionName = regionName,
|
|
ElectionDistrictName = selectedDistricts.Count == 1 ? primaryDistrict?.DistrictName ?? districtName : regionName,
|
|
Candidates = candidates,
|
|
TotalExpectedVotes = totalVotes,
|
|
TurnoutVotes = turnoutVotes,
|
|
CountedVotesFromApi = countedVotes,
|
|
RemainingVotesFromApi = remainingVotes,
|
|
CountedRateFromApi = countedRate,
|
|
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max()
|
|
};
|
|
}
|
|
|
|
private static async Task<ElectionDataSnapshot> CreateAggregateCouncilSeatSnapshotAsync(
|
|
SbsElectionApiClient apiClient,
|
|
string electionType,
|
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> selectedDistricts,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var refreshResults = await apiClient
|
|
.GetCountingSnapshotsAsync(electionType, selectedDistricts, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (refreshResults.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("No counting data was available for council seat aggregation.");
|
|
}
|
|
|
|
var candidates = BuildCouncilSeatSummaryCandidates(refreshResults);
|
|
if (candidates.Length == 0)
|
|
{
|
|
throw new InvalidOperationException("No council seat candidates were available for aggregation.");
|
|
}
|
|
|
|
var totalVotes = refreshResults.Sum(result => Math.Max(0, result.TotalExpectedVotes));
|
|
var turnoutVotes = refreshResults.Sum(result => Math.Max(0, result.TurnoutVotes));
|
|
var countedVotes = refreshResults.Sum(result => Math.Max(0, result.CountedVotes ?? 0));
|
|
var remainingVotes = refreshResults.Sum(result => Math.Max(0, result.RemainingVotes ?? 0));
|
|
var countedRate = ResolveCouncilSeatAggregateCountedRate(refreshResults);
|
|
var primaryDistrict = selectedDistricts.FirstOrDefault();
|
|
var regionName = ResolveAggregateRegionLabel(selectedDistricts);
|
|
var districtName = selectedDistricts.Count == 1
|
|
? primaryDistrict?.DisplayName ?? regionName
|
|
: regionName;
|
|
|
|
return new ElectionDataSnapshot
|
|
{
|
|
BroadcastPhase = BroadcastPhase.Counting,
|
|
ElectionType = electionType,
|
|
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
|
|
DistrictCode = selectedDistricts.Count == 1 ? primaryDistrict?.DistrictCode ?? string.Empty : string.Empty,
|
|
RegionName = regionName,
|
|
ElectionDistrictName = selectedDistricts.Count == 1 ? primaryDistrict?.DistrictName ?? districtName : regionName,
|
|
Candidates = candidates,
|
|
TotalExpectedVotes = totalVotes,
|
|
TurnoutVotes = turnoutVotes,
|
|
CountedVotesFromApi = countedVotes,
|
|
RemainingVotesFromApi = remainingVotes,
|
|
CountedRateFromApi = countedRate,
|
|
ReceivedAt = refreshResults.Select(result => result.ReceivedAt).DefaultIfEmpty(DateTimeOffset.Now).Max()
|
|
};
|
|
}
|
|
|
|
private static IReadOnlyList<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> ResolveAggregateCouncilSeatGroups(
|
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> targets)
|
|
{
|
|
return targets
|
|
.GroupBy(ResolveCouncilSeatGroupKey, StringComparer.OrdinalIgnoreCase)
|
|
.Select(group => (IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>)group.ToArray())
|
|
.ToArray();
|
|
}
|
|
|
|
private static IReadOnlyList<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> ResolveCurrentLeaderGroups(
|
|
FormatTemplateDefinition template,
|
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> targets)
|
|
{
|
|
if (IsBottomCurrentLeaderTemplate(template))
|
|
{
|
|
return [targets];
|
|
}
|
|
|
|
var pageSize = Math.Max(1, ResolveCurrentLeaderPageSize(template));
|
|
return targets
|
|
.Select((target, index) => new { Target = target, PageIndex = index / pageSize })
|
|
.GroupBy(item => item.PageIndex)
|
|
.Select(group => (IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>)group
|
|
.Select(item => item.Target)
|
|
.ToArray())
|
|
.ToArray();
|
|
}
|
|
|
|
private static double ResolveCouncilSeatAggregateCountedRate(
|
|
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
|
{
|
|
return refreshResults.Select(result => result.CountedRate ?? 0).DefaultIfEmpty(0).Max();
|
|
}
|
|
|
|
private static string ResolveCouncilSeatGroupKey(SbsElectionApiClient.DistrictSelectionOption target)
|
|
{
|
|
return FirstNonWhiteSpace(target.RegionName, target.ParentRegionCode, target.DisplayName, "ALL");
|
|
}
|
|
|
|
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(
|
|
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
|
{
|
|
var seatCandidates = refreshResults.Any(result => !result.CountingClosed)
|
|
? refreshResults.SelectMany(ResolveCurrentCouncilSeatCandidates).ToArray()
|
|
: refreshResults
|
|
.SelectMany(result => result.Candidates ?? Array.Empty<CandidateEntry>())
|
|
.Where(candidate => CountsAsCouncilSeat(candidate.EffectiveJudgement))
|
|
.ToArray();
|
|
|
|
return BuildCouncilSeatSummaryCandidates(seatCandidates);
|
|
}
|
|
|
|
private static IEnumerable<CandidateEntry> ResolveCurrentCouncilSeatCandidates(
|
|
SbsElectionApiClient.SbsElectionRefreshResult result)
|
|
{
|
|
var candidates = result.Candidates ?? Array.Empty<CandidateEntry>();
|
|
if (candidates.Count == 0)
|
|
{
|
|
return Array.Empty<CandidateEntry>();
|
|
}
|
|
|
|
var seatCount = Math.Max(
|
|
result.SeatCount,
|
|
candidates.Select(candidate => candidate.BroadcastSeatCount).DefaultIfEmpty(0).Max());
|
|
if (seatCount <= 0)
|
|
{
|
|
return Array.Empty<CandidateEntry>();
|
|
}
|
|
|
|
var rankedCandidates = candidates
|
|
.Where(candidate => candidate.BroadcastRank > 0 && candidate.BroadcastRank <= seatCount)
|
|
.OrderBy(candidate => candidate.BroadcastRank)
|
|
.ThenByDescending(candidate => candidate.VoteCount)
|
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (rankedCandidates.Length > 0)
|
|
{
|
|
return rankedCandidates;
|
|
}
|
|
|
|
return candidates
|
|
.OrderByDescending(candidate => candidate.VoteCount)
|
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
|
.Take(seatCount)
|
|
.ToArray();
|
|
}
|
|
|
|
private static CandidateEntry[] BuildCouncilSeatSummaryCandidates(IReadOnlyList<CandidateEntry> candidates)
|
|
{
|
|
return candidates
|
|
.GroupBy(candidate => ResolvePanseParty(candidate), StringComparer.OrdinalIgnoreCase)
|
|
.Select(group =>
|
|
{
|
|
var first = group.First();
|
|
return new
|
|
{
|
|
Party = group.Key,
|
|
ColorParty = FirstNonWhiteSpace(first.EffectiveColorParty, first.Party, group.Key),
|
|
SeatCount = group.Count()
|
|
};
|
|
})
|
|
.Where(row => row.SeatCount > 0)
|
|
.OrderByDescending(row => row.SeatCount)
|
|
.ThenBy(row => row.Party, StringComparer.Ordinal)
|
|
.Select((row, index) => new CandidateEntry
|
|
{
|
|
CandidateCode = $"{CouncilSeatDistrictCandidateCodePrefix}{index + 1:00}",
|
|
BallotNumber = (index + 1).ToString(CultureInfo.InvariantCulture),
|
|
Name = row.Party,
|
|
Party = row.Party,
|
|
ColorParty = row.ColorParty,
|
|
VoteCount = row.SeatCount,
|
|
VoteRate = row.SeatCount,
|
|
HasImage = false,
|
|
ManualJudgement = CandidateJudgement.None,
|
|
AutomaticJudgement = CandidateJudgement.Elected
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
private static string ResolveAggregateRegionLabel(
|
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> selectedDistricts)
|
|
{
|
|
var regionNames = selectedDistricts
|
|
.Select(target => target.RegionName)
|
|
.Where(regionName => !string.IsNullOrWhiteSpace(regionName))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (regionNames.Length == 1)
|
|
{
|
|
return regionNames[0];
|
|
}
|
|
|
|
return "전체";
|
|
}
|
|
|
|
private static CandidateEntry[] BuildRegionalLeaderCandidates(
|
|
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
|
{
|
|
return refreshResults
|
|
.Select(CreateCurrentLeaderCandidate)
|
|
.Where(candidate => candidate is not null)
|
|
.Cast<CandidateEntry>()
|
|
.ToArray();
|
|
}
|
|
|
|
private static CandidateEntry[] BuildEducationPanseSummaryCandidates(
|
|
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
|
{
|
|
var leaders = refreshResults
|
|
.Select(CreateCurrentLeaderCandidate)
|
|
.Where(candidate => candidate is not null)
|
|
.Cast<CandidateEntry>()
|
|
.ToArray();
|
|
var counts = leaders
|
|
.GroupBy(candidate => NormalizePansePartyKey(ResolveEducationPanseParty(candidate)), StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
|
|
|
var rows = new[]
|
|
{
|
|
new PanseSummaryRow("진보", counts.TryGetValue("진보", out var progressiveCount) ? progressiveCount : 0),
|
|
new PanseSummaryRow("보수", counts.TryGetValue("보수", out var conservativeCount) ? conservativeCount : 0),
|
|
new PanseSummaryRow("중도", counts.TryGetValue("중도", out var moderateCount) ? moderateCount : 0)
|
|
};
|
|
|
|
var extraCount = counts
|
|
.Where(pair => pair.Key is not ("진보" or "보수" or "중도"))
|
|
.Sum(pair => pair.Value);
|
|
if (extraCount > 0)
|
|
{
|
|
rows[^1] = new PanseSummaryRow("기타", rows[^1].Count + extraCount);
|
|
}
|
|
|
|
return CreatePanseSummaryCandidates(rows);
|
|
}
|
|
|
|
private static CandidateEntry[] BuildPartyPanseSummaryCandidates(
|
|
string electionType,
|
|
IReadOnlyList<SbsElectionApiClient.SbsElectionRefreshResult> refreshResults)
|
|
{
|
|
var leaders = refreshResults
|
|
.Select(CreateCurrentLeaderCandidate)
|
|
.Where(candidate => candidate is not null)
|
|
.Cast<CandidateEntry>()
|
|
.ToArray();
|
|
return BuildPanseSummaryCandidates(leaders, ResolvePanseParty, countByVoteCount: false);
|
|
}
|
|
|
|
private static CandidateEntry? CreateCurrentLeaderCandidate(
|
|
SbsElectionApiClient.SbsElectionRefreshResult result)
|
|
{
|
|
var leader = (result.Candidates ?? Array.Empty<CandidateEntry>())
|
|
.OrderByDescending(candidate => candidate.VoteCount)
|
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
|
.FirstOrDefault();
|
|
if (leader is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var candidate = leader.Clone();
|
|
candidate.BroadcastDistrictName = FirstNonWhiteSpace(result.DistrictName, result.ElectionDistrictName);
|
|
candidate.BroadcastRegionName = result.RegionName;
|
|
candidate.BroadcastElectionDistrictName = FirstNonWhiteSpace(result.ElectionDistrictName, result.DistrictName);
|
|
candidate.BroadcastDistrictCode = result.DistrictCode;
|
|
candidate.BroadcastCountedRate = result.CountedRate;
|
|
return candidate;
|
|
}
|
|
|
|
private static CandidateEntry[] BuildPanseSummaryCandidates(
|
|
IReadOnlyList<CandidateEntry> candidates,
|
|
Func<CandidateEntry, string> partySelector,
|
|
bool countByVoteCount)
|
|
{
|
|
var counts = candidates
|
|
.GroupBy(candidate => ResolvePartyPanseGroup(partySelector(candidate)), StringComparer.Ordinal)
|
|
.ToDictionary(
|
|
group => group.Key,
|
|
group => group.Sum(candidate => countByVoteCount ? Math.Max(0, candidate.VoteCount) : 1),
|
|
StringComparer.Ordinal);
|
|
|
|
var rows = new[]
|
|
{
|
|
new PanseSummaryRow(PanseDemocraticPartyLabel, counts.TryGetValue(PanseDemocraticPartyLabel, out var democraticCount) ? democraticCount : 0),
|
|
new PanseSummaryRow(PansePeoplePowerPartyLabel, counts.TryGetValue(PansePeoplePowerPartyLabel, out var peoplePowerCount) ? peoplePowerCount : 0),
|
|
new PanseSummaryRow(PanseOtherPartyLabel, counts.TryGetValue(PanseOtherPartyLabel, out var otherCount) ? otherCount : 0)
|
|
};
|
|
|
|
return CreatePanseSummaryCandidates(rows);
|
|
}
|
|
|
|
private static string ResolvePartyPanseGroup(string party)
|
|
{
|
|
var normalized = NormalizePansePartyKey(party);
|
|
if (IsPanseDemocraticPartyKey(normalized))
|
|
{
|
|
return PanseDemocraticPartyLabel;
|
|
}
|
|
|
|
if (IsPansePeoplePowerPartyKey(normalized))
|
|
{
|
|
return PansePeoplePowerPartyLabel;
|
|
}
|
|
|
|
return PanseOtherPartyLabel;
|
|
}
|
|
|
|
private static bool IsPanseDemocraticPartyKey(string normalizedParty)
|
|
{
|
|
return string.Equals(normalizedParty, "더불어민주당", StringComparison.Ordinal) ||
|
|
string.Equals(normalizedParty, "민주당", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsPansePeoplePowerPartyKey(string normalizedParty)
|
|
{
|
|
return string.Equals(normalizedParty, "국민의힘", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static CandidateEntry[] CreatePanseSummaryCandidates(IReadOnlyList<PanseSummaryRow> rows)
|
|
{
|
|
return rows
|
|
.Select((row, index) => new CandidateEntry
|
|
{
|
|
CandidateCode = $"{PanseSummaryCandidateCodePrefix}{index + 1:00}",
|
|
BallotNumber = (index + 1).ToString(),
|
|
Name = row.Party,
|
|
Party = row.Party,
|
|
ColorParty = row.Party,
|
|
VoteCount = row.Count,
|
|
VoteRate = row.Count,
|
|
HasImage = false,
|
|
ManualJudgement = CandidateJudgement.None,
|
|
AutomaticJudgement = CandidateJudgement.Elected
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
private static bool CountsAsCouncilSeat(CandidateJudgement judgement)
|
|
{
|
|
return judgement is CandidateJudgement.Leading or
|
|
CandidateJudgement.Confirmed or
|
|
CandidateJudgement.Elected or
|
|
CandidateJudgement.ElectedInProgress or
|
|
CandidateJudgement.UnopposedElected or
|
|
CandidateJudgement.ElectedAfterCountComplete;
|
|
}
|
|
|
|
private static string ResolvePanseParty(CandidateEntry candidate)
|
|
{
|
|
return FirstNonWhiteSpace(candidate.Party, candidate.EffectiveColorParty, "무소속");
|
|
}
|
|
|
|
private static string ResolveEducationPanseParty(CandidateEntry candidate)
|
|
{
|
|
var party = FirstNonWhiteSpace(candidate.Party, candidate.EffectiveColorParty, "중도");
|
|
var normalized = NormalizePansePartyKey(party);
|
|
if (normalized.Contains("진보", StringComparison.Ordinal))
|
|
{
|
|
return "진보";
|
|
}
|
|
|
|
if (normalized.Contains("보수", StringComparison.Ordinal))
|
|
{
|
|
return "보수";
|
|
}
|
|
|
|
if (normalized.Contains("중도", StringComparison.Ordinal))
|
|
{
|
|
return "중도";
|
|
}
|
|
|
|
return string.IsNullOrWhiteSpace(party) ? "중도" : party.Trim();
|
|
}
|
|
|
|
private static string NormalizePansePartyKey(string party)
|
|
{
|
|
return string.Concat((party ?? string.Empty).Where(character => !char.IsWhiteSpace(character)));
|
|
}
|
|
|
|
private static async Task<ElectionDataSnapshot> CreateSingleTurnoutOverviewSnapshotAsync(
|
|
SbsElectionApiClient apiClient,
|
|
string electionType,
|
|
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> allDistricts,
|
|
SbsElectionApiClient.DistrictSelectionOption target,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var overview = await apiClient
|
|
.GetTurnoutOverviewAsync(electionType, allDistricts, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
var item = FindTurnoutOverviewItem(overview.Items, target);
|
|
if (item is null || item.TurnoutVotes <= 0 || item.TurnoutRate <= 0)
|
|
{
|
|
throw new InvalidOperationException("Selected region has no positive turnout overview data.");
|
|
}
|
|
|
|
var regionName = item.RegionName ?? target.RegionName ?? string.Empty;
|
|
var districtName = item.DisplayName ?? target.DisplayName ?? regionName;
|
|
var electionDistrictName = ResolveTurnoutElectionDistrictName(
|
|
electionType,
|
|
item,
|
|
target,
|
|
regionName,
|
|
districtName);
|
|
|
|
return new ElectionDataSnapshot
|
|
{
|
|
BroadcastPhase = BroadcastPhase.PreElection,
|
|
ElectionType = electionType,
|
|
DistrictName = string.IsNullOrWhiteSpace(districtName) ? regionName : districtName,
|
|
DistrictCode = item.DistrictCode ?? target.DistrictCode ?? string.Empty,
|
|
RegionName = regionName,
|
|
ElectionDistrictName = electionDistrictName,
|
|
Candidates = Array.Empty<CandidateEntry>(),
|
|
TotalExpectedVotes = Math.Max(0, item.TotalExpectedVotes),
|
|
TurnoutVotes = Math.Max(0, item.TurnoutVotes),
|
|
CountedVotesFromApi = null,
|
|
RemainingVotesFromApi = null,
|
|
CountedRateFromApi = null,
|
|
ReceivedAt = overview.ReceivedAt == default ? DateTimeOffset.Now : overview.ReceivedAt,
|
|
NationalTurnoutRateOverride = overview.NationalTurnoutRate
|
|
};
|
|
}
|
|
|
|
private static bool ShouldUseAggregateTurnoutSnapshot(
|
|
FormatTemplateDefinition template,
|
|
BroadcastPhase phase,
|
|
string electionType)
|
|
{
|
|
return phase == BroadcastPhase.PreElection &&
|
|
SupportsPreElectionTurnout(electionType) &&
|
|
(IsBottomTurnoutBoardTemplate(template) ||
|
|
IsRegionalTurnoutBoardTemplate(template) ||
|
|
IsTopTurnoutDistrictBoardTemplate(template));
|
|
}
|
|
|
|
private static bool ShouldUseAggregatePanseSnapshot(
|
|
FormatTemplateDefinition template,
|
|
BroadcastPhase phase)
|
|
{
|
|
return phase == BroadcastPhase.Counting &&
|
|
((template.RecommendedChannel == BroadcastChannel.TopLeft &&
|
|
template.Name.StartsWith("판세_", StringComparison.Ordinal)) ||
|
|
IsNormalPanseMapTemplate(template));
|
|
}
|
|
|
|
private static bool ShouldUseAggregateCurrentLeaderSnapshot(
|
|
FormatTemplateDefinition template,
|
|
BroadcastPhase phase)
|
|
{
|
|
return phase == BroadcastPhase.Counting &&
|
|
IsCurrentLeaderTemplate(template);
|
|
}
|
|
|
|
private static bool IsCurrentLeaderTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return template.Name.StartsWith("이시각1위_", StringComparison.Ordinal) ||
|
|
IsBottomCurrentLeaderTemplate(template);
|
|
}
|
|
|
|
private static bool IsBottomCurrentLeaderTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return template.RecommendedChannel == BroadcastChannel.Bottom &&
|
|
template.Name.StartsWith("1위_", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static int ResolveCurrentLeaderPageSize(FormatTemplateDefinition template)
|
|
{
|
|
if (template.RecommendedChannel == BroadcastChannel.Bottom &&
|
|
template.Name.StartsWith("1위_", StringComparison.Ordinal))
|
|
{
|
|
return 3;
|
|
}
|
|
|
|
if (template.Name.Contains("_L", StringComparison.Ordinal) ||
|
|
template.Id.Contains("_L", StringComparison.Ordinal) ||
|
|
template.SceneWidth >= 5000)
|
|
{
|
|
return 3;
|
|
}
|
|
|
|
return template.Name.Contains("_HD", StringComparison.Ordinal) ||
|
|
template.Id.Contains("_HD", StringComparison.Ordinal)
|
|
? 2
|
|
: 1;
|
|
}
|
|
|
|
private static bool ShouldUseAggregateCouncilSeatSnapshot(
|
|
FormatTemplateDefinition template,
|
|
BroadcastPhase phase)
|
|
{
|
|
return phase == BroadcastPhase.Counting &&
|
|
ScheduleTemplatePolicy.IsCouncilSeatTableFormat(template.Name);
|
|
}
|
|
|
|
private static bool IsNormalPanseMapTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return template.RecommendedChannel == BroadcastChannel.Normal &&
|
|
string.Equals(template.Name, "판세_광역단체장", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsPanseEducationTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return string.Equals(template.Name, "판세_교육감", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsCouncilElectionType(string electionType)
|
|
{
|
|
return string.Equals(electionType, "광역의원", StringComparison.Ordinal) ||
|
|
string.Equals(electionType, "기초의원", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool ShouldUseSingleTurnoutOverviewSnapshot(
|
|
FormatTemplateDefinition template,
|
|
BroadcastPhase phase,
|
|
string electionType)
|
|
{
|
|
return phase == BroadcastPhase.PreElection &&
|
|
SupportsPreElectionTurnout(electionType) &&
|
|
!ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) &&
|
|
template.Name.Contains("투표율", StringComparison.Ordinal);
|
|
}
|
|
|
|
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 bool IsTopTurnoutDistrictBoardTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return template.RecommendedChannel == BroadcastChannel.TopLeft &&
|
|
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,
|
|
bool useAllRegions = false)
|
|
{
|
|
var regionFilters = useAllRegions ? Array.Empty<string>() : station.RegionFilters;
|
|
var cacheKey = $"{electionType}|{string.Join(",", regionFilters)}";
|
|
if (!districtCache.TryGetValue(cacheKey, out var districts))
|
|
{
|
|
districts = await apiClient
|
|
.GetDistrictOptionsAsync(electionType, 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 => GetNormalizedRegionKeys(district.RegionName).Any(configuredRegions.Contains));
|
|
}
|
|
|
|
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,
|
|
PgmWindow? pgmWindow)
|
|
{
|
|
foreach (var cut in ResolveDiagnosticPlaybackCuts(template, snapshot))
|
|
{
|
|
Exception? lastException = null;
|
|
for (var attempt = 1; attempt <= 3; attempt++)
|
|
{
|
|
try
|
|
{
|
|
await SendSingleCutAsync(adapter, station, template, cut, snapshot, options, result, pgmWindow).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 IReadOnlyList<FormatCutDefinition> ResolveDiagnosticPlaybackCuts(
|
|
FormatTemplateDefinition template,
|
|
ElectionDataSnapshot snapshot)
|
|
{
|
|
if (!IsCandidatePagedTemplate(template) || template.Cuts.Count == 0)
|
|
{
|
|
return template.Cuts;
|
|
}
|
|
|
|
var candidateCount = Math.Max(snapshot.Candidates.Count, 1);
|
|
var pageSize = ResolveCandidatePageSize(template);
|
|
var pageStarts = Enumerable
|
|
.Range(0, (int)Math.Ceiling(candidateCount / (double)pageSize))
|
|
.Select(pageIndex => pageIndex * pageSize)
|
|
.ToArray();
|
|
var playbackCuts = new List<FormatCutDefinition>(template.Cuts.Count * pageStarts.Length);
|
|
foreach (var baseCut in template.Cuts)
|
|
{
|
|
foreach (var candidateStartIndex in pageStarts)
|
|
{
|
|
var isLastPage = candidateStartIndex == pageStarts[^1];
|
|
var cutName = ResolveCandidatePagedCutName(template, baseCut.Name, candidateStartIndex, isLastPage);
|
|
playbackCuts.Add(new FormatCutDefinition
|
|
{
|
|
Name = cutName,
|
|
DurationSeconds = baseCut.DurationSeconds,
|
|
CandidateStartIndex = candidateStartIndex,
|
|
UseEndScene = baseCut.UseEndScene,
|
|
SceneIdOverride = ResolvePagedSceneId(template, cutName)
|
|
});
|
|
}
|
|
}
|
|
|
|
return playbackCuts;
|
|
}
|
|
|
|
private static string ResolveCandidatePagedCutName(
|
|
FormatTemplateDefinition template,
|
|
string cutName,
|
|
int candidateStartIndex,
|
|
bool isLastPage)
|
|
{
|
|
if (IsAllCandidateTemplate(template))
|
|
{
|
|
if (candidateStartIndex == 0)
|
|
{
|
|
return cutName;
|
|
}
|
|
|
|
if (template.RecommendedChannel == BroadcastChannel.Bottom)
|
|
{
|
|
return ResolveSuffixedCutName(cutName, "_loop");
|
|
}
|
|
|
|
if (isLastPage)
|
|
{
|
|
return ResolveSuffixedCutName(cutName, "_END");
|
|
}
|
|
|
|
return ResolveCandidatePageSize(template) == 1
|
|
? ResolveSuffixedCutName(cutName, "_loop")
|
|
: cutName;
|
|
}
|
|
|
|
return candidateStartIndex == 0
|
|
? cutName
|
|
: ResolveSuffixedCutName(cutName, "_loop");
|
|
}
|
|
|
|
private static string ResolveSuffixedCutName(string cutName, string suffix)
|
|
{
|
|
if (cutName.EndsWith(suffix, StringComparison.Ordinal))
|
|
{
|
|
return cutName;
|
|
}
|
|
|
|
const string inSuffix = "_in";
|
|
if (string.Equals(suffix, "_loop", StringComparison.Ordinal) &&
|
|
cutName.EndsWith(inSuffix, StringComparison.Ordinal))
|
|
{
|
|
return cutName[..^inSuffix.Length] + suffix;
|
|
}
|
|
|
|
return cutName + suffix;
|
|
}
|
|
|
|
private static string ResolvePagedSceneId(FormatTemplateDefinition template, string cutName)
|
|
{
|
|
var folderName = Path.GetDirectoryName(template.Id);
|
|
return string.IsNullOrWhiteSpace(folderName)
|
|
? cutName
|
|
: Path.Combine(folderName, cutName);
|
|
}
|
|
|
|
private static bool IsCandidatePagedTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return IsCareerTemplate(template) ||
|
|
IsAllCandidateTemplate(template) ||
|
|
IsBottomCurrentLeaderTemplate(template);
|
|
}
|
|
|
|
private static bool IsCareerTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return template.Name.StartsWith("경력_", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static BroadcastPhase ResolveScheduleRefreshPhase(FormatTemplateDefinition template, BroadcastPhase phase)
|
|
{
|
|
return phase == BroadcastPhase.PreElection && IsCareerTemplate(template)
|
|
? BroadcastPhase.Counting
|
|
: phase;
|
|
}
|
|
|
|
private static bool IsAllCandidateTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return template.Name.StartsWith("모든후보_", StringComparison.Ordinal) ||
|
|
template.Name.StartsWith("전후보_", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static int ResolveCandidatePageSize(FormatTemplateDefinition template)
|
|
{
|
|
if (IsBottomCurrentLeaderTemplate(template) ||
|
|
(template.RecommendedChannel == BroadcastChannel.Bottom && IsAllCandidateTemplate(template)))
|
|
{
|
|
return 3;
|
|
}
|
|
|
|
if (!IsAllCandidateTemplate(template))
|
|
{
|
|
return 1;
|
|
}
|
|
|
|
return template.SceneWidth >= 5000 ||
|
|
template.Name.Contains("8316", StringComparison.Ordinal) ||
|
|
template.Name.Contains("3840", StringComparison.Ordinal) ||
|
|
template.Name.Contains("2880", StringComparison.Ordinal) ||
|
|
template.Name.Contains("5760", StringComparison.Ordinal) ||
|
|
template.Id.Contains("_L", StringComparison.Ordinal)
|
|
? 3
|
|
: 1;
|
|
}
|
|
|
|
private static async Task SendSingleCutAsync(
|
|
ITornado3Adapter adapter,
|
|
BroadcastStationProfile station,
|
|
FormatTemplateDefinition template,
|
|
FormatCutDefinition cut,
|
|
ElectionDataSnapshot snapshot,
|
|
CurrentApiCutDiagnosticsOptions options,
|
|
CurrentApiCutDiagnosticResult result,
|
|
PgmWindow? pgmWindow)
|
|
{
|
|
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");
|
|
if (options.CaptureMixedPreviewImages)
|
|
{
|
|
await CaptureMixedPreviewImagesIfRequestedAsync(adapter, template, cut, options, result).ConfigureAwait(false);
|
|
}
|
|
|
|
if (options.CaptureSceneImages || options.CapturePgmImages)
|
|
{
|
|
await Task.Delay(options.OnAirDelayMs, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
await CaptureSceneImageIfRequestedAsync(adapter, template, cut, options, result).ConfigureAwait(false);
|
|
CapturePgmImageIfRequested(pgmWindow, template, cut, options, result);
|
|
}
|
|
finally
|
|
{
|
|
if (!options.KeepOnAirBetweenSends)
|
|
{
|
|
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 TryOutAllAsync(ITornado3Adapter adapter)
|
|
{
|
|
foreach (var channel in Enum.GetValues<BroadcastChannel>())
|
|
{
|
|
try
|
|
{
|
|
await TryOutAsync(adapter, channel).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
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.Channel}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}");
|
|
var (width, height) = ResolveSceneCaptureSize(template);
|
|
var captureFrames = options.SceneCaptureFrames.Count == 0
|
|
? new[] { -1 }
|
|
: options.SceneCaptureFrames;
|
|
|
|
for (var index = 0; index < captureFrames.Count; index++)
|
|
{
|
|
var frame = captureFrames[index];
|
|
var frameToken = frame < 0 ? "final" : $"f{frame:0000}";
|
|
var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}_{frameToken}.png"));
|
|
|
|
await karismaAdapter.SavePendingSceneImageAsync(
|
|
template.RecommendedChannel,
|
|
outputPath,
|
|
width,
|
|
height,
|
|
frame,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
ThrowIfAdapterErrored(adapter, "capture");
|
|
|
|
if (index == captureFrames.Count - 1)
|
|
{
|
|
result.CapturePath = outputPath;
|
|
result.CaptureHash = ComputeSha256(outputPath);
|
|
result.CaptureBytes = new FileInfo(outputPath).Length;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task CaptureMixedPreviewImagesIfRequestedAsync(
|
|
ITornado3Adapter adapter,
|
|
FormatTemplateDefinition template,
|
|
FormatCutDefinition cut,
|
|
CurrentApiCutDiagnosticsOptions options,
|
|
CurrentApiCutDiagnosticResult result)
|
|
{
|
|
if (!options.CaptureMixedPreviewImages)
|
|
{
|
|
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.Channel}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}_MIXED");
|
|
var (width, height) = ResolveSceneCaptureSize(template);
|
|
var captureDelays = options.MixedPreviewCaptureDelaysMs.Count == 0
|
|
? new[] { options.OnAirDelayMs }
|
|
: options.MixedPreviewCaptureDelaysMs
|
|
.Where(delay => delay >= 0)
|
|
.Distinct()
|
|
.OrderBy(delay => delay)
|
|
.ToArray();
|
|
|
|
var elapsedMs = 0;
|
|
for (var index = 0; index < captureDelays.Length; index++)
|
|
{
|
|
var delayMs = captureDelays[index];
|
|
var waitMs = Math.Max(0, delayMs - elapsedMs);
|
|
if (waitMs > 0)
|
|
{
|
|
await Task.Delay(waitMs, CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
|
|
elapsedMs = delayMs;
|
|
var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}_d{delayMs:0000}.png"));
|
|
var captured = await adapter.TryCapturePendingCutPreviewAsync(
|
|
template.RecommendedChannel,
|
|
outputPath,
|
|
width,
|
|
height,
|
|
frame: -1,
|
|
CancellationToken.None)
|
|
.ConfigureAwait(false);
|
|
ThrowIfAdapterErrored(adapter, "mixed-preview-capture");
|
|
|
|
if (!captured)
|
|
{
|
|
result.Warning = AppendWarning(result.Warning, $"mixed preview capture failed at {delayMs}ms");
|
|
continue;
|
|
}
|
|
|
|
if (index == captureDelays.Length - 1)
|
|
{
|
|
result.MixedPreviewCapturePath = outputPath;
|
|
result.MixedPreviewCaptureHash = ComputeSha256(outputPath);
|
|
result.MixedPreviewCaptureBytes = new FileInfo(outputPath).Length;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void CapturePgmImageIfRequested(
|
|
PgmWindow? pgmWindow,
|
|
FormatTemplateDefinition template,
|
|
FormatCutDefinition cut,
|
|
CurrentApiCutDiagnosticsOptions options,
|
|
CurrentApiCutDiagnosticResult result)
|
|
{
|
|
if (!options.CapturePgmImages || pgmWindow is null)
|
|
{
|
|
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.Channel}_{result.ElectionType}_{template.Name}_{districtToken}_{cut.Name}_PGM");
|
|
var outputPath = Path.GetFullPath(Path.Combine(captureDirectory, $"{fileStem}.png"));
|
|
|
|
using var bitmap = CaptureWindowBitmap(pgmWindow.Value.Bounds);
|
|
bitmap.Save(outputPath, ImageFormat.Png);
|
|
|
|
result.PgmCapturePath = outputPath;
|
|
result.PgmCaptureHash = ComputeSha256(outputPath);
|
|
result.PgmCaptureBytes = new FileInfo(outputPath).Length;
|
|
}
|
|
|
|
private static PgmWindow? TryFindPgmWindow()
|
|
{
|
|
var process = Process.GetProcessesByName("Tornado3")
|
|
.FirstOrDefault(candidate => string.Equals(candidate.MainWindowTitle, "PGM", StringComparison.Ordinal));
|
|
if (process is not null && process.MainWindowHandle != IntPtr.Zero)
|
|
{
|
|
var processWindow = TryBuildPgmWindow(process.MainWindowHandle);
|
|
if (processWindow is not null)
|
|
{
|
|
return processWindow;
|
|
}
|
|
}
|
|
|
|
var windows = new List<PgmWindow>();
|
|
EnumWindows((windowHandle, _) =>
|
|
{
|
|
if (!IsWindowVisible(windowHandle) ||
|
|
!string.Equals(GetWindowTitle(windowHandle), "PGM", StringComparison.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
var window = TryBuildPgmWindow(windowHandle);
|
|
if (window is not null)
|
|
{
|
|
windows.Add(window.Value);
|
|
}
|
|
|
|
return true;
|
|
}, IntPtr.Zero);
|
|
|
|
return windows.Count == 0
|
|
? null
|
|
: windows
|
|
.OrderByDescending(window => window.Bounds.Width * window.Bounds.Height)
|
|
.First();
|
|
}
|
|
|
|
private static PgmWindow? TryBuildPgmWindow(IntPtr windowHandle)
|
|
{
|
|
if (!GetWindowRect(windowHandle, out var rect))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var width = rect.Right - rect.Left;
|
|
var height = rect.Bottom - rect.Top;
|
|
if (width <= 0 || height <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new PgmWindow(windowHandle, new Rect(rect.Left, rect.Top, width, height));
|
|
}
|
|
|
|
private static string GetWindowTitle(IntPtr windowHandle)
|
|
{
|
|
var length = GetWindowTextLength(windowHandle);
|
|
if (length <= 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var builder = new StringBuilder(length + 1);
|
|
_ = GetWindowText(windowHandle, builder, builder.Capacity);
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static Bitmap CaptureWindowBitmap(Rect bounds)
|
|
{
|
|
var width = Math.Max(1, bounds.Width);
|
|
var height = Math.Max(1, bounds.Height);
|
|
var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
|
|
using var graphics = Graphics.FromImage(bitmap);
|
|
|
|
graphics.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy);
|
|
return bitmap;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool GetWindowRect(IntPtr hWnd, out NativeRect lpRect);
|
|
|
|
private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
|
|
|
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
|
|
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
private static extern int GetWindowTextLength(IntPtr hWnd);
|
|
|
|
[DllImport("user32.dll")]
|
|
[return: MarshalAs(UnmanagedType.Bool)]
|
|
private static extern bool IsWindowVisible(IntPtr hWnd);
|
|
|
|
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,
|
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
|
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();
|
|
var isWinnerTemplate = IsWinnerTemplate(template);
|
|
|
|
if (validCandidates.Length == 0)
|
|
{
|
|
errorMessage = isWinnerTemplate
|
|
? "winner candidate list is empty"
|
|
: "candidate list is empty";
|
|
return false;
|
|
}
|
|
|
|
if (isWinnerTemplate &&
|
|
!validCandidates.Any(candidate => IsElectedJudgement(candidate.EffectiveJudgement)))
|
|
{
|
|
errorMessage = "elected judgement is missing";
|
|
return false;
|
|
}
|
|
|
|
if (!isWinnerTemplate && validCandidates.Length != snapshot.Candidates.Count)
|
|
{
|
|
errorMessage = "required candidate fields are blank";
|
|
return false;
|
|
}
|
|
|
|
if (sceneVariables.Count > 0 &&
|
|
RequiresTopTwoCandidateNameVariables(template) &&
|
|
!HasCandidateNameVariables(sceneVariables, Math.Min(2, validCandidates.Length), out var missingCandidateNameVariables))
|
|
{
|
|
errorMessage = $"candidate name variables are missing from scene: {string.Join(", ", missingCandidateNameVariables)}";
|
|
return false;
|
|
}
|
|
|
|
var requiredCandidateCount = ResolveRequiredCandidateCount(template);
|
|
if (requiredCandidateCount > 0 && validCandidates.Length < requiredCandidateCount)
|
|
{
|
|
warning = $"template-slot-count-{requiredCandidateCount}-with-{validCandidates.Length}-candidates";
|
|
}
|
|
|
|
if (!isWinnerTemplate && !IsCareerTemplate(template) && 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 IReadOnlyDictionary<string, KarismaSceneVariableDefinition> ResolveSceneVariablesForTemplate(
|
|
KarismaSceneVariableCatalog sceneVariableCatalog,
|
|
string t3CutPath,
|
|
FormatTemplateDefinition template)
|
|
{
|
|
try
|
|
{
|
|
var resolvedScene = KarismaSceneResolver.ResolveScene(template, t3CutPath, useLoop: false);
|
|
return sceneVariableCatalog.GetSceneVariables(t3CutPath, resolvedScene.Path);
|
|
}
|
|
catch
|
|
{
|
|
return new Dictionary<string, KarismaSceneVariableDefinition>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
}
|
|
|
|
private static bool RequiresTopTwoCandidateNameVariables(FormatTemplateDefinition template)
|
|
{
|
|
if (template.RecommendedChannel != BroadcastChannel.TopLeft)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return string.Equals(template.Name, "광역단체장_2인", StringComparison.Ordinal) ||
|
|
string.Equals(template.Name, "광역단체장_2인_텍스트", StringComparison.Ordinal) ||
|
|
string.Equals(template.Name, "기초단체장_2인", StringComparison.Ordinal) ||
|
|
string.Equals(template.Name, "기초단체장_2인_텍스트", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool HasCandidateNameVariables(
|
|
IReadOnlyDictionary<string, KarismaSceneVariableDefinition> sceneVariables,
|
|
int requiredSlotCount,
|
|
out IReadOnlyList<string> missingVariables)
|
|
{
|
|
var missing = new List<string>();
|
|
for (var slot = 1; slot <= requiredSlotCount; slot++)
|
|
{
|
|
var variableName = $"후보명{slot:00}";
|
|
if (!sceneVariables.ContainsKey(variableName))
|
|
{
|
|
missing.Add(variableName);
|
|
}
|
|
}
|
|
|
|
missingVariables = missing;
|
|
return missing.Count == 0;
|
|
}
|
|
|
|
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 string GetTemplateBaseName(string? source)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(source))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
return Path.GetFileNameWithoutExtension(
|
|
source
|
|
.Replace('/', Path.DirectorySeparatorChar)
|
|
.Replace('\\', Path.DirectorySeparatorChar));
|
|
}
|
|
|
|
private static bool IsWinnerTemplate(FormatTemplateDefinition template)
|
|
{
|
|
return IsWinnerTemplate(template.Name) ||
|
|
IsWinnerTemplate(template.Id) ||
|
|
template.Cuts.Any(cut => IsWinnerTemplate(cut.Name));
|
|
}
|
|
|
|
private static bool IsWinnerTemplate(string? source)
|
|
{
|
|
return GetTemplateBaseName(source).StartsWith("당선_", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static bool IsElectedJudgement(CandidateJudgement judgement)
|
|
{
|
|
return judgement is CandidateJudgement.Elected
|
|
or CandidateJudgement.ElectedInProgress
|
|
or CandidateJudgement.UnopposedElected
|
|
or CandidateJudgement.ElectedAfterCountComplete;
|
|
}
|
|
|
|
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(
|
|
FormatTemplateDefinition template,
|
|
BroadcastPhase phase,
|
|
string defaultElectionType)
|
|
{
|
|
if (IsTopTurnoutDistrictBoardTemplate(template))
|
|
{
|
|
return "기초단체장";
|
|
}
|
|
|
|
return ResolveScheduleElectionType(template.Name, phase, defaultElectionType);
|
|
}
|
|
|
|
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 IEnumerable<string> GetNormalizedRegionKeys(string? regionName)
|
|
{
|
|
var normalizedRegion = NormalizeRegion(regionName);
|
|
if (string.IsNullOrWhiteSpace(normalizedRegion))
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
yield return normalizedRegion;
|
|
|
|
if (string.Equals(normalizedRegion, "전남광주", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(normalizedRegion, "광주전남", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
yield return "광주";
|
|
yield return "전남";
|
|
}
|
|
}
|
|
|
|
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.NationalTurnoutRate = snapshot.NationalTurnoutRate;
|
|
result.Leader = snapshot.Candidates
|
|
.OrderByDescending(candidate => candidate.VoteCount)
|
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
|
.FirstOrDefault()?.Name ?? string.Empty;
|
|
result.Candidates = snapshot.Candidates
|
|
.OrderByDescending(candidate => candidate.VoteCount)
|
|
.ThenBy(candidate => candidate.Name, StringComparer.Ordinal)
|
|
.Select((candidate, index) => new CurrentApiCandidateDiagnostic
|
|
{
|
|
Rank = index + 1,
|
|
BallotNumber = candidate.BallotNumber,
|
|
Name = candidate.Name,
|
|
Party = candidate.Party,
|
|
VoteCount = candidate.VoteCount,
|
|
VoteRate = candidate.VoteRate,
|
|
VoteRateDisplay = candidate.VoteRateDisplay,
|
|
Judgement = candidate.EffectiveJudgement.ToString()
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
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 static string AppendWarning(string current, string warning)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(current))
|
|
{
|
|
return warning;
|
|
}
|
|
|
|
return current + "; " + warning;
|
|
}
|
|
|
|
private static void WriteKarismaLog(CurrentApiCutDiagnosticsOptions options, LogService logService)
|
|
{
|
|
var logPath = Path.Combine(options.OutputPath, "karisma-log.txt");
|
|
var lines = logService.Entries
|
|
.Reverse()
|
|
.Where(entry => entry is not null)
|
|
.Select(entry => $"{entry.Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{entry.Level}] {entry.Message ?? string.Empty}");
|
|
File.WriteAllLines(logPath, lines);
|
|
}
|
|
|
|
private readonly record struct PgmWindow(IntPtr Handle, Rect Bounds);
|
|
|
|
private readonly record struct Rect(int Left, int Top, int Width, int Height);
|
|
|
|
[StructLayout(LayoutKind.Sequential)]
|
|
private readonly struct NativeRect
|
|
{
|
|
public int Left { get; init; }
|
|
public int Top { get; init; }
|
|
public int Right { get; init; }
|
|
public int Bottom { get; init; }
|
|
}
|
|
|
|
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 bool CaptureMixedPreviewImages { get; init; }
|
|
|
|
public bool CapturePgmImages { get; init; }
|
|
|
|
public IReadOnlyList<int> SceneCaptureFrames { get; init; } = Array.Empty<int>();
|
|
|
|
public IReadOnlyList<int> MixedPreviewCaptureDelaysMs { get; init; } = Array.Empty<int>();
|
|
|
|
public bool KeepOnAirBetweenSends { get; init; }
|
|
|
|
public int OnAirDelayMs { get; init; } = 900;
|
|
|
|
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 captureMixedPreviewImages = false;
|
|
var capturePgmImages = false;
|
|
IReadOnlyList<int> sceneCaptureFrames = Array.Empty<int>();
|
|
IReadOnlyList<int> mixedPreviewCaptureDelaysMs = Array.Empty<int>();
|
|
var keepOnAirBetweenSends = false;
|
|
var onAirDelayMs = 900;
|
|
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 "--capture-scene-frames":
|
|
sceneCaptureFrames = ParseFrameList(NextValue());
|
|
captureSceneImages = true;
|
|
break;
|
|
case "--capture-mixed-preview-images":
|
|
captureMixedPreviewImages = true;
|
|
break;
|
|
case "--capture-mixed-preview-delays-ms":
|
|
mixedPreviewCaptureDelaysMs = ParseFrameList(NextValue());
|
|
captureMixedPreviewImages = true;
|
|
break;
|
|
case "--capture-pgm-images":
|
|
capturePgmImages = true;
|
|
break;
|
|
case "--keep-on-air-between-sends":
|
|
keepOnAirBetweenSends = true;
|
|
break;
|
|
case "--on-air-delay-ms":
|
|
if (int.TryParse(NextValue(), out var parsedOnAirDelayMs))
|
|
{
|
|
onAirDelayMs = Math.Max(0, parsedOnAirDelayMs);
|
|
}
|
|
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,
|
|
CaptureMixedPreviewImages = captureMixedPreviewImages,
|
|
CapturePgmImages = capturePgmImages,
|
|
SceneCaptureFrames = sceneCaptureFrames,
|
|
MixedPreviewCaptureDelaysMs = mixedPreviewCaptureDelaysMs,
|
|
KeepOnAirBetweenSends = keepOnAirBetweenSends,
|
|
OnAirDelayMs = onAirDelayMs,
|
|
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 static IReadOnlyList<int> ParseFrameList(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return Array.Empty<int>();
|
|
}
|
|
|
|
return value
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(part => int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var frame)
|
|
? frame
|
|
: (int?)null)
|
|
.Where(frame => frame.HasValue)
|
|
.Select(frame => frame!.Value)
|
|
.Distinct()
|
|
.ToArray();
|
|
}
|
|
}
|
|
|
|
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 string MixedPreviewCapturePath { get; set; } = string.Empty;
|
|
|
|
public string MixedPreviewCaptureHash { get; set; } = string.Empty;
|
|
|
|
public long MixedPreviewCaptureBytes { get; set; }
|
|
|
|
public string PgmCapturePath { get; set; } = string.Empty;
|
|
|
|
public string PgmCaptureHash { get; set; } = string.Empty;
|
|
|
|
public long PgmCaptureBytes { 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 double NationalTurnoutRate { get; set; }
|
|
|
|
public string Leader { get; set; } = string.Empty;
|
|
|
|
public List<CurrentApiCandidateDiagnostic> Candidates { get; set; } = new List<CurrentApiCandidateDiagnostic>();
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
|
|
private sealed class CurrentApiCandidateDiagnostic
|
|
{
|
|
public int Rank { get; set; }
|
|
|
|
public string BallotNumber { get; set; } = string.Empty;
|
|
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
public string Party { get; set; } = string.Empty;
|
|
|
|
public int VoteCount { get; set; }
|
|
|
|
public double VoteRate { get; set; }
|
|
|
|
public string VoteRateDisplay { get; set; } = string.Empty;
|
|
|
|
public string Judgement { get; set; } = string.Empty;
|
|
}
|
|
|
|
private readonly record struct PanseSummaryRow(string Party, int Count);
|
|
}
|