Merge branch 'review/slice-3-recheck'
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,3 +42,48 @@
|
|||||||
Исправлено:
|
Исправлено:
|
||||||
|
|
||||||
- `docs/protocol.md` не упоминал `?lang=` у hire/subjects, хотя клиент его шлёт.
|
- `docs/protocol.md` не упоминал `?lang=` у hire/subjects, хотя клиент его шлёт.
|
||||||
|
|
||||||
|
## Перепроверка среза 3. Штат
|
||||||
|
|
||||||
|
- **Фазы:** 10–13
|
||||||
|
- **Проверен на:** `a0f0152`, 2026-08-20
|
||||||
|
- **Пути:** `src/HSchool.People/Staffing.cs`, `src/HSchool.People/ApplicantPool.cs`,
|
||||||
|
`src/HSchool.Content/SubjectDef` и `defs/staffing`, `src/HSchool.Server/Api/SchoolEndpoints.cs`
|
||||||
|
(staffing/hire/subjects), `src/HSchool.Server/mods/core/defs/{subjects,positions,rooms,staffing,traits}`,
|
||||||
|
`src/HSchool.Client/src/ui/{managementPanel,mapEditor,applicantsDialog}.ts`,
|
||||||
|
`src/HSchool.Client/src/net/api.ts`, `docs/protocol.md` (staffing),
|
||||||
|
`tests/HSchool.People.Tests/{StaffingTests,ApplicantPoolTests}.cs`,
|
||||||
|
`tests/HSchool.AppHost.Tests/StaffingApiTests.cs`,
|
||||||
|
`tests/HSchool.Content.Tests/VanillaCoreTests.cs`,
|
||||||
|
`src/HSchool.Client/src/ui/{managementPanel,mapEditor}.test.ts`
|
||||||
|
- **Итог:** перепроверка после дрейфа с `bdb1253`. Обещания фаз 10–13 целы. Дописаны 3 теста;
|
||||||
|
в `protocol.md` у соискателя — все навыки, не «три сильных».
|
||||||
|
|
||||||
|
Что подтверждено:
|
||||||
|
|
||||||
|
- `PETeacher` нет; кабинет — `defaultSeats`, без учительской должности; слоты у столовой,
|
||||||
|
медкабинета, кабинета директора и библиотеки. Неизвестный навык роняет каталог.
|
||||||
|
Старт — суббота 31 марта 2012. `GET /api/catalog` отдаёт предметы.
|
||||||
|
- Пул 32, детерминизм по сиду и неделе, частичное обновление, устойчивый размер, навыки и черты
|
||||||
|
в цене часа, пустой штат при полных местах, соискатель вне ростера до найма.
|
||||||
|
- Фонд = цена часа × нагрузка, не ниже ставки; часы делятся; `MaxWeeklyHours` не запрещает
|
||||||
|
назначение. `GET /staffing` читает снимок; наём и назначение — мейлбокс с `TaskCompletionSource`.
|
||||||
|
Предел 100 000 проверяется в момент действия. `PrimarySchool` на ванили — трое (`teachersShort`).
|
||||||
|
- Экран: Обзор / Управление, строка денег, непокрытые, окно пула, штат, назначение с карточки,
|
||||||
|
отказ по пределу текстом, `t(...)`.
|
||||||
|
|
||||||
|
Дрейф с прошлой проверки (не регрессия среза 3):
|
||||||
|
|
||||||
|
- `ApplicantPool` берёт имена страны, не набор имён; новым соискателям назначается ориентация.
|
||||||
|
- Во «Управлении» рядом вкладка правил одежды и речи; пул по-прежнему отдельным окном.
|
||||||
|
|
||||||
|
Дописано:
|
||||||
|
|
||||||
|
- `VanillaCoreTests` — у столовой, медкабинета, кабинета директора и библиотеки слоты на месте.
|
||||||
|
- `mapEditor.test.ts` — у кабинета поле «мест», у столовой именованные слоты, не доска.
|
||||||
|
- `ManagementPanel` — строка денег; назначение и снятие предмета с карточки нанятого.
|
||||||
|
|
||||||
|
Исправлено:
|
||||||
|
|
||||||
|
- `docs/protocol.md` всё ещё писал, что у соискателя в `GET /staffing` «три сильных навыка»;
|
||||||
|
API давно отдаёт все навыки (окно пула сравнивает целиком).
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Срез 1. Оболочка и карта | `d087486` | [01-shell](01-shell/reviewed.md) |
|
| Срез 1. Оболочка и карта | `d087486` | [01-shell](01-shell/reviewed.md) |
|
||||||
| Срез 2. Люди | `8108508` | [02-people](02-people/reviewed.md) |
|
| Срез 2. Люди | `8108508` | [02-people](02-people/reviewed.md) |
|
||||||
| Срез 3. Штат | `bdb1253` | [03-staffing](03-staffing/reviewed.md) |
|
| Срез 3. Штат | `a0f0152` | [03-staffing](03-staffing/reviewed.md) |
|
||||||
| Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) |
|
| Срез 4. Расписание | `01ef277` | [04-schedule](04-schedule/reviewed.md) |
|
||||||
| Срез 5. Жизнь школы | `cbe739a` | [05-ai](05-ai/reviewed.md) |
|
| Срез 5. Жизнь школы | `cbe739a` | [05-ai](05-ai/reviewed.md) |
|
||||||
| Срез 6. Фундамент | `cbe739a` | [06-foundation](06-foundation/reviewed.md) |
|
| Срез 6. Фундамент | `cbe739a` | [06-foundation](06-foundation/reviewed.md) |
|
||||||
|
|||||||
+3
-3
@@ -690,9 +690,9 @@ one.
|
|||||||
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
|
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
|
||||||
id on the roster; hiring them sets `isStaff` on that person and does not create a second
|
id on the roster; hiring them sets `isStaff` on that person and does not create a second
|
||||||
entity. Generated candidates (`aN.p0`) join the roster only when hired. `skills` on an
|
entity. Generated candidates (`aN.p0`) join the roster only when hired. `skills` on an
|
||||||
applicant are the three strongest, for the management list; the card lists every skill they
|
applicant are every skill they have (the applicant window compares the whole row, not a
|
||||||
have, not the three strongest. `positions` and `subjects` are the school's catalog, so the hire and assign pickers
|
top-three summary); the person card lists the same set. `positions` and `subjects` are the
|
||||||
do not need a second request.
|
school's catalog, so the hire and assign pickers do not need a second request.
|
||||||
|
|
||||||
`GET /api/schools/{id}/people/{personId}` also opens a card for someone who is only in the
|
`GET /api/schools/{id}/people/{personId}` also opens a card for someone who is only in the
|
||||||
applicant pool (needs are the frozen snapshot — they are not in the World yet). Unknown
|
applicant pool (needs are the frozen snapshot — they are not in the World yet). Unknown
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
|
assignSubject,
|
||||||
fetchPerson,
|
fetchPerson,
|
||||||
fetchGameStatus,
|
fetchGameStatus,
|
||||||
fetchDressRules,
|
fetchDressRules,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
fetchStaffing,
|
fetchStaffing,
|
||||||
fetchTimetable,
|
fetchTimetable,
|
||||||
hireStaff,
|
hireStaff,
|
||||||
|
unassignSubject,
|
||||||
type PersonCard,
|
type PersonCard,
|
||||||
type Staffing,
|
type Staffing,
|
||||||
type Timetable,
|
type Timetable,
|
||||||
@@ -356,3 +358,151 @@ describe('ManagementPanel rules tab', () => {
|
|||||||
expect(second.listElement.querySelector('.rules')?.hasAttribute('hidden')).toBe(false);
|
expect(second.listElement.querySelector('.rules')?.hasAttribute('hidden')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function hiredTeacher(): Staffing {
|
||||||
|
return {
|
||||||
|
allocated: 10_000,
|
||||||
|
payroll: 4_000,
|
||||||
|
remaining: 6_000,
|
||||||
|
uncovered: [],
|
||||||
|
applicants: [],
|
||||||
|
staff: [
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
fullName: 'Petrov Ivan',
|
||||||
|
female: false,
|
||||||
|
age: 40,
|
||||||
|
isParent: false,
|
||||||
|
position: 'Teacher',
|
||||||
|
positionLabel: 'Teacher',
|
||||||
|
hourlyWageAsk: 50,
|
||||||
|
weeklyHours: 20,
|
||||||
|
monthlyPay: 4_000,
|
||||||
|
subjects: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
positions: [{ defName: 'Teacher', label: 'Teacher' }],
|
||||||
|
subjects: [
|
||||||
|
{
|
||||||
|
defName: 'Mathematics',
|
||||||
|
label: 'Math',
|
||||||
|
gradeMin: 5,
|
||||||
|
gradeMax: 11,
|
||||||
|
hoursPerWeek: 5,
|
||||||
|
teachersShort: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ManagementPanel money and subjects', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
setLocale('en');
|
||||||
|
vi.mocked(fetchStaffing).mockReset();
|
||||||
|
vi.mocked(fetchTimetable).mockReset();
|
||||||
|
vi.mocked(fetchPerson).mockReset();
|
||||||
|
vi.mocked(assignSubject).mockReset();
|
||||||
|
vi.mocked(unassignSubject).mockReset();
|
||||||
|
vi.mocked(fetchStaffing).mockResolvedValue(hiredTeacher());
|
||||||
|
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
||||||
|
vi.mocked(fetchPerson).mockResolvedValue({
|
||||||
|
...personCard(),
|
||||||
|
id: 't1',
|
||||||
|
fullName: 'Petrov Ivan',
|
||||||
|
female: false,
|
||||||
|
roles: ['staff'],
|
||||||
|
position: 'Teacher',
|
||||||
|
positionLabel: 'Teacher',
|
||||||
|
});
|
||||||
|
vi.mocked(fetchGameStatus).mockResolvedValue({
|
||||||
|
tick: 0,
|
||||||
|
tickRate: 20,
|
||||||
|
schools: 1,
|
||||||
|
maxSchools: 6,
|
||||||
|
connections: 0,
|
||||||
|
swarmUiConfigured: false,
|
||||||
|
swarmUiConnected: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
setLocale(initialLocale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows allocated, committed and remaining', async () => {
|
||||||
|
const panel = new ManagementPanel();
|
||||||
|
document.body.append(panel.listElement, panel.cardElement);
|
||||||
|
panel.show(2);
|
||||||
|
await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(10_000)));
|
||||||
|
expect(panel.listElement.textContent).toContain(t('staffAllocated'));
|
||||||
|
expect(panel.listElement.textContent).toContain(t('staffPayroll'));
|
||||||
|
expect(panel.listElement.textContent).toContain(t('staffRemaining'));
|
||||||
|
expect(panel.listElement.textContent).toContain(formatMoney(4_000));
|
||||||
|
expect(panel.listElement.textContent).toContain(formatMoney(6_000));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns and unassigns a subject from the hired card', async () => {
|
||||||
|
const assigned: Staffing = {
|
||||||
|
...hiredTeacher(),
|
||||||
|
payroll: 7_000,
|
||||||
|
remaining: 3_000,
|
||||||
|
staff: [
|
||||||
|
{
|
||||||
|
...hiredTeacher().staff[0],
|
||||||
|
weeklyHours: 35,
|
||||||
|
monthlyPay: 7_000,
|
||||||
|
subjects: [{ defName: 'Mathematics', label: 'Math' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
vi.mocked(assignSubject).mockResolvedValue(assigned);
|
||||||
|
vi.mocked(unassignSubject).mockResolvedValue(hiredTeacher());
|
||||||
|
|
||||||
|
const panel = new ManagementPanel();
|
||||||
|
document.body.append(panel.listElement, panel.cardElement);
|
||||||
|
panel.show(2);
|
||||||
|
const row = await vi.waitFor(() => {
|
||||||
|
const node = panel.listElement.querySelector('tr.people__row');
|
||||||
|
if (!(node instanceof HTMLTableRowElement)) {
|
||||||
|
throw new Error('staff row is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
row.click();
|
||||||
|
const assign = await vi.waitFor(() => {
|
||||||
|
const button = [...panel.cardElement.querySelectorAll('button')].find(
|
||||||
|
(candidate) => candidate.textContent === t('staffAssign'),
|
||||||
|
);
|
||||||
|
if (!(button instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error('assign button is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
|
||||||
|
assign.click();
|
||||||
|
await vi.waitFor(() => expect(assignSubject).toHaveBeenCalledWith(2, 't1', 'Mathematics', 'en'));
|
||||||
|
await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(3_000)));
|
||||||
|
|
||||||
|
const unassign = await vi.waitFor(() => {
|
||||||
|
const button = [...panel.cardElement.querySelectorAll('button')].find(
|
||||||
|
(candidate) => candidate.textContent === t('staffUnassign'),
|
||||||
|
);
|
||||||
|
if (!(button instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error('unassign button is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
});
|
||||||
|
|
||||||
|
unassign.click();
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(unassignSubject).toHaveBeenCalledWith(2, 't1', 'Mathematics', 'en'),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(panel.listElement.textContent).toContain(formatMoney(6_000)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import type { CatalogResponse, MapLayout } from '../net/api.ts';
|
||||||
|
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||||
|
import { t } from '../i18n/strings.ts';
|
||||||
|
import { mapEditor } from './mapEditor.ts';
|
||||||
|
|
||||||
|
const initialLocale = getLocale();
|
||||||
|
|
||||||
|
function catalog(): CatalogResponse {
|
||||||
|
return {
|
||||||
|
territories: [{ defName: 'Yard', label: 'Yard' }],
|
||||||
|
buildings: [{ defName: 'SchoolBuilding', label: 'Building' }],
|
||||||
|
floors: [{ defName: 'Floor', label: 'Floor' }],
|
||||||
|
rooms: [
|
||||||
|
{
|
||||||
|
defName: 'Classroom',
|
||||||
|
label: 'Classroom',
|
||||||
|
homeroom: true,
|
||||||
|
seatThing: 'StudentDesk',
|
||||||
|
defaultSeats: 16,
|
||||||
|
slots: [],
|
||||||
|
positions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defName: 'Cafeteria',
|
||||||
|
label: 'Cafeteria',
|
||||||
|
homeroom: false,
|
||||||
|
defaultSeats: 0,
|
||||||
|
slots: [
|
||||||
|
{ key: 'counter', thing: 'DiningTable', count: 1 },
|
||||||
|
{ key: 'seats', thing: 'Chair', count: 8 },
|
||||||
|
],
|
||||||
|
positions: ['CafeteriaCook'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
things: [
|
||||||
|
{ defName: 'DiningTable', label: 'Table' },
|
||||||
|
{ defName: 'Chair', label: 'Chair' },
|
||||||
|
],
|
||||||
|
defaultMap: map(),
|
||||||
|
countries: [],
|
||||||
|
subjects: [],
|
||||||
|
dayFrame: null,
|
||||||
|
holidays: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function map(): MapLayout {
|
||||||
|
return {
|
||||||
|
territory: { id: 'yard', def: 'Yard' },
|
||||||
|
buildings: [{ id: 'b1', def: 'SchoolBuilding' }],
|
||||||
|
floors: [{ id: 'f1', def: 'Floor', building: 'b1' }],
|
||||||
|
rooms: [
|
||||||
|
{ id: 'r-class', def: 'Classroom', building: 'b1', floor: 'f1', seats: 16 },
|
||||||
|
{
|
||||||
|
id: 'r-cafe',
|
||||||
|
def: 'Cafeteria',
|
||||||
|
building: 'b1',
|
||||||
|
floor: 'f1',
|
||||||
|
slots: [
|
||||||
|
{ key: 'counter', thing: 'DiningTable', count: 1 },
|
||||||
|
{ key: 'seats', thing: 'Chair', count: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ a: 'yard', b: 'b1' },
|
||||||
|
{ a: 'b1', b: 'f1' },
|
||||||
|
{ a: 'f1', b: 'r-class' },
|
||||||
|
{ a: 'f1', b: 'r-cafe' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function furnishing(root: HTMLElement): HTMLElement {
|
||||||
|
const section = [...root.querySelectorAll('.editor-section')].find((node) =>
|
||||||
|
(node.querySelector('.editor-section__title')?.textContent ?? '').startsWith(t('editorSlots')),
|
||||||
|
);
|
||||||
|
if (!(section instanceof HTMLElement)) {
|
||||||
|
throw new Error('furnishing section is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickRoom(root: HTMLElement, name: string): void {
|
||||||
|
const button = [...root.querySelectorAll('.tree__button')].find(
|
||||||
|
(candidate) => candidate.textContent === name,
|
||||||
|
);
|
||||||
|
if (!(button instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error(`${name} is missing from the tree`);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mapEditor homeroom seats', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
setLocale(initialLocale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a seats field on a classroom and named slots on a cafeteria', () => {
|
||||||
|
setLocale('en');
|
||||||
|
const editor = mapEditor({ catalog: catalog(), map: map(), onChange: () => {} });
|
||||||
|
document.body.append(editor.element);
|
||||||
|
|
||||||
|
clickRoom(editor.element, 'Classroom');
|
||||||
|
const classroom = furnishing(editor.element);
|
||||||
|
expect(classroom.textContent).toContain(t('editorSeats'));
|
||||||
|
expect(classroom.querySelector('input.input--count')).not.toBeNull();
|
||||||
|
expect(classroom.querySelector('select')).toBeNull();
|
||||||
|
|
||||||
|
clickRoom(editor.element, 'Cafeteria');
|
||||||
|
const cafeteria = furnishing(editor.element);
|
||||||
|
expect(cafeteria.textContent).not.toContain(t('editorSeats'));
|
||||||
|
expect(cafeteria.querySelectorAll('select').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,10 @@ public class VanillaCoreTests
|
|||||||
Assert.Equal("StudentDesk", catalog.Rooms["Classroom"].SeatThing);
|
Assert.Equal("StudentDesk", catalog.Rooms["Classroom"].SeatThing);
|
||||||
Assert.Equal(16, catalog.Rooms["Classroom"].DefaultSeats);
|
Assert.Equal(16, catalog.Rooms["Classroom"].DefaultSeats);
|
||||||
Assert.Empty(catalog.Rooms["Classroom"].Slots);
|
Assert.Empty(catalog.Rooms["Classroom"].Slots);
|
||||||
|
Assert.NotEmpty(catalog.Rooms["Cafeteria"].Slots);
|
||||||
|
Assert.NotEmpty(catalog.Rooms["MedicalOffice"].Slots);
|
||||||
|
Assert.NotEmpty(catalog.Rooms["PrincipalsOffice"].Slots);
|
||||||
|
Assert.NotEmpty(catalog.Rooms["Library"].Slots);
|
||||||
Assert.Equal(1, catalog.Things["StudentDesk"].PupilSlots);
|
Assert.Equal(1, catalog.Things["StudentDesk"].PupilSlots);
|
||||||
Assert.Equal(1, catalog.Things["Computer"].PupilSlots);
|
Assert.Equal(1, catalog.Things["Computer"].PupilSlots);
|
||||||
Assert.Equal(0, catalog.Things["Desk"].PupilSlots);
|
Assert.Equal(0, catalog.Things["Desk"].PupilSlots);
|
||||||
|
|||||||
Reference in New Issue
Block a user