This commit is contained in:
2026-05-02 05:35:16 +09:00
parent 57aeba4bb8
commit e40a2a568e
36 changed files with 3198 additions and 411 deletions

View File

@@ -58,7 +58,7 @@ internal static class CurrentApiCutDiagnostics
Console.WriteLine($"- Station: {(options.AllStations ? "ALL" : options.StationId)}");
Console.WriteLine($"- Region Scope: {options.RegionScope}");
Console.WriteLine($"- Max Regions: {(options.MaxRegions <= 0 ? "all" : options.MaxRegions)}");
Console.WriteLine($"- Simulated Sends: {(options.SimulateSend ? options.SendLimit.ToString() : "off")}");
Console.WriteLine($"- Send Mode: {ResolveSendModeLabel(options)}");
Console.WriteLine($"- Output: {options.OutputPath}");
var stationCatalog = new StationCatalogService().GetAll();
@@ -80,6 +80,9 @@ internal static class CurrentApiCutDiagnostics
.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)
@@ -99,7 +102,17 @@ internal static class CurrentApiCutDiagnostics
using var apiClient = new SbsElectionApiClient();
var logService = new LogService();
var adapter = options.SimulateSend ? new MockTornado3Adapter(logService) : null;
var preElectionHistoryService = new PreElectionHistoryService(logService);
ITornado3Adapter? adapter;
try
{
adapter = CreateSendAdapter(options, logService);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
return 1;
}
var districtCache = new Dictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>>(StringComparer.Ordinal);
var results = new List<CurrentApiCutDiagnosticResult>();
var simulatedSendCount = 0;
@@ -109,7 +122,17 @@ internal static class CurrentApiCutDiagnostics
foreach (var template in formats)
{
var electionType = ResolveScheduleElectionType(template.Name, phase, options.DefaultElectionType);
var districts = await GetDistrictsAsync(apiClient, districtCache, electionType).ConfigureAwait(false);
IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption> districts;
try
{
districts = await GetDistrictsAsync(apiClient, districtCache, electionType, station).ConfigureAwait(false);
}
catch (Exception ex)
{
results.Add(CurrentApiCutDiagnosticResult.DistrictLoadFailed(station, template, phase, electionType, ex.Message));
continue;
}
var targets = ResolveTargets(districts, station, options)
.ToArray();
@@ -136,11 +159,24 @@ internal static class CurrentApiCutDiagnostics
try
{
var refreshResult = await apiClient
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
.ConfigureAwait(false);
var snapshot = CreateSnapshot(phase, electionType, refreshResult);
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
ElectionDataSnapshot snapshot;
if (UsesStoredPreElectionHistory(template))
{
snapshot = CreateStoredPreElectionHistorySnapshot(
phase,
electionType,
target,
preElectionHistoryService);
PopulateDataFields(result, snapshot, "stored pre-election history");
}
else
{
var refreshResult = await apiClient
.RefreshAsync(phase, electionType, target.DisplayName, target.DistrictCode, CancellationToken.None)
.ConfigureAwait(false);
snapshot = CreateSnapshot(phase, electionType, refreshResult);
PopulateDataFields(result, snapshot, refreshResult.SourcePath);
}
if (!ValidateSnapshotForFormat(template, snapshot, out var validationError, out var warning))
{
@@ -152,8 +188,10 @@ internal static class CurrentApiCutDiagnostics
{
await SimulateSendAsync(adapter, station, template, snapshot, options.ImageRootPath).ConfigureAwait(false);
simulatedSendCount++;
result.Status = "sent-mock";
result.Detail = "validated and mock send completed";
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
@@ -174,6 +212,11 @@ internal static class CurrentApiCutDiagnostics
}
}
if (adapter is IDisposable disposable)
{
disposable.Dispose();
}
WriteReports(options, results);
PrintSummary(results, options.OutputPath);
@@ -182,15 +225,53 @@ internal static class CurrentApiCutDiagnostics
: 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<IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> GetDistrictsAsync(
SbsElectionApiClient apiClient,
IDictionary<string, IReadOnlyList<SbsElectionApiClient.DistrictSelectionOption>> districtCache,
string electionType)
string electionType,
BroadcastStationProfile station)
{
if (!districtCache.TryGetValue(electionType, out var districts))
var cacheKey = $"{electionType}|{string.Join(",", station.RegionFilters)}";
if (!districtCache.TryGetValue(cacheKey, out var districts))
{
districts = await apiClient.GetDistrictOptionsAsync(electionType, CancellationToken.None).ConfigureAwait(false);
districtCache[electionType] = districts;
districts = await apiClient
.GetDistrictOptionsAsync(electionType, station.RegionFilters, CancellationToken.None)
.ConfigureAwait(false);
districtCache[cacheKey] = districts;
}
return districts;
@@ -265,6 +346,42 @@ internal static class CurrentApiCutDiagnostics
};
}
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 = string.IsNullOrWhiteSpace(regionName) ? districtName : regionName,
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 async Task SimulateSendAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
@@ -274,11 +391,84 @@ internal static class CurrentApiCutDiagnostics
{
foreach (var cut in template.Cuts)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
Exception? lastException = null;
for (var attempt = 1; attempt <= 3; attempt++)
{
try
{
await SendSingleCutAsync(adapter, station, template, cut, snapshot, imageRootPath).ConfigureAwait(false);
lastException = null;
break;
}
catch (Exception ex) when (attempt < 3)
{
lastException = ex;
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
await Task.Delay(750, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
lastException = ex;
}
}
if (lastException is not null)
{
throw lastException;
}
}
}
private static async Task SendSingleCutAsync(
ITornado3Adapter adapter,
BroadcastStationProfile station,
FormatTemplateDefinition template,
FormatCutDefinition cut,
ElectionDataSnapshot snapshot,
string imageRootPath)
{
await adapter.EnsureConnectedAsync(CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "connect");
try
{
await adapter.ApplyCutAsync(template.RecommendedChannel, template, cut, snapshot, station, 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);
await adapter.OutAsync(template.RecommendedChannel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "take");
}
finally
{
await TryOutAsync(adapter, template.RecommendedChannel).ConfigureAwait(false);
if (adapter.IsLiveCg)
{
await Task.Delay(250, CancellationToken.None).ConfigureAwait(false);
}
}
}
private static async Task TryOutAsync(ITornado3Adapter adapter, BroadcastChannel channel)
{
try
{
await adapter.OutAsync(channel, CancellationToken.None).ConfigureAwait(false);
ThrowIfAdapterErrored(adapter, "out");
}
catch
{
if (!adapter.IsLiveCg)
{
throw;
}
}
}
private static void ThrowIfAdapterErrored(ITornado3Adapter adapter, string action)
{
if (adapter.State == TornadoConnectionState.Error)
{
throw new InvalidOperationException($"Karisma live send failed during {action}.");
}
}
@@ -290,6 +480,30 @@ internal static class CurrentApiCutDiagnostics
{
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))
{
@@ -355,6 +569,12 @@ internal static class CurrentApiCutDiagnostics
return true;
}
private static bool UsesStoredPreElectionHistory(FormatTemplateDefinition template)
{
return ScheduleTemplatePolicy.IsHistoricalTurnoutFormat(template.Name) ||
ScheduleTemplatePolicy.IsHistoricalWinnerFormat(template.Name);
}
private static string JoinWarning(string current, string next)
{
if (string.IsNullOrWhiteSpace(current))
@@ -443,15 +663,27 @@ internal static class CurrentApiCutDiagnostics
return "교육감";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal) ||
resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
if (resolvedFormatName.Contains("기초의원", StringComparison.Ordinal))
{
return "기초의원";
}
if (resolvedFormatName.Contains("기초단체장", StringComparison.Ordinal))
{
return "기초단체장";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal) ||
resolvedFormatName.Contains("광역의원", StringComparison.Ordinal) ||
resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
if (resolvedFormatName.Contains("광역의원", StringComparison.Ordinal))
{
return "광역의원";
}
if (resolvedFormatName.Contains("보궐선거", StringComparison.Ordinal))
{
return "국회의원";
}
if (resolvedFormatName.Contains("광역단체장", StringComparison.Ordinal))
{
return "광역단체장";
}
@@ -578,8 +810,12 @@ internal static class CurrentApiCutDiagnostics
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 int SendLimit { get; init; } = 24;
public string ImageRootPath { get; init; } = TornadoPathResolver.GetDefaultT3CutPath();
@@ -601,9 +837,10 @@ internal static class CurrentApiCutDiagnostics
var includeVideoWall = false;
int? templateLimit = null;
var filter = string.Empty;
var excludeFilter = string.Empty;
var simulateSend = true;
var liveSend = false;
var sendLimit = 24;
var imageRootPath = TornadoPathResolver.GetDefaultT3CutPath();
var outputPath = Path.Combine(
"artifacts",
"current-api-cut-diagnostics",
@@ -644,8 +881,16 @@ internal static class CurrentApiCutDiagnostics
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 "--send-limit":
if (int.TryParse(NextValue(), out var parsedSendLimit))
@@ -654,7 +899,7 @@ internal static class CurrentApiCutDiagnostics
}
break;
case "--image-root":
imageRootPath = TornadoPathResolver.NormalizeConfiguredPath(NextValue());
_ = NextValue();
break;
case "--output":
outputPath = NextValue();
@@ -675,9 +920,11 @@ internal static class CurrentApiCutDiagnostics
IncludeVideoWall = includeVideoWall,
TemplateLimit = templateLimit,
Filter = filter,
ExcludeFilter = excludeFilter,
SimulateSend = simulateSend,
LiveSend = liveSend,
SendLimit = sendLimit,
ImageRootPath = imageRootPath,
ImageRootPath = TornadoPathResolver.GetDefaultT3CutPath(),
OutputPath = outputPath,
DefaultElectionType = defaultElectionType
};
@@ -751,5 +998,25 @@ internal static class CurrentApiCutDiagnostics
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
};
}
}
}