Enhance school creation and staffing management with native language support
ci / server (push) Failing after 3m44s
ci / client (push) Successful in 14s

- Updated protocol documentation to include `nativeLanguages` in `nameSets` and added `nativeLanguage` to school creation options.
- Enhanced the UI for school creation to allow selection of native languages, improving user experience.
- Revised API interfaces to accommodate new native language features, ensuring proper data handling.
- Improved localization strings to support new native language functionalities in both English and Russian.
- Updated tests to validate the new native language features and ensure robust functionality in staffing scenarios.
This commit is contained in:
Leonid Pershin
2026-08-19 22:22:31 +03:00
parent 21d79cb49b
commit 5a00ad7746
48 changed files with 2064 additions and 312 deletions
+32 -1
View File
@@ -160,6 +160,11 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label);
Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label);
var slavic = Assert.Single(ru.NameSets, set => set.DefName == "Slavic");
Assert.Equal(
["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"],
slavic.NativeLanguages.Select(language => language.DefName).ToArray());
Assert.Equal("Белорусский", Assert.Single(slavic.NativeLanguages, language => language.DefName == "BelarusianLanguage").Label);
Assert.Equal("Начальные классы", Assert.Single(ru.Subjects, subject => subject.DefName == "PrimarySchool").Label);
Assert.Equal("Primary", Assert.Single(en.Subjects, subject => subject.DefName == "PrimarySchool").Label);
var classroom = Assert.Single(ru.Rooms, room => room.DefName == "Classroom");
@@ -287,6 +292,27 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal("unknown-name-set", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownNativeLanguage_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужой язык",
startDate = ExpectedDefaultStart,
nameSetId = "Slavic",
nativeLanguage = "Klingon",
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-native-language", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithACustomConnectedMap_Succeeds()
{
@@ -417,7 +443,7 @@ public class SchoolApiTests(AppHostFixture fixture)
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets,
IReadOnlyList<NameSetInfoResponse> NameSets,
IReadOnlyList<SubjectInfoResponse> Subjects,
DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays,
@@ -425,6 +451,11 @@ public class SchoolApiTests(AppHostFixture fixture)
private sealed record DefInfoResponse(string DefName, string Label);
private sealed record NameSetInfoResponse(
string DefName,
string Label,
IReadOnlyList<DefInfoResponse> NativeLanguages);
private sealed record RoomInfoResponse(
string DefName,
string Label,
@@ -29,14 +29,23 @@ public class StaffingApiTests(AppHostFixture fixture)
Assert.Equal(100_000f, staffing.Allocated);
Assert.Equal(0f, staffing.Payroll);
Assert.Equal(100_000f, staffing.Remaining);
Assert.Equal(12, staffing.Applicants.Count);
Assert.Equal(32, staffing.Applicants.Count);
Assert.Empty(staffing.Staff);
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool");
Assert.NotEmpty(staffing.Positions);
Assert.Contains(staffing.Positions, position => position.DefName == "Teacher");
Assert.Contains(staffing.Subjects, subject => subject.DefName == "Mathematics");
Assert.All(staffing.Applicants, applicant => Assert.NotEmpty(applicant.Skills));
Assert.All(
staffing.Applicants,
applicant =>
{
Assert.Contains(applicant.Skills, skill => skill.Id == "Communication");
Assert.Contains(
applicant.Skills,
skill => skill.Id is "RussianLanguage" or "BelarusianLanguage" or "UkrainianLanguage");
Assert.All(applicant.Skills, skill => Assert.False(string.IsNullOrWhiteSpace(skill.Value)));
});
}
[Fact]
@@ -135,6 +144,12 @@ public class StaffingApiTests(AppHostFixture fixture)
$"/api/schools/{school.Id}/staff/hire",
new { personId = applicant.Id, position = "Teacher" },
TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
Assert.Equal("payroll-exceeded", await ProblemCodeAsync(response));
break;
}
response.EnsureSuccessStatusCode();
staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
@@ -143,7 +158,7 @@ public class StaffingApiTests(AppHostFixture fixture)
Assert.Equal(100_000f, staffing.Allocated);
Assert.True(staffing.Payroll > 0f);
Assert.True(staffing.Payroll <= staffing.Allocated);
Assert.Equal(12, staffing.Staff.Count);
Assert.NotEmpty(staffing.Staff);
}
[Fact]
@@ -224,9 +239,23 @@ public class StaffingApiTests(AppHostFixture fixture)
var staffing = await GetStaffingAsync(client, school.Id);
while (staffing.Applicants.Count > 0)
{
staffing = await HireAsync(client, school.Id, staffing.Applicants[0].Id, "Teacher");
var applicant = staffing.Applicants[0];
using var response = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/staff/hire",
new { personId = applicant.Id, position = "Teacher" },
TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Conflict)
{
break;
}
response.EnsureSuccessStatusCode();
staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(staffing);
}
Assert.NotEmpty(staffing.Staff);
var subjects = staffing.Subjects.Select(subject => subject.DefName).ToArray();
Assert.NotEmpty(subjects);
@@ -327,7 +356,8 @@ public class StaffingApiTests(AppHostFixture fixture)
bool IsParent,
float HourlyWageAsk,
float MonthlyBase,
IReadOnlyList<LabeledStatResponse> Skills);
IReadOnlyList<LabeledStatResponse> Skills,
IReadOnlyList<DefLabelResponse> Traits);
private sealed record LabeledStatResponse(string Id, string Label, string Value);
@@ -118,6 +118,73 @@ public class TimetableApiTests(AppHostFixture fixture)
&& lesson.Locked);
}
/// <summary>
/// The refusal codes the client switches on. `pin-rejected` is covered above; these five are
/// the rest of the documented contract, and none of them was exercised.
/// </summary>
[Fact]
public async Task PinAndUnpinRefusals_CarryTheDocumentedCodes()
{
using var client = fixture.App.CreateHttpClient("server");
await SchoolApiTests.ResetAsync(client);
var school = await SchoolApiTests.CreateAsync(client, "Расписание отказы", TuesdayMorning);
var empty = await GetTimetableAsync(client, school.Id);
var klass = empty.Classes[0];
var room = empty.Rooms.First(candidate => candidate.Id.StartsWith("classroom-", StringComparison.Ordinal));
// Nobody teaches anything yet, so a well-formed pin still has no teacher to place.
Assert.Equal("no-teacher", await PinCodeAsync(client, school.Id, klass.Id, "Mathematics", room.Id));
Assert.Equal("unknown-class", await PinCodeAsync(client, school.Id, "cZZ", "Mathematics", room.Id));
Assert.Equal("unknown-subject", await PinCodeAsync(client, school.Id, klass.Id, "Astrology", room.Id));
Assert.Equal("unknown-room", await PinCodeAsync(client, school.Id, klass.Id, "Mathematics", "no-such-room"));
await HireMathAsync(client, school.Id);
var table = await GetTimetableAsync(client, school.Id);
var lesson = table.Lessons.First(row => row.Subject == "Mathematics");
using var pin = await client.PostAsJsonAsync(
$"/api/schools/{school.Id}/timetable/pin",
new { lesson.ClassId, lesson.Subject, lesson.RoomId, lesson.Day, lesson.Period },
TestContext.Current.CancellationToken);
pin.EnsureSuccessStatusCode();
var unpinQuery = $"classId={Uri.EscapeDataString(lesson.ClassId)}&subject={lesson.Subject}"
+ $"&day={lesson.Day}&period={lesson.Period}";
using var unpin = await client.DeleteAsync(
$"/api/schools/{school.Id}/timetable/pin?{unpinQuery}",
TestContext.Current.CancellationToken);
unpin.EnsureSuccessStatusCode();
// The lock is gone; asking again is a 404, not a silent success.
using var again = await client.DeleteAsync(
$"/api/schools/{school.Id}/timetable/pin?{unpinQuery}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, again.StatusCode);
Assert.Equal("unknown-lesson", await ProblemCodeAsync(again));
using var incomplete = await client.DeleteAsync(
$"/api/schools/{school.Id}/timetable/pin?classId={Uri.EscapeDataString(lesson.ClassId)}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, incomplete.StatusCode);
Assert.Equal("invalid-query", await ProblemCodeAsync(incomplete));
}
private static async Task<string?> PinCodeAsync(
HttpClient client,
int schoolId,
string classId,
string subject,
string roomId)
{
using var response = await client.PostAsJsonAsync(
$"/api/schools/{schoolId}/timetable/pin",
new { classId, subject, roomId, day = 0, period = 1 },
TestContext.Current.CancellationToken);
Assert.False(response.IsSuccessStatusCode, $"pinning {subject} into {roomId} was expected to fail.");
return await ProblemCodeAsync(response);
}
[Fact]
public async Task Saturday_HasNoOccupancyOnTheMap()
{
+12 -2
View File
@@ -19,7 +19,17 @@ public class PeopleDefTests
Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0));
Assert.True(catalog.Needs["Sleep"].RestoredOffCampus);
Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour);
Assert.Contains(catalog.Skills["Agility"].BodyLimits, limit => limit.Attribute == "Build" && limit.Value == "Obese");
Assert.True(catalog.Skills["Communication"].Always);
Assert.True(catalog.Skills["Agility"].Always);
Assert.True(catalog.Skills["Pedagogy"].Work);
Assert.Equal(0.4f, catalog.Skills["English"].AdultChance);
Assert.Equal(
["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"],
catalog.NameSets["Slavic"].Spoken);
Assert.Equal(0.6f, catalog.NameSets["Slavic"].RelatedLanguageChance);
Assert.Equal(40, catalog.NameSets["Slavic"].RelatedLanguageMax);
Assert.Contains(catalog.Subjects["ForeignLanguage"].Skills, share => share.Skill == "English");
Assert.False(catalog.Skills.ContainsKey("ForeignLanguage"));
Assert.True(catalog.NameSets.ContainsKey("Slavic"));
Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20);
Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"]));
@@ -27,7 +37,7 @@ public class PeopleDefTests
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
Assert.NotNull(catalog.StaffingRules);
Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(32, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(4, catalog.StaffingRules.WeeksPerMonth);
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
@@ -42,7 +42,7 @@ public class VanillaCoreTests
Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max);
Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation"));
Assert.NotNull(catalog.StaffingRules);
Assert.Equal(12, catalog.StaffingRules.PoolSize);
Assert.Equal(32, catalog.StaffingRules.PoolSize);
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
Assert.NotNull(catalog.DayFrame);
+1 -1
View File
@@ -96,7 +96,7 @@ internal static class Fixtures
}
public static Roster Generate(MapLayout map, int seed = SchoolSeed) =>
RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf);
RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf, "RussianLanguage");
public static string RepoRoot()
{
@@ -0,0 +1,185 @@
namespace HSchool.People.Tests;
public class SkillGrantTests
{
[Fact]
public void FirstYear_HasCoreAndPrimary_ButNotChemistryOrEnglish()
{
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var pupils = PupilsIn(roster, 1).ToArray();
Assert.NotEmpty(pupils);
Assert.All(
pupils,
person =>
{
Assert.True(person.Skills.ContainsKey("Communication"));
Assert.True(person.Skills.ContainsKey("RussianLanguage"));
Assert.True(person.Skills.ContainsKey("Agility"));
Assert.True(person.Skills.ContainsKey("Mathematics"));
Assert.False(person.Skills.ContainsKey("Chemistry"));
Assert.False(person.Skills.ContainsKey("Physics"));
Assert.False(person.Skills.ContainsKey("English"));
Assert.False(person.Skills.ContainsKey("German"));
Assert.False(person.Skills.ContainsKey("Pedagogy"));
});
}
[Fact]
public void YearFive_HasEnglish_YearEight_HasChemistry()
{
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
Assert.All(
PupilsIn(roster, 5),
person =>
{
Assert.True(person.Skills.ContainsKey("English"));
Assert.False(person.Skills.ContainsKey("Chemistry"));
Assert.False(person.Skills.ContainsKey("German"));
});
Assert.All(PupilsIn(roster, 8), person => Assert.True(person.Skills.ContainsKey("Chemistry")));
}
[Fact]
public void Adults_HaveNativeAndCommunication_ButNotEverySkill()
{
var catalog = Fixtures.Catalog();
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var adults = roster.People.Where(person => !person.IsStudent).ToArray();
Assert.NotEmpty(adults);
var concrete = catalog.Skills.Values.Count(skill => !skill.Abstract);
Assert.All(
adults,
person =>
{
Assert.True(person.Skills.ContainsKey("Communication"));
Assert.True(person.Skills.ContainsKey("RussianLanguage"));
Assert.True(person.Skills.Count < concrete);
Assert.InRange(person.Skills.Keys.Count(name => catalog.Skills[name].Work), 0, 2);
});
Assert.Contains(
adults,
person => person.Skills.Keys.Any(name => catalog.Skills[name].Work));
}
[Fact]
public void BelarusianNative_IsGrantedToEveryone_PupilsStillStudyRussian()
{
var catalog = Fixtures.Catalog();
var roster = RosterGenerator.Generate(
catalog,
Fixtures.Classrooms(11),
Fixtures.SchoolSeed,
"Slavic",
Fixtures.AsOf,
"BelarusianLanguage");
var max = catalog.NameSets["Slavic"].RelatedLanguageMax;
Assert.All(
roster.People,
person =>
{
Assert.True(person.Skills.ContainsKey("BelarusianLanguage"));
if (person.Skills.TryGetValue("UkrainianLanguage", out var ukrainian))
{
Assert.InRange(ukrainian, 0, max);
}
});
Assert.All(
roster.People.Where(person => person.IsStudent),
person => Assert.True(person.Skills.ContainsKey("RussianLanguage")));
Assert.Contains(
roster.People.Where(person => person.IsStudent),
person => person.Skills["RussianLanguage"] > max);
}
[Fact]
public void RelatedLanguages_AreCommonAndStayLow()
{
var catalog = Fixtures.Catalog();
var max = catalog.NameSets["Slavic"].RelatedLanguageMax;
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
Assert.Contains(
roster.People,
person =>
person.Skills.ContainsKey("BelarusianLanguage")
|| person.Skills.ContainsKey("UkrainianLanguage"));
Assert.Contains(roster.People, person => !person.Skills.ContainsKey("BelarusianLanguage"));
Assert.Contains(roster.People, person => !person.Skills.ContainsKey("UkrainianLanguage"));
Assert.True(
roster.People.Count(person =>
person.Skills.ContainsKey("BelarusianLanguage")
|| person.Skills.ContainsKey("UkrainianLanguage"))
> roster.People.Count / 2);
Assert.All(
roster.People,
person =>
{
Assert.True(person.Skills.ContainsKey("RussianLanguage"));
if (person.Skills.TryGetValue("BelarusianLanguage", out var belarusian))
{
Assert.InRange(belarusian, 0, max);
}
if (person.Skills.TryGetValue("UkrainianLanguage", out var ukrainian))
{
Assert.InRange(ukrainian, 0, max);
}
});
}
[Fact]
public void OmittedNativeLanguage_IsStableForTheSameSeed()
{
var map = Fixtures.Classrooms(2);
var a = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Slavic", Fixtures.AsOf);
var b = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Slavic", Fixtures.AsOf);
Assert.Equal(
a.People.Select(person => string.Join(',', person.Skills.Keys.Order(StringComparer.Ordinal))),
b.People.Select(person => string.Join(',', person.Skills.Keys.Order(StringComparer.Ordinal))));
}
[Fact]
public void Pupils_DoNotRollWorkSkills()
{
var catalog = Fixtures.Catalog();
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
Assert.All(
roster.People.Where(person => person.IsStudent),
person => Assert.DoesNotContain(person.Skills.Keys, name => catalog.Skills[name].Work));
}
[Fact]
public void Intake_YearFourGainsEnglishWithoutRerollingMath()
{
var catalog = Fixtures.Catalog();
var before = Fixtures.Generate(Fixtures.Classrooms(5));
var year4 = before.Classes.Single(schoolClass => schoolClass.Year == 4);
var pupil = before.People.First(person => person.Id == year4.PupilIds[0]);
Assert.False(pupil.Skills.ContainsKey("English"));
var math = pupil.Skills["Mathematics"];
var after = YearlyIntake.Apply(
catalog,
before,
Fixtures.SchoolSeed,
"Slavic",
new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc));
var grown = after.People.First(person => person.Id == pupil.Id);
Assert.True(grown.ClassId is { } classId && after.Classes.Any(schoolClass =>
schoolClass.Id.Equals(classId, StringComparison.Ordinal) && schoolClass.Year == 5));
Assert.True(grown.Skills.ContainsKey("English"));
Assert.Equal(math, grown.Skills["Mathematics"]);
}
private static IEnumerable<Person> PupilsIn(Roster roster, int year)
{
var classIds = roster.Classes
.Where(schoolClass => schoolClass.Year == year)
.Select(schoolClass => schoolClass.Id)
.ToHashSet(StringComparer.Ordinal);
return roster.People.Where(person =>
person.IsStudent && person.ClassId is { } classId && classIds.Contains(classId));
}
}
+6 -4
View File
@@ -224,9 +224,10 @@ public class StaffingTests
Assert.Equal(catalog.Subjects.Values.Count(subject => !subject.Abstract), uncovered.Count);
var applicant = pool.Applicants[0];
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap);
var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, 100_000f);
var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", 100_000f);
Assert.Equal(StaffingError.None, assigned.Error);
Assert.DoesNotContain(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "Mathematics");
Assert.Contains(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "PrimarySchool");
}
@@ -236,8 +237,9 @@ public class StaffingTests
{
var (catalog, map, roster, pool) = Fresh();
var applicant = pool.Applicants[0];
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap);
var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap);
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, 100_000f);
var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", 100_000f);
Assert.Equal(StaffingError.None, assigned.Error);
var json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, assigned.Roster, assigned.Pool));
var loaded = RosterJson.Parse(json);
@@ -118,5 +118,8 @@ public class YearlyIntakeTests
private static string Snapshot(Roster roster) =>
string.Join('\n', roster.People.Select(person =>
$"{person.Id}|{person.FamilyId}|{person.IsStudent}|{person.IsStaff}|{person.IsParent}|{person.ClassId}|{person.Name.Full}"));
$"{person.Id}|{person.FamilyId}|{person.IsStudent}|{person.IsStaff}|{person.IsParent}|{person.ClassId}|{person.Name.Full}|{Skills(person)}"));
private static string Skills(Person person) =>
string.Join(',', person.Skills.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"));
}