Skip to content

Commit 820f8bf

Browse files
committed
feat(draw/music): 添加附加音乐设置与公平抽取保护
新增每条记录的附加音乐模型 DrawMusicAttachedSettings 及解析器,并在 GlobalConstants 注册设置 ID;增强 MusicLibraryService 行为(删除时拒绝外部兼容路径并清除被引用的托管曲目,支持通过配置刷新并保存 profile 引用)。 重构并增强 DrawEngine:引入 ApplyAverageGapProtection、FilterPreparedStudents 等,改进公平抽取的平均差保护逻辑、候选池扩展与权重计算(支持课程范围统计与历史缓存过滤),并在验证/抽取路径上用特定异常映射到可理解的 DrawStatus;同时在 verification 审计负载中加入 fairness 与候选计数元数据以提升可审计性。 改进 ProfileRecordIdentity:支持紧凑(N 格式)记录 ID 的迁移与合并,保留历史并删除 legacy 键以避免别名冲突。 增加/更新大量单元测试覆盖新行为(音乐库、验证导出、抽取算法/配置、记录迁移等),并同步更新若干 AGENTS.md 文档以说明托管音乐、课程联动与附加设置约定。
1 parent 29b4122 commit 820f8bf

30 files changed

Lines changed: 1208 additions & 124 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo
109109
- Built-in main navigation entries may use `PageLocation.Bottom` for bottom-pinned sidebar items; roll-call (`main.rollCall`) and lottery (`main.lottery`) are bottom-pinned and full-width/title-hidden. Quick draw is not a main navigation page and opens from the floating window.
110110
- Page IDs: `main.xxx`, `settings.xxx`, `settings.group.xxx`.
111111
- Picking animation style is unified: settings expose it as `AnimationStyle` / “动画样式”, and RollCall, QuickDraw, and Lottery use the same style for both rolling preview/process animation and final result reveal. Do not split process/result animation style settings.
112+
- Managed draw music is app-layer only: `settings.personalized.music` imports/deletes/previews MP3/WAV/FLAC files in `data/audio/music`, while the four draw-settings pages select process/result tracks through managed IDs, no-music, or random-play options. Student/prize attached settings may override only animation and result track IDs; the first drawn record supplies both overrides for a multi-record result. Deleting a managed track clears global and per-record references. `DrawAudioService` keeps SoundFlow private, loops process music only when `MoreSettings.BackgroundMusicLoop` is enabled, stops it on cancellation, and plays result music once.
113+
- Course linkage uses fixed v2 data-source values: `0=Off`, `1=CSES`, `2=ClassIsland`. CSES schedules live at `data/CSES/cses_schedule.yml`; ClassIsland is accessed only through the app-layer official IPC adapter. Only a confirmed course break restricts local draw/reset or hides the floating window. Missing/invalid CSES data, ClassIsland connection loss, or an unknown state must permit normal operation.
114+
- `LinkageSettings.VerificationRequired` governs the course-time bypass prompt and is distinct from `SecuritySettings.ProtectLinkage`, which continues to protect only external SecRandom URL/IPC mutations. Student course history uses `HistoryItem.CourseName` with `RecordId` identity; empty legacy course values remain global history.
112115
- Plugin pages are runtime-registered through `AddPluginMainPage` / `AddPluginSettingsPage`; their IDs must start with `plugin.<plugin-id>.` and must not occupy built-in `main.*` or `settings.*` IDs.
113116
- Plugin contracts live under `SecRandom.Core/Plugins`; plugin runtime/loading state lives under `SecRandom/Services/Plugins` and is registered from `BuildHost()`.
114117
- Plugin logs must use the original logging pipeline. Plugin categories use `SecRandom.Plugin[<plugin-id>].*`; plugin detail views may only filter their own category prefix.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using SecRandom.Services.Verification;
2+
using SecRandom.Shared.Models.Verification;
3+
4+
namespace SecRandom.Core.Tests;
5+
6+
public sealed class DrawProofExportServiceTests
7+
{
8+
[Fact]
9+
public void CreateFileName_UsesChinaStandardTimeListAndFilters()
10+
{
11+
DrawProof proof = new()
12+
{
13+
ProofId = Guid.Parse("12345678-1234-1234-1234-123456789abc"),
14+
CreatedAtUtc = new DateTimeOffset(2026, 7, 14, 0, 30, 12, 345, TimeSpan.Zero)
15+
};
16+
17+
var fileName = DrawProofExportService.CreateFileName(
18+
proof,
19+
DrawProofExportContext.ForStudents("高一:一班", "A/组", "女"));
20+
21+
Assert.Equal("20260714_083012_345_高一_一班_组别=A_组、性别=女_12345678.srproof.json", fileName);
22+
}
23+
24+
[Fact]
25+
public void CreateFileName_UsesAllScopeWhenNoStudentFilterIsSelected()
26+
{
27+
DrawProof proof = new()
28+
{
29+
ProofId = Guid.Parse("abcdef12-1234-1234-1234-123456789abc"),
30+
CreatedAtUtc = new DateTimeOffset(2026, 7, 14, 0, 0, 0, TimeSpan.Zero)
31+
};
32+
33+
var fileName = DrawProofExportService.CreateFileName(proof, DrawProofExportContext.ForStudents("默认名单"));
34+
35+
Assert.Contains("默认名单_范围=全部_abcdef12", fileName);
36+
}
37+
38+
[Fact]
39+
public void CreateFileName_KeepsTheFileNameWithinWindowsLimits()
40+
{
41+
DrawProof proof = new();
42+
var context = new DrawProofExportContext(
43+
new string('名', 100),
44+
[new string('组', 100), new string('性', 100), new string('课', 100)]);
45+
46+
var fileName = DrawProofExportService.CreateFileName(proof, context);
47+
48+
Assert.True(fileName.Length <= 240);
49+
}
50+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
using System.Collections.ObjectModel;
2+
using System.Text.Json;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Hosting;
5+
using Microsoft.Extensions.Logging;
6+
using SecRandom.Core.Abstraction;
7+
using SecRandom.Core.Abstraction.Services;
8+
using SecRandom.Core.Enums;
9+
using SecRandom.Core.Enums.Configs;
10+
using SecRandom.Core.Models;
11+
using SecRandom.Core.Models.SubConfigs.Picking;
12+
using SecRandom.Core.Services.Config;
13+
using SecRandom.Core.Services.Draw;
14+
using SecRandom.Shared.Abstraction;
15+
using SecRandom.Shared.Models.Profile;
16+
17+
namespace SecRandom.Core.Tests;
18+
19+
public sealed class FairDrawAlgorithmTests
20+
{
21+
[Fact]
22+
public void FairDraw_ExcludesAboveAverageCandidatesWhenLowerCandidatesCanSatisfyDraw()
23+
{
24+
var low = new Student { Name = "Low", RecordId = Guid.NewGuid() };
25+
var average = new Student { Name = "Average", RecordId = Guid.NewGuid() };
26+
var high = new Student { Name = "High", RecordId = Guid.NewGuid() };
27+
var history = new StudentHistory
28+
{
29+
Students =
30+
{
31+
[low.RecordId.ToString("D")] = new History { TotalCount = 0 },
32+
[average.RecordId.ToString("D")] = new History { TotalCount = 2 },
33+
[high.RecordId.ToString("D")] = new History { TotalCount = 4 }
34+
}
35+
};
36+
var config = CreateConfig(new FairDrawSettingsConfig
37+
{
38+
FairDraw = true,
39+
FairDrawGroup = false,
40+
FairDrawGender = false,
41+
FairDrawTime = false,
42+
ColdStartEnabled = false,
43+
EnableAvgGapProtection = true,
44+
GapThreshold = 10,
45+
MinWeight = 0,
46+
MaxWeight = 10
47+
});
48+
var students = new StudentList { Students = [low, average, high] };
49+
50+
using var host = CreateHost(config, new TestProfileService(history, students));
51+
IAppHost.Host = host;
52+
var engine = new DrawEngine();
53+
54+
var localResult = engine.DrawPreparedStudents(1, [low, average, high], DrawSettingsType.RollCall);
55+
var verificationInput = engine.CreateStudentVerificationInput(1, [low, average, high], DrawSettingsType.RollCall);
56+
57+
Assert.True(localResult.IsSuccess);
58+
Assert.DoesNotContain(high, localResult.Result);
59+
Assert.DoesNotContain(verificationInput.Candidates, candidate => candidate.RecordId == high.RecordId);
60+
using var audit = JsonDocument.Parse(verificationInput.AuditPayload);
61+
Assert.True(audit.RootElement.GetProperty("fairness").GetProperty("averageGapProtectionApplied").GetBoolean());
62+
Assert.Equal(3, audit.RootElement.GetProperty("fairness").GetProperty("candidateCountBeforeAverageGapProtection").GetInt32());
63+
Assert.Equal(2, audit.RootElement.GetProperty("fairness").GetProperty("candidateCountAfterAverageGapProtection").GetInt32());
64+
}
65+
66+
[Fact]
67+
public void CreateStudentVerificationInput_UsesCourseScopedBalanceWeights()
68+
{
69+
var groupA = new Student { Name = "A", Group = "A", RecordId = Guid.NewGuid() };
70+
var groupB = new Student { Name = "B", Group = "B", RecordId = Guid.NewGuid() };
71+
var history = new StudentHistory();
72+
history.GroupStats["A"] = 100;
73+
history.Students["legacy"] = new History
74+
{
75+
Histories = new ObservableCollection<HistoryItem>(
76+
Enumerable.Range(0, 10)
77+
.Select(_ => new HistoryItem { CourseName = "数学", RecordGroup = "B" }))
78+
};
79+
var config = CreateConfig(new FairDrawSettingsConfig
80+
{
81+
FairDraw = true,
82+
FairDrawGroup = true,
83+
FairDrawGender = false,
84+
FairDrawTime = false,
85+
ColdStartEnabled = false,
86+
EnableAvgGapProtection = false,
87+
FrequencyWeight = 0,
88+
BaseWeight = 0,
89+
GroupWeight = 1,
90+
MinWeight = 0,
91+
MaxWeight = 10
92+
});
93+
var students = new StudentList { Students = [groupA, groupB] };
94+
95+
using var host = CreateHost(config, new TestProfileService(history, students));
96+
IAppHost.Host = host;
97+
98+
var input = new DrawEngine().CreateStudentVerificationInput(1, [groupA, groupB], DrawSettingsType.RollCall, "数学");
99+
100+
Assert.True(input.Candidates.Single(candidate => candidate.RecordId == groupA.RecordId).WeightMicros
101+
> input.Candidates.Single(candidate => candidate.RecordId == groupB.RecordId).WeightMicros);
102+
}
103+
104+
private static MainConfigModel CreateConfig(FairDrawSettingsConfig fairSettings)
105+
{
106+
return new MainConfigModel
107+
{
108+
FairDrawSettings = fairSettings,
109+
RollCallSettings = new RollCallSettingsConfig(),
110+
LotterySettings = new LotterySettingsConfig(),
111+
DefaultDrawSettings = new DefaultDrawSettingsConfig()
112+
};
113+
}
114+
115+
private static IHost CreateHost(MainConfigModel config, IProfileService profile)
116+
{
117+
return Host.CreateDefaultBuilder()
118+
.ConfigureServices(services =>
119+
{
120+
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
121+
services.AddSingleton<IProfileService>(profile);
122+
services.AddSingleton<ConfigServiceBase>(new TestConfigService(config));
123+
services.AddSingleton<MainConfigHandler>();
124+
})
125+
.Build();
126+
}
127+
128+
private sealed class TestConfigService(MainConfigModel config) : ConfigServiceBase
129+
{
130+
public override bool IsConfigExists<T>(T fallback) => true;
131+
public override T LoadConfig<T>(T fallback) => config is T typed ? typed : fallback;
132+
public override void SaveConfig<T>(T config) { }
133+
public override void DeleteConfig<T>(T config) { }
134+
}
135+
136+
private sealed class TestProfileService(StudentHistory history, StudentList students) : IProfileService
137+
{
138+
public StudentList? CurrentStudentList { get; } = students;
139+
public StudentHistory? CurrentStudentHistory { get; } = history;
140+
public PrizeList? CurrentPrizeList { get; } = new();
141+
public PrizeHistory? CurrentPrizeHistory { get; } = new();
142+
public StudentListConfig? StudentListConfig => null;
143+
public StudentHistoryConfig? StudentHistoryConfig => null;
144+
public PrizeListConfig? PrizeListConfig => null;
145+
public PrizeHistoryConfig? PrizeHistoryConfig => null;
146+
public void LoadStudentProfile(string name, bool saveCurrent = true) { }
147+
public void LoadPrizeProfile(string name, bool saveCurrent = true) { }
148+
public void RecordStudentHistory(IReadOnlyList<Student> students, DateTime now, int requestedCount, string drawGroup = "", string drawGender = "", int drawMethod = 0, IReadOnlyDictionary<Student, double>? weights = null, string courseName = "") { }
149+
public void RecordPrizeHistory(IReadOnlyList<Prize> prizes, DateTime now, int requestedCount) { }
150+
public void ClearCurrentStudentHistory() { }
151+
public void ClearCurrentPrizeHistory() { }
152+
public void SaveProfile() { }
153+
}
154+
}

SecRandom.Core.Tests/FairDrawSettingsConfigTests.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.Extensions.Logging;
55
using SecRandom.Core.Abstraction;
66
using SecRandom.Core.Abstraction.Services;
7+
using SecRandom.Core.Enums;
78
using SecRandom.Core.Enums.Configs;
89
using SecRandom.Core.Models;
910
using SecRandom.Core.Models.SubConfigs.Picking;
@@ -198,6 +199,45 @@ public void CalculateStudentWeight_CourseScopeUsesOnlyCourseGroupAndGenderHistor
198199
> weights.Single(item => item.Candidate == groupA).Weight);
199200
}
200201

