Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo
- Full IPC/URL compatibility is app-layer routed through `ProtocolCommandRouter`: structured current-user named-pipe IPC is additive to legacy `ShowMainWindow`/`Restart`/`Url:` delivery, and all external mutations must use `ISecurityService`. `data/*` queries must use non-mutating profile snapshots, never active-profile loading APIs.
- `HistoryItem.DrawRoundId` identifies every record committed by one logical draw. Populate it for new history writes; IPC history projections group by it and must never expose internal `RecordId`.
- History record groups (历史记录组) are an optional second history dimension beside `HistoryItem.CourseName`, never a replacement: on the record `HistoryGroup` (empty marker = the default group) is set when the record is written and both filters apply together, so a gated draw reads and writes the intersection. The scope parameter is deliberately **tri-state** (`string? historyGroup`): `null` means do not filter on the group axis at all (feature off or selector hidden = the pre-#294 behaviour, every record counts), `""` means the default group only (records whose marker is empty, which is what the panel's 默认记录组 option selects), and a name means that one group. Collapsing `null` and `""` re-introduces the original unfairness in the other direction, because the option labelled 默认记录组 would then count records that were explicitly sorted into another group. `DrawEngine`'s history caches, `IProfileService.Record*History`, `IDrawCommitService` commit records, and `VerificationDrawCoordinator` carry the value as a trailing optional parameter — nothing in the weight calculator changes, because filtering the cache already yields the group-local counts. Deliberate limits: temporary draw records stay one bucket per student list (splitting them per group would let switching groups reset the repeat limit), the group name is the identity (renaming rewrites markers, and `HistoryGroupName` rejects blank/over-long/control/reserved names such as the `__break__` course sentinel), and the group *selection* is transient UI state — it is never persisted, and hiding the selector always means "no group scope" (`null`, i.e. every record counts again), so a stale selection can never silently keep filtering. The switch and the group create/rename/delete management live on the history-management settings page (`SecRandom/Views/SettingsPages/History/HistoryManagementSettingsPage.axaml(.cs)`), not on a draw-settings page, and the main panels expose only the selector. Moving records between groups goes through `IProfileCatalogManager.Move{Student,Prize}HistoryGroup` (marker-only, no new record, no `DrawRoundId`) and never through `IDrawCommitService`. Enabling the feature is not a protected operation, the switch is readable-but-not-writable through the control plane, and downgrading to a build without it silently folds grouped records back into the global history.
- Draw commits must go through `IDrawCommitService` (`DrawCommitCoordinator`): one logical draw gets exactly one `DrawRoundId` (a caller may supply one), temporary records commit before persistent history, a mid-commit failure rolls back through snapshot compensation, and commits serialize behind the coordinator gate. Never reintroduce bare two-step writes of `IProfileService.Record*History` plus temporary-record calls; the optional `drawRoundId` parameter (and `drawMethod` on `RecordPrizeHistory`) exists for coordinator use.
- Draw commits must go through `IDrawCommitService` (`DrawCommitCoordinator`): one logical draw gets exactly one `DrawRoundId` (a caller may supply one), temporary records commit before persistent history, a mid-commit failure rolls back through snapshot compensation, and commits serialize behind the coordinator gate. Never reintroduce bare two-step writes of `IProfileService.Record*History` plus temporary-record calls; the optional `drawRoundId` parameter (and `drawMethod` on `RecordPrizeHistory`) exists for coordinator use. Preview frames write no temporary record and no history: the coordinator is the only writer of the fairness surface, and the weight/filter/verification layers read persistent history only, so an animation that flickers through a hundred names can neither record a hit nor shift a weight. A preview shuffles with `Random.Shared` while the draw samples through the injected `IRandomSource` (`CryptoRandomSource`), so animation frames cannot consume or bias the draw's random stream. `SecRandom.Core.Tests/PreviewAnimationFairnessTests.cs` pins all three claims, including a reflection guard that the presentation contracts expose no commit or temporary-record surface.
- File persistence saves are atomic replacements: `FileConfigService.SaveConfig` and `DrawTemporaryRecordService.SaveState` write a temporary file and atomically replace the target; new file-persistence code must follow the same pattern instead of overwriting files in place.
- Draw proofs use the SecRandom v3 algorithm ID and an `algorithmEngineVersion`. Engine v3.1 commits the sampling mode into the request: student draws use history-balanced weighted sampling, count-based lottery inventory uses equal-probability partial permutation when no internal rule applies, and pan or internal-rule lottery draws use weighted sampling without replacement. Keep the verifier's replay logic byte-compatible with each supported proof engine version. Engine v3.4.0 does not change the replay mathematics at all — it only marks that the `fairness` block may carry `historyScope` — but the service side must be deployed **before** a client build sends that generation, exactly like a new audit field: a 3.3-generation verifier replays the same proof and reads the narrowed per-candidate `historyCount` values as global history. `fair.sectl.cn` accepts 3.4/3.3/3.2 as the current algorithm family, 3.1 as the previous family, and 3.0 as the oldest, and keeps replaying every previously issued generation.
- Ordinary draws immediately save an `OfflineReproducible` proof, then submit that newly generated proof to `fair.sectl.cn` for background replay/signing. A submission must use the proof object captured at draw time and never a re-read of the proof file: `DrawProofAttestationService` keeps the pending item in memory, persists only its path, proof ID, and canonical proof hash to `data/proofs/attestation-queue.json`, retries failures with bounded exponential backoff, resumes queued items after a restart, and refuses plus reports an item whose file no longer matches the recorded hash (it then keeps the receipt in the queue instead of overwriting the changed file). Startup must never scan or re-attest historical proof files that were never queued. A current server receipt signs a canonical hash of all proof fields except the receipt itself, plus the proof ID, input hash, payload hash, anonymous audit-payload hash, and mode; public sharing requires that receipt. This detects changes after the signed submission, but it does not attest to an unmodified local executable, an authentic real-world candidate pool, or absence of pre-draw result selection. Do not call it a pre-draw server witness. Local proof retention applies both the configured age and total storage limits; after every new save, delete oldest `.srproof.json` files until the configured storage cap is met.
Expand Down
278 changes: 278 additions & 0 deletions SecRandom.Core.Tests/PreviewAnimationFairnessTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using SecRandom.Core.Abstraction.Services;
using SecRandom.Core.Abstraction.Services.Presentation;
using SecRandom.Core.Services;
using SecRandom.Core.Services.Config;
using SecRandom.Core.Services.Draw;
using SecRandom.Shared;
using SecRandom.Shared.Models.Profile;

namespace SecRandom.Core.Tests;

/// <summary>
/// 预览(滚动)动画的公平性防线。
/// <para>
/// 预览帧在 QuickDrawPageViewModel.ShowPreviewAsync 里只做
/// <c>candidates.OrderBy(_ =&gt; Random.Shared.Next()).Take(count)</c> 这类内存洗牌,渲染只经
/// DataTemplate / RenderTransform;写临时记录的入口只有 DrawCommitCoordinator
/// (RecordStudents / RecordPrizes),而它只在真实一轮抽取结束后被调用一次。
/// 因此反复预览既不抬高临时记录计数,也不写历史,后续权重与「是否已抽到」判定保持不变。
/// </para>
/// <para>
/// 这些测试用真实 DI 运行时(AddCoreRuntimeServices)+ 临时 data root,避免 fake 把结论证成同义反复;
/// 同时保留一个「提交确实会改变同一测量面」的对照,证明断言灵敏。
/// </para>
/// </summary>
public sealed class PreviewAnimationFairnessTests : IDisposable
{
private const int PreviewFrameCount = 40;
private const int SampledPerFrame = 2;

private readonly string _dataRoot = Path.Combine(
Path.GetTempPath(),
"SecRandom",
"preview-animation-fairness",
Guid.NewGuid().ToString("N"));

public PreviewAnimationFairnessTests()
{
ResetDataRoot();
ConfigureDataRoot(_dataRoot);
}

[Fact]
public void PreviewFrames_OnlyReshuffleTheFrozenPool_AndWriteNoFairnessState()
{
using var provider = CreateProvider();
var profile = provider.GetRequiredService<IProfileService>();
var students = SeedStudents(profile, 6);
var listName = profile.StudentListConfig!.Name;
var temporary = provider.GetRequiredService<IDrawTemporaryRecordService>();

var frames = SimulatePreviewFrames(students, SampledPerFrame, listName);

Assert.Equal(PreviewFrameCount, frames.Count);
Assert.All(frames, frame => Assert.Equal(SampledPerFrame, frame.Count));
// 每一帧都只能来自抽取开始前冻结的候选池,永远不会包含池外记录。
Assert.All(frames, frame => Assert.All(frame, student => Assert.Contains(student, students)));

// 临时记录 = 公平计数(重复限制 / 平均间隔 / 权重都经它或历史读取),预览后必须仍是空的。
Assert.Empty(temporary.GetStudentCounts(listName, string.Empty, string.Empty));
// 持久历史同样一条都不能有。
Assert.Empty(profile.CurrentStudentHistory!.Students);
Assert.Equal(0, profile.CurrentStudentHistory.TotalRounds);
Assert.Equal(0, profile.CurrentStudentHistory.TotalStats);
}

[Fact]
public void RepeatedPreviewFrames_LeaveFairnessWeightsBitIdentical()
{
using var provider = CreateProvider();
var profile = provider.GetRequiredService<IProfileService>();
var students = SeedStudents(profile, 5);
var listName = profile.StudentListConfig!.Name;
var engine = CreateEngine(provider);

var before = engine.CalculateStudentWeight(students);
SimulatePreviewFrames(students, SampledPerFrame, listName);
var after = engine.CalculateStudentWeight(students);

Assert.Equal(before.Count, after.Count);
for (var index = 0; index < before.Count; index++)
{
Assert.Same(before[index].Candidate, after[index].Candidate);
// 精确相等:权重是历史上计数的纯函数,预览没有可写入的路径。
Assert.Equal(before[index].Weight, after[index].Weight);
}

Assert.All(after, weighted => Assert.True(weighted.Weight > 0));
}

[Fact]
public void OneRealCommit_DoesMoveTheSameWeightSurface_SoTheGuardIsSensitive()
{
using var provider = CreateProvider();
var profile = provider.GetRequiredService<IProfileService>();
var students = SeedStudents(profile, 5);
var listName = profile.StudentListConfig!.Name;
var engine = CreateEngine(provider);
var temporary = provider.GetRequiredService<IDrawTemporaryRecordService>();
var winner = students[0];

var before = engine.CalculateStudentWeight(students);
var roundId = provider.GetRequiredService<IDrawCommitService>()
.CommitStudentDraw(new StudentDrawCommit([winner], DateTime.Now, 1, listName));
var after = engine.CalculateStudentWeight(students);

Assert.False(string.IsNullOrWhiteSpace(roundId));
var beforeWinner = before.Single(weighted => ReferenceEquals(weighted.Candidate, winner)).Weight;
var afterWinner = after.Single(weighted => ReferenceEquals(weighted.Candidate, winner)).Weight;
Assert.True(afterWinner < beforeWinner,
$"一次真实提交必须降低中签者权重(before={beforeWinner}, after={afterWinner}),否则上一条断言没有灵敏度。");

Assert.Equal(1, temporary.GetStudentCounts(listName, string.Empty, string.Empty)[winner.RecordId.ToString("D")]);
Assert.Equal(1, profile.CurrentStudentHistory!.TotalRounds);
}

[Fact]
public void PreviewPresentationRequests_CarryNoRoundIdentity_AndThePhaseEnumHasNoCommitPhase()
{
// 相位只有「滚动中」与「已提交结果」两种:不存在“预览已提交”这种半状态。
Assert.Equal([DrawPresentationPhase.Preview, DrawPresentationPhase.Reveal], Enum.GetValues<DrawPresentationPhase>());

var student = new Student { Name = "Ada", RecordId = Guid.NewGuid() };
var preview = new DrawPresentationRequest
{
Channel = DrawPresentationChannel.QuickDraw,
Phase = DrawPresentationPhase.Preview,
Students = [student],
ListName = "class-a",
RequestedCount = 1
};

Assert.True(preview.IsPreview);
Assert.Equal(1, preview.ItemCount);
// 预览请求天然没有轮次身份(ResultPresentationBridge.BuildRequest 从不填这两个字段),
// 呈现器即使想“按轮次反查历史”也无从下手。
Assert.Equal(Guid.Empty, preview.ProofId);
Assert.Equal(string.Empty, preview.DrawRoundId);

var revealed = preview with
{
Phase = DrawPresentationPhase.Reveal,
ProofId = Guid.NewGuid(),
DrawRoundId = "round-1"
};
Assert.False(revealed.IsPreview);
Assert.True(preview.IsPreview); // with 表达式产出新快照,不改原件

// init-only:呈现器拿到的快照无法在构造后被逐属性改写(init 访问器带 IsExternalInit 修饰符,不算可变 setter)。
Assert.True(typeof(DrawPresentationRequest).IsSealed);
var mutableSetters = typeof(DrawPresentationRequest)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(property => property.SetMethod is { IsPublic: true } &&
!property.SetMethod.ReturnParameter.GetRequiredCustomModifiers()
.Contains(typeof(IsExternalInit)))
.Select(property => property.Name)
.ToArray();
Assert.Empty(mutableSetters);
}

[Fact]
public void PresentationContracts_ExposeNoCommitOrTemporaryRecordSurface()
{
var presenterTypes = typeof(IDrawResultPresenter)
.GetMethods()
.SelectMany(method => method.GetParameters()
.Select(parameter => parameter.ParameterType)
.Append(method.ReturnType))
.ToArray();

Assert.DoesNotContain(typeof(IDrawCommitService), presenterTypes);
Assert.DoesNotContain(typeof(IDrawTemporaryRecordService), presenterTypes);

var requestTypes = typeof(DrawPresentationRequest)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(property => property.PropertyType)
.ToArray();

Assert.DoesNotContain(typeof(IDrawCommitService), requestTypes);
Assert.DoesNotContain(typeof(IDrawTemporaryRecordService), requestTypes);
}

public void Dispose()
{
ResetDataRoot();
if (Directory.Exists(_dataRoot))
Directory.Delete(_dataRoot, recursive: true);
}

/// <summary>
/// 复刻 QuickDrawPageViewModel.ShowPreviewAsync 的帧循环(洗牌冻结池 + 生成 Preview 请求),
/// 但不触碰任何服务:这正是产品代码里预览分支的全部行为。
/// </summary>
private static List<IReadOnlyList<Student>> SimulatePreviewFrames(
IReadOnlyList<Student> frozenPool,
int count,
string listName,
int frames = PreviewFrameCount)
{
var rendered = new List<IReadOnlyList<Student>>(frames);
var previewAnimationRevision = 0;

for (var frame = 0; frame < frames; frame++)
{
var frameStudents = frozenPool.OrderBy(_ => Random.Shared.Next()).Take(count).ToList();
rendered.Add(frameStudents);

var request = new DrawPresentationRequest
{
Channel = DrawPresentationChannel.QuickDraw,
Phase = DrawPresentationPhase.Preview,
Students = frameStudents,
ListName = listName,
RequestedCount = count
};
Assert.True(request.IsPreview);

previewAnimationRevision++;
}

Assert.Equal(frames, previewAnimationRevision);
return rendered;
}

private static List<Student> SeedStudents(IProfileService profile, int count)
{
var students = Enumerable.Range(1, count)
.Select(index => new Student
{
Name = $"Preview-{index:00}",
Id = index.ToString("00"),
RecordId = Guid.NewGuid()
})
.ToList();

foreach (var student in students)
profile.CurrentStudentList!.Students.Add(student);

profile.SaveProfile();
return students;
}

private static DrawEngine CreateEngine(ServiceProvider provider)
{
return new DrawEngine(
provider.GetRequiredService<MainConfigHandler>(),
provider.GetRequiredService<IProfileService>(),
NullLogger<DrawEngine>.Instance);
}

private static ServiceProvider CreateProvider()
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
services.AddCoreRuntimeServices();
return services.BuildServiceProvider();
}

private static void ConfigureDataRoot(string dataRoot)
{
GetUtilsMethod("ConfigureDataRoot").Invoke(null, [dataRoot]);
}

private static void ResetDataRoot()
{
GetUtilsMethod("ResetDataRootForTests").Invoke(null, null);
}

private static MethodInfo GetUtilsMethod(string name)
{
return typeof(Utils).GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Utils.{name} was not found.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public partial class FloatingWindowSettingsConfig : ObservableObject
[ObservableProperty] private int _dockedWindowSize = 32;
[ObservableProperty] private bool _draggable = true;
[ObservableProperty] private int _floatingWindowSize = 56;
// 悬浮窗自定义文字:默认关闭;空字符串等于不显示,旧配置缺字段时按默认值加载
[ObservableProperty] private bool _enableFloatingWindowText;
[ObservableProperty] private string _floatingWindowText = string.Empty;
// 自定义文字字号(像素):0 表示跟随悬浮窗尺寸自动缩放,与按钮字号保持同一比例
[ObservableProperty] private int _floatingWindowTextFontSize;
[ObservableProperty] private int _longPressDuration = 500;
[ObservableProperty] private bool _doNotStealFocus = true;
[ObservableProperty] private bool _hideOnForeground = false;
Expand Down
Loading
Loading