Pause the school on warning notices until the owner dismisses them.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 22:33:38 +03:00
co-authored by Cursor
parent e22d9bae33
commit f36af269bb
27 changed files with 993 additions and 51 deletions
@@ -121,6 +121,177 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.Null(replayed);
}
[Fact]
public async Task PausingNotice_SetsRunningFalse_AndIgnoresSetRunningTrue()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Пауза варнингом", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
await ReceiveClockWhereAsync(socket, clock => clock.Running);
var posted = await PostDevNoticeAsync(client, school.Id, "GenerationFailed");
var notice = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal(posted.Id, notice.Id);
Assert.Equal("GenerationFailed", notice.DefName);
Assert.True(notice.Pause);
Assert.Equal(NoticeSeverity.Error, notice.Severity);
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
Assert.False(later.Running);
Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs);
}
[Fact]
public async Task DismissLastPausing_AllowsSetRunningTrue_DoesNotAutoPlay()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Dismiss не Play", StartDate);
using var socket = await OpenSchoolAsync(school.Id, client);
await ReceiveClockWhereAsync(socket, clock => clock.Running);
var posted = await PostDevNoticeAsync(client, school.Id, "GenerationFailed");
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10));
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id)));
var stillPaused = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
Assert.False(stillPaused.Running);
Assert.Equal(paused.GameTimeUnixMs, stillPaused.GameTimeUnixMs);
await SendAsync(socket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
var resumed = await ReceiveClockWhereAsync(socket, clock => clock.Running);
Assert.True(resumed.Running);
}
[Fact]
public async Task SaveLoad_KeepsSticky_DropsInfo()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Sticky сейв", StartDate);
using (var socket = await OpenSchoolAsync(school.Id, client))
{
await ReceiveClockAsync(socket);
await PostDevNoticeAsync(client, school.Id, "DayStarted");
var info = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal("DayStarted", info.DefName);
Assert.False(info.Pause);
await PostDevNoticeAsync(client, school.Id, "GenerationFailed");
var sticky = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal("GenerationFailed", sticky.DefName);
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
}
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = await FindAsync(client, school.Id);
Assert.False(restored.Running);
using var reopened = await OpenSchoolAsync(school.Id, client);
var replayed = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(reopened, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal("GenerationFailed", replayed.DefName);
Assert.True(replayed.Pause);
Assert.False((await ReceiveClockAsync(reopened)).Running);
var extra = await TryReceiveNoticeAsync(reopened, TimeSpan.FromSeconds(2));
Assert.True(extra is not { DefName: "DayStarted" });
}
[Fact]
public async Task Guest_Dismiss_DoesNotClearPause_OwnerDoes()
{
using var ownerClient = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticePauseOwner");
await SchoolApiTests.WipeAllSavesAsync(ownerClient);
var school = await SchoolApiTests.CreateAsync(ownerClient, "Гость не закрывает", StartDate);
using var ownerSocket = await OpenSchoolAsync(school.Id, ownerClient);
await ReceiveClockWhereAsync(ownerSocket, clock => clock.Running);
var posted = await PostDevNoticeAsync(ownerClient, school.Id, "GenerationFailed");
await ReceiveUntilAsync(ownerSocket, MessageType.ServerNotice, TimeSpan.FromSeconds(10));
await ReceiveClockWhereAsync(ownerSocket, clock => !clock.Running);
using var guestHttp = await SchoolApiTests.CreateIsolatedClientAsync(fixture.App, "NoticePauseGuest");
using var forbidden = await guestHttp.PostAsync(
$"/api/schools/{school.Id}/notices/{posted.Id}/dismiss",
content: null,
TestContext.Current.CancellationToken);
Assert.Equal(System.Net.HttpStatusCode.Forbidden, forbidden.StatusCode);
Assert.Equal("not-owner", await SchoolApiTests.ProblemCodeAsync(forbidden));
var guestCookie = guestHttp.DefaultRequestHeaders.TryGetValues("Cookie", out var guestCookies)
? guestCookies.First()
: await SchoolApiTests.WebSocketCookieAsync(guestHttp);
using var guestSocket = await ConnectWithCookieAsync(guestCookie);
await ReceiveUntilAsync(guestSocket, MessageType.ServerWelcome);
await SendAsync(guestSocket, buffer =>
ProtocolCodec.WriteOpenSchool(buffer, new ClientOpenSchoolMessage(school.Id)));
var guestNotice = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(guestSocket, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
Assert.Equal(posted.Id, guestNotice.Id);
await SendAsync(guestSocket, buffer =>
ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id)));
await Task.Delay(TimeSpan.FromMilliseconds(400), TestContext.Current.CancellationToken);
Assert.False((await FindAsync(ownerClient, school.Id)).Running);
await SendAsync(ownerSocket, buffer =>
ProtocolCodec.WriteDismissNotice(buffer, new ClientDismissNoticeMessage(posted.Id)));
await SendAsync(ownerSocket, buffer =>
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
var resumed = await ReceiveClockWhereAsync(ownerSocket, clock => clock.Running);
Assert.True(resumed.Running);
using var delete = await ownerClient.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
delete.EnsureSuccessStatusCode();
}
[Fact]
public async Task OpenAfterReload_ReplaysSticky_ClockPaused()
{
using var client = await CreateOwnerHttpClientAsync();
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "F5 sticky", StartDate);
using (var socket = await OpenSchoolAsync(school.Id, client))
{
await ReceiveClockAsync(socket);
await PostDevNoticeAsync(client, school.Id, "GenerationFailed");
await ReceiveUntilAsync(socket, MessageType.ServerNotice, TimeSpan.FromSeconds(10));
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
}
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
using var reopened = await OpenSchoolAsync(school.Id, client);
var notice = ProtocolCodec.ReadNotice(
await ReceiveUntilAsync(reopened, MessageType.ServerNotice, TimeSpan.FromSeconds(10)));
var clock = await ReceiveClockWhereAsync(reopened, frame => !frame.Running);
Assert.Equal("GenerationFailed", notice.DefName);
Assert.True(notice.Pause);
Assert.False(clock.Running);
Assert.False((await FindAsync(client, school.Id)).Running);
}
[Fact]
public async Task OpeningASchool_SendsAMapSnapshot()
{
@@ -539,6 +710,18 @@ public class GameSocketTests(AppHostFixture fixture)
private static DateTime ToDate(ServerClockMessage clock) =>
DateTimeOffset.FromUnixTimeMilliseconds(clock.GameTimeUnixMs).UtcDateTime;
private static async Task<DevNoticeResponse> PostDevNoticeAsync(HttpClient client, int schoolId, string defName)
{
using var response = await client.PostAsJsonAsync(
$"/api/dev/schools/{schoolId}/notices",
new { defName, personId = 0u },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<DevNoticeResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body);
return body;
}
private async Task<SchoolApiTests.SchoolResponse> FindAsync(HttpClient client, int schoolId)
{
var state = await SchoolApiTests.GetSchoolsAsync(client);
@@ -784,4 +967,6 @@ public class GameSocketTests(AppHostFixture fixture)
private sealed record DirectorySnapshot(IReadOnlyList<DirectoryPersonSnapshot> People);
private sealed record DirectoryPersonSnapshot(string Id, string FullName);
private sealed record DevNoticeResponse(uint Id, string DefName, bool Pause);
}
@@ -26,7 +26,11 @@ public class EventDefTests
Assert.Equal(EventActions.None, catalog.Events["LessonStarted"].Action);
Assert.True(catalog.Events.ContainsKey("GenerationFailed"));
Assert.Equal(EventSeverities.Error, catalog.Events["GenerationFailed"].Severity);
Assert.True(catalog.Events["GenerationFailed"].Pause);
Assert.Equal(0, catalog.Events["GenerationFailed"].TtlMs);
Assert.Equal(EventTriggers.GenerationFailed, catalog.Events["GenerationFailed"].Trigger);
Assert.Equal(EventActions.None, catalog.Events["GenerationFailed"].Action);
Assert.Equal("Начало дня", catalog.Label("ru", catalog.Events["DayStarted"]));
Assert.Equal("The day has started", catalog.Label("en", catalog.Events["DayStarted"]));
}
@@ -0,0 +1,93 @@
using HSchool.Content;
using HSchool.Protocol;
using HSchool.Server.Game;
namespace HSchool.Server.Tests;
public class NoticeBoardTests
{
[Fact]
public void PausingPost_IsSticky_InfoIsNot()
{
var board = new NoticeBoard(maxSticky: 8);
Assert.True(board.TryPost(Info(), personId: 0, out var info));
Assert.False(info.Pause);
Assert.False(board.HasPausing);
Assert.Empty(board.Sticky);
Assert.True(board.TryPost(Pausing(), personId: 0, out var warning));
Assert.True(warning.Pause);
Assert.True(board.HasPausing);
Assert.Equal("GenerationFailed", Assert.Single(board.Sticky).DefName);
Assert.DoesNotContain(board.ToSave(), row => row.DefName == "DayStarted");
}
[Fact]
public void NinthSticky_IsNotPosted()
{
var board = new NoticeBoard(maxSticky: 8);
for (var i = 0; i < 8; i++)
{
Assert.True(board.TryPost(Pausing(), personId: 0, out _));
}
Assert.False(board.TryPost(Pausing(), personId: 0, out var ninth));
Assert.Equal(default, ninth);
Assert.Equal(8, board.Sticky.Count);
}
[Fact]
public void Dismiss_RemovesSticky_DoesNotClearTheBoardUntilTheLast()
{
var board = new NoticeBoard(maxSticky: 8);
Assert.True(board.TryPost(Pausing(), personId: 0, out var first));
Assert.True(board.TryPost(Pausing(), personId: 0, out var second));
Assert.True(board.TryDismiss(first.Id));
Assert.True(board.HasPausing);
Assert.Equal(second.Id, Assert.Single(board.Sticky).Id);
Assert.True(board.TryDismiss(second.Id));
Assert.False(board.HasPausing);
Assert.False(board.TryDismiss(second.Id));
}
[Fact]
public void Load_RestoresSticky_SkipsInfoRows()
{
var saved = new StickyNoticeSave[]
{
new() { Id = 3, DefName = "DayStarted", Severity = NoticeSeverity.Info, Pause = false, TtlMs = 8000 },
new() { Id = 4, DefName = "GenerationFailed", Severity = NoticeSeverity.Error, Pause = true, TtlMs = 0 },
};
var board = new NoticeBoard(maxSticky: 8, saved);
Assert.True(board.HasPausing);
var sticky = Assert.Single(board.Sticky);
Assert.Equal(4u, sticky.Id);
Assert.Equal("GenerationFailed", sticky.DefName);
Assert.Equal(NoticeSeverity.Error, sticky.Severity);
}
private static EventDef Info() => new()
{
DefName = "DayStarted",
Severity = EventSeverities.Info,
Pause = false,
TtlMs = 8000,
Trigger = EventTriggers.DayStart,
Action = EventActions.None,
};
private static EventDef Pausing() => new()
{
DefName = "GenerationFailed",
Severity = EventSeverities.Error,
Pause = true,
TtlMs = 0,
Trigger = EventTriggers.GenerationFailed,
Action = EventActions.None,
};
}
@@ -44,6 +44,62 @@ public class SchoolStoreTests
}
}
[Fact]
public void Save_RoundTripsStickyNotices_AndOmitsThemWhenMissing()
{
var directory = Directory.CreateTempSubdirectory("h-school-store-");
try
{
var store = CreateStore(directory.FullName);
store.Save(new SchoolSave
{
Format = 3,
Id = 2,
Name = "Sticky",
GameTime = new DateTime(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc),
Running = false,
SpeedIndex = 1,
Notices =
[
new StickyNoticeSave
{
Id = 9,
DefName = "GenerationFailed",
Severity = 2,
Pause = true,
TtlMs = 0,
},
],
});
var withNotices = Assert.Single(store.LoadAll());
var sticky = Assert.Single(withNotices.Notices!);
Assert.Equal(9u, sticky.Id);
Assert.Equal("GenerationFailed", sticky.DefName);
Assert.True(sticky.Pause);
File.WriteAllText(
Path.Combine(directory.FullName, "3.json"),
"""
{
"format": 3,
"id": 3,
"name": "Plain",
"gameTime": "2012-03-31T06:00:00Z",
"running": false,
"speedIndex": 0
}
""");
var plain = store.LoadAll().Single(save => save.Id == 3);
Assert.True(plain.Notices is null || plain.Notices.Count == 0);
}
finally
{
directory.Delete(recursive: true);
}
}
private static SchoolStore CreateStore(string directory)
{
var options = Options.Create(new SimulationOptions { SavesDirectory = directory });