202+
[Fact]
203+
public void CreateStudentVerificationInput_AppliesAverageGapProtection()
204+
{
205+
var first = new Student { Name = "A", RecordId = Guid.NewGuid() };
206+
var second = new Student { Name = "B", RecordId = Guid.NewGuid() };
207+
var overdrawn = new Student { Name = "C", RecordId = Guid.NewGuid() };
208+
var history = new StudentHistory
209+
{
210+
Students =
211+
{
212+
[first.RecordId.ToString("D")] = new History { TotalCount = 0 },
213+
[second.RecordId.ToString("D")] = new History { TotalCount = 0 },
214+
[overdrawn.RecordId.ToString("D")] = new History { TotalCount = 3 }
215+
}
216+
};
217+
var config = BuildConfig(new FairDrawSettingsConfig
218+
{
219+
FairDraw = true,
220+
EnableAvgGapProtection = true,
221+
GapThreshold = 1,
222+
FairDrawGroup = false,
223+
FairDrawGender = false,
224+
FairDrawTime = false,
225+
ColdStartEnabled = false
226+
});
227+
config.RollCallSettings.DrawType = DrawType.Fair;
228+
229+
using var host = BuildHost(config, new TestProfileService(history));
230+
IAppHost.Host = host;
231+
232+
var input = new DrawEngine().CreateStudentVerificationInput(
233+
1,
234+
[first, second, overdrawn],
235+
DrawSettingsType.RollCall);
236+
237+
Assert.Equal(2, input.Candidates.Count);
238+
Assert.DoesNotContain(input.Candidates, candidate => candidate.RecordId == overdrawn.RecordId);
239+
}
240+
201241
private static MainConfigModel BuildConfig(FairDrawSettingsConfig fairSettings)
202242
{
203243
return new MainConfigModel

SecRandom.Core.Tests/MusicLibraryServiceTests.cs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
using Microsoft.Extensions.Logging.Abstractions;
22
using SecRandom.Core.Abstraction;
3+
using SecRandom.Core.Abstraction.Services;
4+
using SecRandom.Core.Models.AttachedSettings;
35
using SecRandom.Core.Models;
46
using SecRandom.Core.Models.SubConfigs.Picking;
57
using SecRandom.Core.Services.Config;
8+
using SecRandom.Shared.Extensions;
69
using SecRandom.Services.Music;
10+
using SecRandom.Shared.Models.Profile;
711

812
namespace SecRandom.Core.Tests;
913

@@ -40,6 +44,18 @@ public void ResolvePath_RejectsUnsupportedAndTraversalSelections()
4044
Assert.Null(service.ResolvePath("track.ogg"));
4145
}
4246

