投票发布功能

This commit is contained in:
biss committed 2026-08-29 21:42:31 +08:00
1 parent ae9e8e0c9d
commit 1e0652211b
24 files changed
+3633 -7

No files matched your search

@@ -5,6 +5,10 @@ using ElectionSystem.Api.Modules.Identity.Domain;
using ElectionSystem.Api.Modules.Organization;
using ElectionSystem.Api.Modules.Organization.Domain;
using ElectionSystem.Api.Modules.Voters.Domain;
using ElectionSystem.Api.Modules.Election.Domain;
using ElectionSystem.Api.Modules.OfflineCounting.Domain;
using ElectionSystem.Api.Modules.OfflineCounting;
using ElectionSystem.Api.Modules.Results;
using System.Text;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@@ -100,6 +104,64 @@ public sealed class PersistenceModelTests
Assert.False(CursorToken.TryDecode<TestCursor>("not-a-cursor", out _));
}
[Fact]
public void Offline_submissions_enforce_one_entry_per_clerk_and_do_not_store_voters()
{
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
using var context = new ElectionDbContext(options);
var entity = context.Model.FindEntityType(typeof(OfflineCountSubmission))!;
Assert.Contains(entity.GetIndexes(), x => x.IsUnique && x.Properties.Select(p => p.Name).SequenceEqual(new[] { nameof(OfflineCountSubmission.OfflineCountingSessionId), nameof(OfflineCountSubmission.EnteredByUserId) }));
Assert.DoesNotContain(typeof(OfflineCountSubmission).GetProperties(), x => x.Name.Contains("Voter", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(typeof(OfflineCountTally).GetProperties(), x => x.Name.Contains("Voter", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Result_service_counts_confirmed_versions_only_and_keeps_abstention_invalid_and_missing_separate()
{
var options = new DbContextOptionsBuilder<ElectionDbContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
await using var context = new ElectionDbContext(options);
var region = Region("11", "省", AdministrativeRegionLevel.Province);
var election = new ElectionActivity { Name = "测试投票", RegionId = region.Id, Status = ElectionActivityStatus.Counting, VotingStartsAtUtc = DateTime.UtcNow.AddHours(-2), VotingEndsAtUtc = DateTime.UtcNow.AddHours(-1), CountingEndsAtUtc = DateTime.UtcNow.AddHours(1), CreatedByUserId = Guid.NewGuid() };
var contest = new ElectionContest { ElectionActivityId = election.Id, Title = "主任", Type = ElectionContestType.CandidateElection, SortOrder = 1, Seats = 1, MaxSelections = 1 };
var a = new ElectionOption { ElectionContestId = contest.Id, Label = "甲", SortOrder = 1 };
var b = new ElectionOption { ElectionContestId = contest.Id, Label = "乙", SortOrder = 2 };
var confirmed = new OfflineCountingSession { ElectionActivityId = election.Id, CountingStationId = Guid.NewGuid(), Status = OfflineCountingSessionStatus.Confirmed, OpenedByUserId = Guid.NewGuid() };
var unconfirmed = new OfflineCountingSession { ElectionActivityId = election.Id, CountingStationId = Guid.NewGuid(), Status = OfflineCountingSessionStatus.NeedsReview, OpenedByUserId = Guid.NewGuid() };
var good = new OfflineCountSubmission { OfflineCountingSessionId = confirmed.Id, EnteredByUserId = Guid.NewGuid() };
var duplicateVerifiedEntry = new OfflineCountSubmission { OfflineCountingSessionId = confirmed.Id, EnteredByUserId = Guid.NewGuid(), SubmittedAtUtc = good.SubmittedAtUtc.AddSeconds(1) };
var pending = new OfflineCountSubmission { OfflineCountingSessionId = unconfirmed.Id, EnteredByUserId = Guid.NewGuid() };
context.AddRange(region, election, contest, a, b, confirmed, unconfirmed, good, duplicateVerifiedEntry, pending);
context.AddRange(
new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 18 },
new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, ElectionOptionId = b.Id, Votes = 12 },
new OfflineCountTally { OfflineCountSubmissionId = good.Id, ElectionContestId = contest.Id, Abstentions = 3, InvalidBallots = 2, MissingSelections = 1 },
new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 18 },
new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, ElectionOptionId = b.Id, Votes = 12 },
new OfflineCountTally { OfflineCountSubmissionId = duplicateVerifiedEntry.Id, ElectionContestId = contest.Id, Abstentions = 3, InvalidBallots = 2, MissingSelections = 1 },
new OfflineCountTally { OfflineCountSubmissionId = pending.Id, ElectionContestId = contest.Id, ElectionOptionId = a.Id, Votes = 99 });
await context.SaveChangesAsync();
var result = await new ElectionResultService(context).BuildAsync(election.Id, CancellationToken.None);
var summary = Assert.Single(result.Contests);
Assert.Equal(18, summary.Options.Single(x => x.OptionId == a.Id).Votes);
Assert.Equal(30, summary.ValidVotes);
Assert.Equal(3, summary.Abstentions);
Assert.Equal(2, summary.InvalidBallots);
Assert.Equal(1, summary.MissingSelections);
Assert.Equal(new[] { a.Id }, summary.WinnerOptionIds);
}
[Fact]
public void Missing_selections_are_calculated_from_counted_ballots_and_required_choices()
{
var contest = new ElectionContest { ElectionActivityId = Guid.NewGuid(), Title = "两席岗位", Type = ElectionContestType.CandidateElection, SortOrder = 1, Seats = 2, MinSelections = 2, MaxSelections = 2 };
var missing = OfflineCountingRules.CalculateMissingSelections(contest, countedBallots: 20, optionVotes: 33, abstentions: 2, invalidBallots: 1);
Assert.Equal(4, missing);
}
[Fact]
public async Task Four_level_filter_returns_selected_subtree_and_rejects_cross_branch_selection()
{