기초
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user