47+
[Fact]
48+
public void Delete_RejectsExternalCompatibilityPaths()
49+
{
50+
Directory.CreateDirectory(_directory);
51+
var externalPath = Path.Combine(_directory, "external.wav");
52+
File.WriteAllBytes(externalPath, [1]);
53+
var service = CreateService(out _);
54+
55+
Assert.False(service.Delete(new MusicTrack(externalPath, "external", 1)));
56+
Assert.True(File.Exists(externalPath));
57+
}
58+
4359
[Fact]
4460
public void NewDrawSettings_HaveUsableMusicControlDefaults()
4561
{
@@ -76,19 +92,43 @@ public void Delete_ClearsEveryDefaultAndOverrideReference()
7692
Assert.Equal(MusicLibraryService.NoMusicTrackId, config.LotterySettings.ResultMusic);
7793
}
7894

95+
[Fact]
96+
public void Delete_ClearsActiveAttachedMusicReferences()
97+
{
98+
Directory.CreateDirectory(_directory);
99+
File.WriteAllBytes(Path.Combine(_directory, "track.wav"), [1]);
100+
var student = new Student();
101+
student.AttachedObjects[Guid.Parse(GlobalConstants.DrawMusicAttachedSettings)] = new DrawMusicAttachedSettings
102+
{
103+
IsAttachSettingsEnabled = true,
104+
AnimationMusic = "track.wav",
105+
ResultMusic = "track.wav"
106+
};
107+
var profileService = new TestProfileService(student);
108+
var service = CreateService(out _, profileService);
109+
service.Refresh();
110+
111+
Assert.True(service.Delete(Assert.Single(service.Tracks)));
112+
var settings = student.GetAttachedObject<DrawMusicAttachedSettings>(Guid.Parse(GlobalConstants.DrawMusicAttachedSettings));
113+
Assert.NotNull(settings);
114+
Assert.Equal(MusicLibraryService.NoMusicTrackId, settings.AnimationMusic);
115+
Assert.Equal(MusicLibraryService.NoMusicTrackId, settings.ResultMusic);
116+
Assert.Equal(1, profileService.SaveCount);
117+
}
118+
79119
public void Dispose()
80120
{
81121
if (Directory.Exists(_directory))
82122
Directory.Delete(_directory, recursive: true);
83123
}
84124

