Update wire protocol to version 6 and enhance timetable functionality
ci / server (push) Failing after 3m53s
ci / client (push) Successful in 15s

- Bumped the wire protocol version to 6, reflecting changes in the communication structure.
- Expanded the timetable API with new endpoints for fetching and managing lesson schedules, including `GET /api/schools/{id}/timetable` and `POST /api/schools/{id}/timetable/pin`.
- Updated the protocol documentation to include detailed descriptions of the new timetable features and message structures.
- Enhanced the client-side implementation to support the new timetable functionalities, including lesson pinning and unpinning.
- Revised server-side logic to handle timetable operations and ensure proper integration with existing school management features.
- Added tests to validate the new timetable functionalities and ensure robustness in handling lesson data.
This commit is contained in:
Leonid Pershin
2026-08-19 10:05:05 +03:00
parent 2011d12b1d
commit cad3068bac
30 changed files with 1621 additions and 43 deletions
+51 -2
View File
@@ -1,3 +1,4 @@
using System.Net.Http.Json;
using System.Net.WebSockets;
using HSchool.Protocol;
@@ -110,6 +111,8 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.Equal(16, classroom.PupilSlots);
Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16);
Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул");
Assert.Equal("", classroom.ActivitySubject);
Assert.Empty(classroom.Present);
}
[Fact]
@@ -157,6 +160,40 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1");
}
[Fact]
public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnTheRoom()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
var school = await SchoolApiTests.CreateAsync(client, "Кто где сейчас", start);
var staffing = await client.GetFromJsonAsync<StaffingSnapshot>(
$"/api/schools/{school.Id}/staffing",
TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
var applicant = staffing.Applicants[0];
using var hire = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/hire",
new { personId = applicant.Id, position = "Teacher" },
TestContext.Current.CancellationToken);
hire.EnsureSuccessStatusCode();
using var assign = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(applicant.Id)}/subjects",
new { subject = "Mathematics" },
TestContext.Current.CancellationToken);
assign.EnsureSuccessStatusCode();
using var socket = await OpenSchoolAsync(school.Id);
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
var occupied = Assert.Single(snapshot.Nodes, node => node.ActivitySubject.Length > 0);
Assert.Equal("Математика", occupied.ActivitySubject);
Assert.NotEmpty(occupied.ActivityClass);
Assert.Contains(applicant.FullName, occupied.Present);
Assert.True(occupied.Present.Count > 1);
}
[Fact]
public async Task Pausing_FreezesTheClock()
{
@@ -448,7 +485,8 @@ public class GameSocketTests(AppHostFixture fixture)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
cts.CancelAfter(timeout ?? DefaultTimeout);
var buffer = new byte[ProtocolConstants.MaxMessageSize];
var buffer = new byte[64 * 1024];
var chunks = new List<byte>();
while (true)
{
@@ -467,11 +505,22 @@ public class GameSocketTests(AppHostFixture fixture)
throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}.");
}
var frame = buffer[..result.Count];
chunks.AddRange(buffer.AsSpan(0, result.Count).ToArray());
if (!result.EndOfMessage)
{
continue;
}
var frame = chunks.ToArray();
chunks.Clear();
if (ProtocolCodec.PeekMessageType(frame) == expected)
{
return frame;
}
}
}
private sealed record StaffingSnapshot(IReadOnlyList<ApplicantSnapshot> Applicants);
private sealed record ApplicantSnapshot(string Id, string FullName);
}
@@ -0,0 +1,217 @@
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
[Collection(AppHostCollection.Name)]
public class TimetableApiTests(AppHostFixture fixture)
{
private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
private static readonly DateTime SaturdayMorning = new(2012, 4, 7, 10, 20, 0, DateTimeKind.Utc);
[Fact]
public async Task GetTimetable_UnknownSchool_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
using var response = await client.GetAsync("/api/schools/999999/timetable", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Equal("unknown-school", await ProblemCodeAsync(response));
}
[Fact]
public async Task HireMathematics_PlacesLessons_UnassignUncoversThem()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание наём", TuesdayMorning);
var teacher = await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
Assert.Equal(5, table.WeekDays);
Assert.Equal(7, table.LessonCount);
Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics" && lesson.TeacherId == teacher);
Assert.DoesNotContain(table.Uncovered, row => row.Subject == "Mathematics");
using var unassign = await client.DeleteAsync(
$"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher)}/subjects/Mathematics",
TestContext.Current.CancellationToken);
unassign.EnsureSuccessStatusCode();
var after = await GetTimetableAsync(client, school.Id);
Assert.DoesNotContain(after.Lessons, lesson => lesson.Subject == "Mathematics");
Assert.Contains(after.Uncovered, row => row.Subject == "Mathematics");
}
[Fact]
public async Task PinThenHireAnother_KeepsTheLockedSlot()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание закрепление", TuesdayMorning);
var first = await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
var pinned = table.Lessons.First(lesson => lesson.Subject == "Mathematics");
using var pin = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/timetable/pin",
new { pinned.ClassId, pinned.Subject, pinned.RoomId, pinned.Day, pinned.Period },
TestContext.Current.CancellationToken);
pin.EnsureSuccessStatusCode();
var staffing = await GetStaffingAsync(client, school.Id);
var secondApplicant = staffing.Applicants[0];
await HireAsync(client, school.Id, secondApplicant.Id, "Teacher");
await AssignAsync(client, school.Id, secondApplicant.Id, "Mathematics");
var after = await GetTimetableAsync(client, school.Id);
Assert.Contains(
after.Lessons,
lesson =>
lesson.ClassId == pinned.ClassId
&& lesson.Subject == pinned.Subject
&& lesson.TeacherId == first
&& lesson.RoomId == pinned.RoomId
&& lesson.Day == pinned.Day
&& lesson.Period == pinned.Period
&& lesson.Locked);
using var gym = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/timetable/pin",
new { pinned.ClassId, subject = "Mathematics", roomId = "gym-hall", day = 0, period = 1 },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Conflict, gym.StatusCode);
Assert.Equal("pin-rejected", await ProblemCodeAsync(gym));
}
[Fact]
public async Task Reload_RestoresLockedLessons()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание диск", TuesdayMorning);
await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
var pinned = table.Lessons.First(lesson => lesson.Subject == "Mathematics");
using var pin = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/timetable/pin",
new { pinned.ClassId, pinned.Subject, pinned.RoomId, pinned.Day, pinned.Period },
TestContext.Current.CancellationToken);
pin.EnsureSuccessStatusCode();
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = await GetTimetableAsync(client, school.Id);
Assert.Contains(
restored.Lessons,
lesson =>
lesson.ClassId == pinned.ClassId
&& lesson.Subject == pinned.Subject
&& lesson.RoomId == pinned.RoomId
&& lesson.Day == pinned.Day
&& lesson.Period == pinned.Period
&& lesson.Locked);
}
[Fact]
public async Task Saturday_HasNoOccupancyOnTheMap()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание суббота", SaturdayMorning);
await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics");
}
private static async Task<string> HireMathAsync(HttpClient client, int schoolId)
{
var staffing = await GetStaffingAsync(client, schoolId);
var applicant = staffing.Applicants[0];
await HireAsync(client, schoolId, applicant.Id, "Teacher");
await AssignAsync(client, schoolId, applicant.Id, "Mathematics");
return applicant.Id;
}
private static async Task<TimetableResponse> GetTimetableAsync(HttpClient client, int schoolId)
{
var table = await client.GetFromJsonAsync<TimetableResponse>(
$"/api/schools/{schoolId}/timetable?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(table);
return table;
}
private static async Task<StaffingResponse> GetStaffingAsync(HttpClient client, int schoolId)
{
var staffing = await client.GetFromJsonAsync<StaffingResponse>(
$"/api/schools/{schoolId}/staffing?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
return staffing;
}
private static async Task HireAsync(HttpClient client, int schoolId, string personId, string position)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/staff/hire",
new { personId, position },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
private static async Task AssignAsync(HttpClient client, int schoolId, string personId, string subject)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(personId)}/subjects",
new { subject },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
private static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
return problem?.Code;
}
private sealed record ProblemResponse(string? Code);
private sealed record TimetableResponse(
int WeekDays,
int LessonCount,
IReadOnlyList<LessonResponse> Lessons,
IReadOnlyList<UncoveredResponse> Uncovered);
private sealed record LessonResponse(
string ClassId,
int ClassYear,
string ClassLetter,
string Subject,
string SubjectLabel,
string TeacherId,
string TeacherName,
string RoomId,
int Day,
int Period,
bool Locked);
private sealed record UncoveredResponse(
string ClassId,
int ClassYear,
string ClassLetter,
string Subject,
string SubjectLabel,
int Hours);
private sealed record StaffingResponse(
IReadOnlyList<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> Staff);
private sealed record ApplicantResponse(string Id);
private sealed record StaffMemberResponse(string Id);
}
@@ -164,6 +164,34 @@ public class ProtocolCodecTests
Assert.Equal(0, read.Nodes[0].PupilSlots);
Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items);
Assert.Equal(["Директор"], read.Nodes[1].Positions);
Assert.Equal("", read.Nodes[0].ActivitySubject);
Assert.Empty(read.Nodes[0].Present);
}
[Fact]
public void MapSnapshot_RoundTripsOccupancyAfterPositions()
{
var message = new ServerMapSnapshotMessage(3, [
new MapSnapshotNode(
3,
"classroom-101",
"floor-1",
"Класс 101",
16,
[new MapSnapshotItem("Парта", 16)],
[],
"Математика",
"5А",
["Иванова Ольга Михайловна", "Соколов Иван Петрович"]),
]);
var buffer = new byte[ProtocolCodec.MapSnapshotSize(message)];
var length = ProtocolCodec.WriteMapSnapshot(buffer, message);
var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length));
Assert.Equal("Математика", read.Nodes[0].ActivitySubject);
Assert.Equal("5А", read.Nodes[0].ActivityClass);
Assert.Equal(["Иванова Ольга Михайловна", "Соколов Иван Петрович"], read.Nodes[0].Present);
}
[Fact]
@@ -0,0 +1,57 @@
using HSchool.Content;
namespace HSchool.Schedule.Tests;
public class TimetableClockTests
{
private readonly DefCatalog _catalog = Fixtures.Catalog();
private static readonly LessonPlacement MathOnTuesdayPeriod3 = new(
"c5A",
"Mathematics",
"t1",
"classroom-101",
Day: 1,
Period: 3);
[Fact]
public void TuesdayTenTwenty_IsThePlacedLesson()
{
var table = new Timetable([MathOnTuesdayPeriod3], []);
var occurring = TimetableClock.OccurringAt(
table,
_catalog,
new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc),
weekDays: 5);
Assert.Equal([MathOnTuesdayPeriod3], occurring);
}
[Fact]
public void TuesdayTenFifteen_IsABreakAndEmpty()
{
var table = new Timetable([MathOnTuesdayPeriod3], []);
var occurring = TimetableClock.OccurringAt(
table,
_catalog,
new DateTime(2012, 4, 3, 10, 15, 0, DateTimeKind.Utc),
weekDays: 5);
Assert.Empty(occurring);
Assert.Equal(
new OccupancyKey(new DateOnly(2012, 4, 3), DaySlotKind.Break, 2),
TimetableClock.Key(_catalog, new DateTime(2012, 4, 3, 10, 15, 0, DateTimeKind.Utc), 5));
}
[Fact]
public void Saturday_IsEmpty()
{
var table = new Timetable([MathOnTuesdayPeriod3], []);
var occurring = TimetableClock.OccurringAt(
table,
_catalog,
new DateTime(2012, 4, 7, 10, 20, 0, DateTimeKind.Utc),
weekDays: 5);
Assert.Empty(occurring);
}
}