Files
h-school/tests/HSchool.AppHost.Tests/SchoolApiTests.cs
T

897 lines
35 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net.Http.Json;
namespace HSchool.AppHost.Tests;
/// <summary>
/// The main menu's HTTP surface: list, create, delete. Tests share one AppHost, so each of them
/// starts from an empty list rather than assuming one.
/// </summary>
[Collection(AppHostCollection.Name)]
public class SchoolApiTests(AppHostFixture fixture)
{
private static readonly DateTime ExpectedDefaultStart = new(2012, 3, 31, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Schools_ReportTheConfiguredLimitAndDefaultStartDate()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var state = await GetSchoolsAsync(client);
Assert.Equal(2, state.MaxSchools);
Assert.Equal(7, state.MaxSchoolsTotal);
Assert.Equal(ExpectedDefaultStart, state.DefaultStartDate);
// Without the "Z" the browser would read the start date in its own time zone and the
// creation form would offer the wrong hour.
Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind);
Assert.Equal(1d, state.GameMinutesPerRealSecond);
Assert.Equal(5, state.SchoolWeekDays);
Assert.Empty(state.Schools);
}
[Fact]
public async Task CreateSchool_StartsAtTheRequestedDateAndRunning()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "Гимназия у моря", ExpectedDefaultStart);
Assert.Equal("Гимназия у моря", created.Name);
Assert.Equal(ExpectedDefaultStart, created.GameTime);
// Schools live from the moment they exist, whether or not anybody is inside.
Assert.True(created.Running);
var state = await GetSchoolsAsync(client);
Assert.Contains(state.Schools, school => school.Id == created.Id);
}
[Fact]
public async Task CreateSchool_BeyondTheLimit_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var state = await GetSchoolsAsync(client);
for (var i = 0; i < state.MaxSchools; i++)
{
await CreateAsync(client, $"Школа {i + 1}", ExpectedDefaultStart);
}
using var response = await PostAsync(client, "Лишняя", ExpectedDefaultStart);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("school-limit-reached", await ProblemCodeAsync(response));
Assert.Equal(state.MaxSchools, (await GetSchoolsAsync(client)).Schools.Count);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task CreateSchool_WithABlankName_IsRejected(string name)
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await PostAsync(client, name, ExpectedDefaultStart);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-name", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnImpossibleStartDate_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await PostAsync(client, "Школа", new DateTime(1500, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-start-date", await ProblemCodeAsync(response));
}
[Fact]
public async Task DeleteSchool_RemovesItAndFreesASlot()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "На удаление", ExpectedDefaultStart);
using var response = await client.DeleteAsync($"/api/schools/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.DoesNotContain((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id);
}
[Fact]
public async Task DeleteSchool_ThatDoesNotExist_IsNotFound()
{
using var client = fixture.App.CreateHttpClient("server");
await LoginAsync(client);
using var response = await client.DeleteAsync("/api/schools/999999", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task RandomName_IsUsableAsIs()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var suggestion = await client.GetFromJsonAsync<RandomNameResponse>(
"/api/schools/random-name",
TestContext.Current.CancellationToken);
Assert.NotNull(suggestion);
Assert.False(string.IsNullOrWhiteSpace(suggestion.Name));
var created = await CreateAsync(client, suggestion.Name, ExpectedDefaultStart);
Assert.Equal(suggestion.Name, created.Name);
}
[Fact]
public async Task Mods_ListCoreAsRequired()
{
using var client = fixture.App.CreateHttpClient("server");
var response = await client.GetFromJsonAsync<ModsResponse>("/api/mods", TestContext.Current.CancellationToken);
Assert.NotNull(response);
var core = Assert.Single(response.Mods, pack => pack.Id == "core");
Assert.True(core.Required);
Assert.Equal("Базовая игра", core.Label);
Assert.False(string.IsNullOrWhiteSpace(core.Version));
Assert.Empty(core.Requires);
}
[Fact]
public async Task Mods_LabelsCoreInTheRequestedLanguage()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync<ModsResponse>("/api/mods?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<ModsResponse>("/api/mods?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Базовая игра", Assert.Single(ru.Mods, pack => pack.Id == "core").Label);
Assert.Equal("Core", Assert.Single(en.Mods, pack => pack.Id == "core").Label);
}
[Fact]
public async Task Mods_PackWithoutManifest_UsesIdAsLabel()
{
using var client = fixture.App.CreateHttpClient("server");
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22plain"))
{
var response = await client.GetFromJsonAsync<ModsResponse>("/api/mods?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(response);
var pack = Assert.Single(response.Mods, candidate => candidate.Id == "t22plain");
Assert.False(pack.Required);
Assert.Equal("t22plain", pack.Label);
Assert.Equal(string.Empty, pack.Version);
Assert.Empty(pack.Requires);
}
}
[Fact]
public async Task CreateSchool_ReturnsResolvedPackOrder()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "С ванилью", ExpectedDefaultStart);
Assert.Equal(["core"], created.ModIds);
}
[Fact]
public async Task CreateSchool_WithUnselectedDependency_NamesTheMissingPack()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22need", """{ "version": "1", "requires": ["t22ghost"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Без базы",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22need" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
Assert.Equal("missing-mod", problem?.Code);
Assert.Equal("t22ghost", problem?.Missing);
}
}
[Fact]
public async Task CreateSchool_LoadsRequiredPackBeforeDependentEvenIfListedAfter()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22base", """{ "version": "1", "requires": [] }"""))
using (new TempPack(root, "t22addon", """{ "version": "1", "requires": ["t22base"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Сначала зависимость",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22addon", "t22base" },
},
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.Equal(["core", "t22base", "t22addon"], created?.ModIds);
}
}
[Fact]
public async Task CreateSchool_WithCyclicMods_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var root = await ModsDirectoryAsync(client);
using (new TempPack(root, "t22left", """{ "requires": ["t22right"] }"""))
using (new TempPack(root, "t22right", """{ "requires": ["t22left"] }"""))
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Цикл",
startDate = ExpectedDefaultStart,
modIds = new[] { "t22left", "t22right" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("mod-cycle", await ProblemCodeAsync(response));
}
}
[Fact]
public async Task Catalog_LabelsDefsInTheRequestedLanguage()
{
using var client = fixture.App.CreateHttpClient("server");
var ru = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=ru", TestContext.Current.CancellationToken);
var en = await client.GetFromJsonAsync<CatalogResponse>("/api/catalog?lang=en", TestContext.Current.CancellationToken);
Assert.NotNull(ru);
Assert.NotNull(en);
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("Principal's office", Assert.Single(en.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
Assert.Equal("Россия", Assert.Single(ru.Countries, country => country.DefName == "Russia").Label);
Assert.Equal("Russia", Assert.Single(en.Countries, country => country.DefName == "Russia").Label);
var russia = Assert.Single(ru.Countries, country => country.DefName == "Russia");
Assert.Equal(
["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"],
russia.NativeLanguages.Select(language => language.DefName).ToArray());
Assert.Equal("Белорусский", Assert.Single(russia.NativeLanguages, language => language.DefName == "BelarusianLanguage").Label);
Assert.Equal(["TemperateContinental", "ContinentalCold"], russia.ClimatePresets);
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");
Assert.True(classroom.Homeroom);
Assert.Equal("StudentDesk", classroom.SeatThing);
Assert.Equal(16, classroom.DefaultSeats);
Assert.Empty(classroom.Slots);
Assert.Empty(classroom.Positions);
Assert.NotNull(ru.DayFrame);
Assert.Equal("08:30", ru.DayFrame.FirstLesson);
Assert.Equal(7, ru.DayFrame.LessonCount);
Assert.Equal(3, ru.DayFrame.LongBreakAfter);
Assert.Equal("Учебный день", ru.DayFrame.Label);
Assert.NotNull(en.DayFrame);
Assert.Equal("School day", en.DayFrame.Label);
Assert.Equal("GymHall", Assert.Single(ru.Subjects, subject => subject.DefName == "PhysicalEducation").Room);
Assert.Null(Assert.Single(ru.Subjects, subject => subject.DefName == "Mathematics").Room);
Assert.Contains(ru.Holidays, holiday => holiday.DefName == "SpringBreak");
Assert.Contains(ru.Things, thing => thing.DefName == "Chair");
Assert.DoesNotContain(ru.Things, thing => thing.DefName == "TShirt");
Assert.DoesNotContain(ru.Things, thing => thing.DefName == "Textbook");
}
[Fact]
public async Task Catalog_WithAnUnknownMod_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
using var response = await client.GetAsync("/api/catalog?mods=no-such-mod", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
}
/// <summary>
/// A pack id is a folder name that came from a browser. Anything outside the safe alphabet is
/// refused as unknown before it can be joined onto a path — on both endpoints that take one.
/// </summary>
[Fact]
public async Task ModId_ThatEscapesTheModsFolder_IsRejectedOnBothEndpoints()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var catalog = await client.GetAsync("/api/catalog?mods=..%2F..%2Fsaves", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, catalog.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(catalog));
using var create = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Побег из mods",
startDate = ExpectedDefaultStart,
modIds = new[] { "../../saves" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, create.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(create));
}
[Fact]
public async Task CreateSchool_WithABrokenMap_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Дырявая",
startDate = ExpectedDefaultStart,
map = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = Array.Empty<object>(),
floors = Array.Empty<object>(),
rooms = Array.Empty<object>(),
links = Array.Empty<object>(),
},
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("invalid-map", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownMod_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужой мод",
startDate = ExpectedDefaultStart,
modIds = new[] { "no-such-mod" },
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownCountry_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужая страна",
startDate = ExpectedDefaultStart,
countryId = "NoSuchCountry",
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-country", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithRussiaAndNoLanguage_PicksTheSameNativeAsTheSeed()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
const int seed = 42;
var first = await CreateWithCountryAsync(client, "Страна А", ExpectedDefaultStart, "Russia", seed);
var second = await CreateWithCountryAsync(client, "Страна Б", ExpectedDefaultStart, "Russia", seed);
var firstSave = await ReadSchoolSaveAsync(client, first.Id);
var secondSave = await ReadSchoolSaveAsync(client, second.Id);
Assert.Equal("Russia", firstSave.CountryId);
Assert.Equal(firstSave.NativeLanguage, secondSave.NativeLanguage);
Assert.Contains(firstSave.NativeLanguage, new[] { "RussianLanguage", "BelarusianLanguage", "UkrainianLanguage" });
}
[Fact]
public async Task CreateSchool_WithTheSameSeed_GetsTheSameClimatePreset()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
const int seed = 20260818;
var first = await CreateWithCountryAsync(client, "Климат А", ExpectedDefaultStart, "Russia", seed);
var second = await CreateWithCountryAsync(client, "Климат Б", ExpectedDefaultStart, "Russia", seed);
var firstSave = await ReadSchoolSaveAsync(client, first.Id);
var secondSave = await ReadSchoolSaveAsync(client, second.Id);
Assert.Equal(firstSave.ClimatePresetId, secondSave.ClimatePresetId);
Assert.Contains(firstSave.ClimatePresetId, new[] { "TemperateContinental", "ContinentalCold" });
}
[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,
countryId = "Russia",
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()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateWithMapAsync(client, "Своя карта", ExpectedDefaultStart, SimpleCustomMap);
Assert.Equal("Своя карта", created.Name);
Assert.Contains((await GetSchoolsAsync(client)).Schools, school => school.Id == created.Id);
}
[Fact]
public async Task Status_ReportsTheLoopAndTheLimit()
{
using var client = fixture.App.CreateHttpClient("server");
await LoginAsync(client);
var status = await client.GetFromJsonAsync<StatusResponse>("/api/status", TestContext.Current.CancellationToken);
Assert.NotNull(status);
Assert.Equal(20, status.TickRate);
Assert.Equal(2, status.MaxSchools);
Assert.True(status.Tick > 0, "The loop should have ticked by now.");
}
[Fact]
public async Task ReloadFromDisk_RestoresCreatedSchools()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
var created = await CreateAsync(client, "После перезагрузки", ExpectedDefaultStart);
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
var restored = (await GetSchoolsAsync(client)).Schools.Single(school => school.Id == created.Id);
Assert.Equal(created.Name, restored.Name);
Assert.True(restored.Running);
}
internal const string TestPassword = "test-alpha";
internal const string TestUserName = "TestPlayer";
internal static async Task LoginAsync(HttpClient client, string userName = TestUserName)
{
using var current = await client.GetAsync("/api/session", TestContext.Current.CancellationToken);
if (current.IsSuccessStatusCode)
{
return;
}
_ = await LoginAndGetCookieAsync(client, userName);
}
internal static async Task<HttpClient> CreateIsolatedClientAsync(DistributedApplication app, string userName)
{
var login = app.CreateHttpClient("server");
var cookie = await LoginAndGetCookieAsync(login, userName);
login.Dispose();
var handler = new HttpClientHandler { UseCookies = false };
var client = new HttpClient(handler)
{
BaseAddress = new Uri(app.GetEndpoint("server", "http").ToString()),
};
client.DefaultRequestHeaders.Add("Cookie", cookie);
return client;
}
internal static async Task<string> LoginAndGetCookieAsync(HttpClient client, string userName = TestUserName)
{
using var first = await client.PostAsJsonAsync(
"/api/session",
new { password = TestPassword, userName },
TestContext.Current.CancellationToken);
if (first.IsSuccessStatusCode)
{
return ExtractSessionCookie(first);
}
if (first.StatusCode == HttpStatusCode.Conflict)
{
userName = $"T-{Guid.NewGuid():N}"[..10];
using var retry = await client.PostAsJsonAsync(
"/api/session",
new { password = TestPassword, userName },
TestContext.Current.CancellationToken);
retry.EnsureSuccessStatusCode();
return ExtractSessionCookie(retry);
}
first.EnsureSuccessStatusCode();
return ExtractSessionCookie(first);
}
internal static string ExtractSessionCookie(HttpResponseMessage response)
{
if (!response.Headers.TryGetValues("Set-Cookie", out var values))
{
throw new InvalidOperationException("Login did not return a session cookie.");
}
foreach (var value in values)
{
if (!value.StartsWith("hschool.session=", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var end = value.IndexOf(';');
return end >= 0 ? value[..end] : value;
}
throw new InvalidOperationException("Login did not return a session cookie.");
}
internal static async Task ResetAsync(HttpClient client)
{
await LoginAsync(client);
var state = await GetSchoolsAsync(client);
foreach (var school in state.Schools)
{
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
foreach (var school in state.Others)
{
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Forbidden)
{
continue;
}
response.EnsureSuccessStatusCode();
}
}
internal static async Task WipeAllSavesAsync(HttpClient client)
{
await LoginAsync(client);
for (var pass = 0; pass < 4; pass++)
{
var state = await GetSchoolsAsync(client);
if (state.Schools.Count == 0 && state.Others.Count == 0)
{
break;
}
foreach (var school in state.Schools)
{
using var response = await client.DeleteAsync($"/api/schools/{school.Id}", TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
}
foreach (var other in state.Others)
{
using var response = await client.DeleteAsync($"/api/schools/{other.Id}", TestContext.Current.CancellationToken);
if (response.StatusCode == HttpStatusCode.Forbidden)
{
continue;
}
response.EnsureSuccessStatusCode();
}
}
var directory = await SavesDirectoryAsync(client);
foreach (var path in Directory.EnumerateFiles(directory))
{
File.Delete(path);
}
foreach (var path in Directory.EnumerateDirectories(directory))
{
Directory.Delete(path, recursive: true);
}
using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken);
reload.EnsureSuccessStatusCode();
}
internal static async Task<SchoolsResponse> GetSchoolsAsync(HttpClient client)
{
var state = await client.GetFromJsonAsync<SchoolsResponse>("/api/schools", TestContext.Current.CancellationToken);
Assert.NotNull(state);
return state;
}
internal static async Task<SchoolResponse> CreateAsync(
HttpClient client,
string name,
DateTime startDate,
int? seed = null)
{
using var response = await PostAsync(client, name, startDate, seed);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
internal static async Task<SchoolResponse> CreateWithMapAsync(HttpClient client, string name, DateTime startDate, object map)
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new { name, startDate, map },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
private static async Task<SchoolResponse> CreateWithCountryAsync(
HttpClient client,
string name,
DateTime startDate,
string countryId,
int seed)
{
using var response = await client.PostAsJsonAsync(
"/api/schools",
new { name, startDate, countryId, seed },
TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<SchoolResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(created);
return created;
}
private static async Task<SchoolSaveFile> ReadSchoolSaveAsync(HttpClient client, int id)
{
var directory = await SavesDirectoryAsync(client);
var json = await File.ReadAllTextAsync(Path.Combine(directory, $"{id}.json"), TestContext.Current.CancellationToken);
var save = System.Text.Json.JsonSerializer.Deserialize<SchoolSaveFile>(
json,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(save);
return save;
}
internal static async Task<string> SavesDirectoryAsync(HttpClient client)
{
var payload = await client.GetFromJsonAsync<PathResponse>(
"/api/dev/saves-directory",
TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload.Path));
return payload.Path;
}
private sealed record SchoolSaveFile(string? CountryId, string? ClimatePresetId, string? NativeLanguage, string? Owner);
internal static readonly object SimpleCustomMap = new
{
territory = new { id = "yard", def = "SchoolYard" },
buildings = new[] { new { id = "main", def = "MainBuilding" } },
floors = new[] { new { id = "floor-1", def = "StandardFloor", building = "main", label = "1" } },
rooms = new[]
{
new { id = "office", def = "PrincipalsOffice", building = "main", floor = "floor-1", slots = Array.Empty<object>() },
},
links = new[] { new { a = "yard", b = "office" } },
};
internal static Task<HttpResponseMessage> PostAsync(
HttpClient client,
string name,
DateTime startDate,
int? seed = null) =>
client.PostAsJsonAsync(
"/api/schools",
seed is null ? (object)new { name, startDate } : new { name, startDate, seed },
TestContext.Current.CancellationToken);
internal static async Task<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
return problem?.Code;
}
internal sealed record SchoolResponse(
int Id,
string Name,
DateTime GameTime,
bool Running,
byte SpeedIndex,
int Seed,
bool Mine,
IReadOnlyList<string>? ModIds = null);
internal sealed record OtherSchoolResponse(
int Id,
string Name,
DateTime GameTime,
bool Running,
byte SpeedIndex,
int Seed,
string? Owner,
IReadOnlyList<string>? ModIds = null);
internal sealed record SchoolsResponse(
int MaxSchools,
int MaxSchoolsTotal,
DateTime DefaultStartDate,
double GameMinutesPerRealSecond,
int SchoolWeekDays,
IReadOnlyList<SchoolResponse> Schools,
IReadOnlyList<OtherSchoolResponse> Others);
private sealed record RandomNameResponse(string Name);
private sealed record ProblemResponse(string? Code, string? Missing = null);
private sealed record StatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
private sealed record ModsResponse(IReadOnlyList<ModInfoResponse> Mods);
private sealed record ModInfoResponse(
string Id,
bool Required,
string Label,
string Version,
IReadOnlyList<string> Requires);
private sealed record PathResponse(string Path);
private static async Task<string> ModsDirectoryAsync(HttpClient client)
{
var payload = await client.GetFromJsonAsync<PathResponse>(
"/api/dev/mods-directory",
TestContext.Current.CancellationToken);
Assert.NotNull(payload);
Assert.False(string.IsNullOrWhiteSpace(payload.Path));
return payload.Path;
}
private sealed class TempPack : IDisposable
{
public TempPack(string modsRoot, string id, string? packJsonc = null)
{
Path = System.IO.Path.Combine(modsRoot, id);
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
Directory.CreateDirectory(Path);
if (packJsonc is not null)
{
File.WriteAllText(System.IO.Path.Combine(Path, "pack.jsonc"), packJsonc);
}
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path))
{
Directory.Delete(Path, recursive: true);
}
}
}
private sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Territories,
IReadOnlyList<DefInfoResponse> Buildings,
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<CountryInfoResponse> Countries,
IReadOnlyList<SubjectInfoResponse> Subjects,
DayFrameResponse? DayFrame,
IReadOnlyList<HolidayInfoResponse> Holidays,
MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label);
private sealed record CountryInfoResponse(
string DefName,
string Label,
IReadOnlyList<DefInfoResponse> NativeLanguages,
IReadOnlyList<string> ClimatePresets);
private sealed record RoomInfoResponse(
string DefName,
string Label,
bool Homeroom,
string? SeatThing,
int DefaultSeats,
IReadOnlyList<RoomSlotResponse> Slots,
IReadOnlyList<string> Positions);
private sealed record RoomSlotResponse(string Key, string Thing);
private sealed record SubjectInfoResponse(string DefName, string Label, string? Room);
private sealed record DayFrameResponse(
string DefName,
string Label,
string FirstLesson,
int LessonCount,
int LessonMinutes,
int BreakMinutes,
int LongBreakAfter,
int LongBreakMinutes);
private sealed record HolidayInfoResponse(string DefName, string Label);
private sealed record MapLayoutResponse(TerritoryResponse? Territory);
private sealed record TerritoryResponse(string Id, string Def);
}