85-
private MusicLibraryService CreateService(out MainConfigModel config)
125+
private MusicLibraryService CreateService(out MainConfigModel config, IProfileService? profileService = null)
86126
{
87127
config = new MainConfigModel();
88128
var handler = new MainConfigHandler(
89129
NullLogger<MainConfigHandler>.Instance,
90130
new TestConfigService(config));
91-
return new MusicLibraryService(handler, NullLogger<MusicLibraryService>.Instance, _directory);
131+
return new MusicLibraryService(handler, NullLogger<MusicLibraryService>.Instance, _directory, profileService);
92132
}
93133

94134
private sealed class TestConfigService(MainConfigModel config) : ConfigServiceBase
@@ -98,4 +138,26 @@ private sealed class TestConfigService(MainConfigModel config) : ConfigServiceBa
98138
public override void SaveConfig<T>(T value) { }
99139
public override void DeleteConfig<T>(T value) { }
100140
}
141+
142+
private sealed class TestProfileService(Student student) : IProfileService
143+
{
144+
public int SaveCount { get; private set; }
145+
public StudentList? CurrentStudentList { get; } = new() { Students = [student] };
146+
public StudentHistory? CurrentStudentHistory => null;
147+
public PrizeList? CurrentPrizeList => null;
148+
public PrizeHistory? CurrentPrizeHistory => null;
149+
public StudentListConfig? StudentListConfig => null;
150+
public StudentHistoryConfig? StudentHistoryConfig => null;
151+
public PrizeListConfig? PrizeListConfig => null;
152+
public PrizeHistoryConfig? PrizeHistoryConfig => null;
153+
public void LoadStudentProfile(string name, bool saveCurrent = true) { }
154+
public void LoadPrizeProfile(string name, bool saveCurrent = true) { }
155+
public void RecordStudentHistory(IReadOnlyList<Student> students, DateTime now, int requestedCount,
156+
string drawGroup = "", string drawGender = "", int drawMethod = 0,
157+
IReadOnlyDictionary<Student, double>? weights = null, string courseName = "") { }
158+
public void RecordPrizeHistory(IReadOnlyList<Prize> prizes, DateTime now, int requestedCount) { }
159+
public void ClearCurrentStudentHistory() { }
160+
public void ClearCurrentPrizeHistory() { }
161+
public void SaveProfile() => SaveCount++;
162+
}
101163
}

0 commit comments

Comments
 (0)