Merge branch 'phase/37-school-rules'
This commit is contained in:
@@ -12,24 +12,24 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим
|
- [x] В «Управлении» вкладка **«Правила»** рядом со штатом, не новый верхний режим
|
||||||
- [ ] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников.
|
- [x] Четыре комбобокса: форма учеников, цвет учеников, форма сотрудников, цвет сотрудников.
|
||||||
Значения — как в [`../design/inventory.md`](../design/inventory.md)
|
Значения — как в [`../design/inventory.md`](../design/inventory.md)
|
||||||
- [ ] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что
|
- [x] Сохранение пишет завтрашнее правило; на экране видно, что действует сегодня и что
|
||||||
вступит завтра
|
вступит завтра
|
||||||
- [ ] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила —
|
- [x] HTTP: чтение текущих+отложенных, запись отложенных. Отказ по неизвестному коду правила —
|
||||||
`400`, не тишина
|
`400`, не тишина
|
||||||
- [ ] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него
|
- [x] На следующее рабочее утро отложенное становится текущим; фаза 35 уже одевает от него
|
||||||
- [ ] Строки через `t(...)`, обе локали
|
- [x] Строки через `t(...)`, обе локали
|
||||||
- [ ] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом
|
- [x] Vitest: смена комбобокса не зовёт API до подтверждения; ошибка API видна текстом
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] GET правил на новой школе — дефолт обычная / без яркого на обе роли
|
- [x] GET правил на новой школе — дефолт обычная / без яркого на обе роли
|
||||||
- [ ] POST «строгая» сегодня не меняет одежду до следующего рабочего утра
|
- [x] POST «строгая» сегодня не меняет одежду до следующего рабочего утра
|
||||||
- [ ] После утра на теле учеников нет яркого, если отложили «без яркого»
|
- [x] После утра на теле учеников нет яркого, если отложили «без яркого»
|
||||||
- [ ] Неизвестный код политики — `400`
|
- [x] Неизвестный код политики — `400`
|
||||||
- [ ] Вкладка не ломает наём и сетку расписания
|
- [x] Вкладка не ломает наём и сетку расписания
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
| [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят |
|
| [34. Износ и дом](34-wear-home.md) | ✅ | Прочность, утренняя замена, из ветхого не выходят |
|
||||||
| [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура |
|
| [35. Уместность](35-appropriateness.md) | ✅ | Правила, переодевание действием, физкультура |
|
||||||
| [36. Вкладки карточки](36-person-card-tabs.md) | ✅ | Одежда, ноша, сейчас + история за сегодня |
|
| [36. Вкладки карточки](36-person-card-tabs.md) | ✅ | Одежда, ноша, сейчас + история за сегодня |
|
||||||
| [37. Правила школы](37-school-rules.md) | 🔄 | Вкладка в «Управлении», четыре комбобокса, с завтра |
|
| [37. Правила школы](37-school-rules.md) | ✅ | Вкладка в «Управлении», четыре комбобокса, с завтра |
|
||||||
|
|
||||||
34 стоит на 31; 35 — на 32–34; 36 — на 31 и может идти параллельно с 35 в части вкладок одежды,
|
34 стоит на 31; 35 — на 32–34; 36 — на 31 и может идти параллельно с 35 в части вкладок одежды,
|
||||||
история — после 35; 37 стоит на 35 и 36.
|
история — после 35; 37 стоит на 35 и 36.
|
||||||
|
|||||||
@@ -270,6 +270,31 @@ const ru = {
|
|||||||
staffErrorAssigned: 'Этот предмет уже назначен.',
|
staffErrorAssigned: 'Этот предмет уже назначен.',
|
||||||
staffErrorSubject: 'Такого предмета нет.',
|
staffErrorSubject: 'Такого предмета нет.',
|
||||||
|
|
||||||
|
manageTabStaff: 'Штат',
|
||||||
|
manageTabRules: 'Правила',
|
||||||
|
rulesCurrentTitle: 'Сегодня',
|
||||||
|
rulesPendingTitle: 'С завтра',
|
||||||
|
rulesPendingHint: 'Изменения вступают в силу на следующее рабочее утро.',
|
||||||
|
rulesStudentsRole: 'Ученики',
|
||||||
|
rulesStaffRole: 'Сотрудники',
|
||||||
|
rulesStudentsForm: 'Форма учеников',
|
||||||
|
rulesStudentsColor: 'Цвет учеников',
|
||||||
|
rulesStaffForm: 'Форма сотрудников',
|
||||||
|
rulesStaffColor: 'Цвет сотрудников',
|
||||||
|
rulesRoleSummary: '{role}: {form}, {color}',
|
||||||
|
rulesApply: 'Применить',
|
||||||
|
rulesLoadFailed: 'Не удалось загрузить правила одежды.',
|
||||||
|
rulesSaveFailed: 'Не удалось сохранить правила.',
|
||||||
|
rulesPickHint: 'Правила действуют на всю школу.',
|
||||||
|
dressFormRegular: 'обычная',
|
||||||
|
dressFormShort: 'короткая',
|
||||||
|
dressFormStrict: 'строгая',
|
||||||
|
dressColorFree: 'свободно',
|
||||||
|
dressColorNoBright: 'без яркого',
|
||||||
|
dressColorWhiteTopBlackBottom: 'белый верх, чёрный низ',
|
||||||
|
dressErrorUnknownForm: 'Такой формы нет.',
|
||||||
|
dressErrorUnknownColor: 'Такой цветовой политики нет.',
|
||||||
|
|
||||||
mapOccupancy: '{name} ({activity})',
|
mapOccupancy: '{name} ({activity})',
|
||||||
mapHeadcount: '{name} ({count})',
|
mapHeadcount: '{name} ({count})',
|
||||||
mapHeadcountActivity: '{name} ({count} · {activity})',
|
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||||
@@ -574,6 +599,31 @@ const en: Messages = {
|
|||||||
staffErrorAssigned: 'That subject is already assigned.',
|
staffErrorAssigned: 'That subject is already assigned.',
|
||||||
staffErrorSubject: 'That subject is not in the catalog.',
|
staffErrorSubject: 'That subject is not in the catalog.',
|
||||||
|
|
||||||
|
manageTabStaff: 'Staff',
|
||||||
|
manageTabRules: 'Rules',
|
||||||
|
rulesCurrentTitle: 'Today',
|
||||||
|
rulesPendingTitle: 'From tomorrow',
|
||||||
|
rulesPendingHint: 'Changes take effect on the next work morning.',
|
||||||
|
rulesStudentsRole: 'Students',
|
||||||
|
rulesStaffRole: 'Staff',
|
||||||
|
rulesStudentsForm: 'Student form',
|
||||||
|
rulesStudentsColor: 'Student colours',
|
||||||
|
rulesStaffForm: 'Staff form',
|
||||||
|
rulesStaffColor: 'Staff colours',
|
||||||
|
rulesRoleSummary: '{role}: {form}, {color}',
|
||||||
|
rulesApply: 'Apply',
|
||||||
|
rulesLoadFailed: 'Could not load dress rules.',
|
||||||
|
rulesSaveFailed: 'Could not save the rules.',
|
||||||
|
rulesPickHint: 'Rules apply to the whole school.',
|
||||||
|
dressFormRegular: 'regular',
|
||||||
|
dressFormShort: 'short',
|
||||||
|
dressFormStrict: 'strict',
|
||||||
|
dressColorFree: 'free colours',
|
||||||
|
dressColorNoBright: 'no bright colours',
|
||||||
|
dressColorWhiteTopBlackBottom: 'white top, black bottom',
|
||||||
|
dressErrorUnknownForm: 'That form policy is not in the catalog.',
|
||||||
|
dressErrorUnknownColor: 'That colour policy is not in the catalog.',
|
||||||
|
|
||||||
mapOccupancy: '{name} ({activity})',
|
mapOccupancy: '{name} ({activity})',
|
||||||
mapHeadcount: '{name} ({count})',
|
mapHeadcount: '{name} ({count})',
|
||||||
mapHeadcountActivity: '{name} ({count} · {activity})',
|
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||||
|
|||||||
@@ -643,6 +643,33 @@ export async function unassignSubject(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DressRulePair {
|
||||||
|
readonly form: string;
|
||||||
|
readonly color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DressRules {
|
||||||
|
readonly students: DressRulePair;
|
||||||
|
readonly staff: DressRulePair;
|
||||||
|
readonly pendingStudents: DressRulePair | null;
|
||||||
|
readonly pendingStaff: DressRulePair | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDressRules(schoolId: number): Promise<DressRules> {
|
||||||
|
return request<DressRules>(`/api/schools/${schoolId}/dress-rules`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDressRules(
|
||||||
|
schoolId: number,
|
||||||
|
body: { readonly students?: DressRulePair; readonly staff?: DressRulePair },
|
||||||
|
): Promise<DressRules> {
|
||||||
|
return request<DressRules>(`/api/schools/${schoolId}/dress-rules`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface TimetableLesson {
|
export interface TimetableLesson {
|
||||||
readonly classId: string;
|
readonly classId: string;
|
||||||
readonly classYear: number;
|
readonly classYear: number;
|
||||||
|
|||||||
@@ -1047,6 +1047,21 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rules__summary {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules__fieldset {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules__fieldset .people__field + .people__field {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.staffing__badge {
|
.staffing__badge {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchDressRules,
|
||||||
|
setDressRules,
|
||||||
|
type DressRules,
|
||||||
|
} from '../net/api.ts';
|
||||||
|
import { getLocale, setLocale } from '../i18n/locale.ts';
|
||||||
|
import { t } from '../i18n/strings.ts';
|
||||||
|
import { DressRulesPanel } from './dressRulesPanel.ts';
|
||||||
|
|
||||||
|
vi.mock('../net/api.ts', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../net/api.ts')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchDressRules: vi.fn(),
|
||||||
|
setDressRules: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialLocale = getLocale();
|
||||||
|
|
||||||
|
function rules(): DressRules {
|
||||||
|
return {
|
||||||
|
students: { form: 'regular', color: 'noBright' },
|
||||||
|
staff: { form: 'regular', color: 'noBright' },
|
||||||
|
pendingStudents: null,
|
||||||
|
pendingStaff: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DressRulesPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setLocale('en');
|
||||||
|
vi.mocked(fetchDressRules).mockReset();
|
||||||
|
vi.mocked(setDressRules).mockReset();
|
||||||
|
vi.mocked(fetchDressRules).mockResolvedValue(rules());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
setLocale(initialLocale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call save when only a combobox changes', async () => {
|
||||||
|
const panel = new DressRulesPanel();
|
||||||
|
document.body.append(panel.element);
|
||||||
|
panel.show(3);
|
||||||
|
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const colorSelect = panel.element.querySelector('select.input');
|
||||||
|
if (!(colorSelect instanceof HTMLSelectElement)) {
|
||||||
|
throw new Error('student colour select is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
colorSelect.value = 'free';
|
||||||
|
colorSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
expect(setDressRules).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an API error as text after apply', async () => {
|
||||||
|
vi.mocked(setDressRules).mockRejectedValue(new ApiError(400, 'unknown-form', 'bad form'));
|
||||||
|
const panel = new DressRulesPanel();
|
||||||
|
document.body.append(panel.element);
|
||||||
|
panel.show(3);
|
||||||
|
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const formSelect = panel.element.querySelector('fieldset select.input');
|
||||||
|
if (!(formSelect instanceof HTMLSelectElement)) {
|
||||||
|
throw new Error('student form select is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
formSelect.value = 'strict';
|
||||||
|
const apply = panel.element.querySelector('button.button');
|
||||||
|
if (!(apply instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error('apply button is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
apply.click();
|
||||||
|
const banner = panel.element.querySelector('.staffing__error');
|
||||||
|
if (!(banner instanceof HTMLElement)) {
|
||||||
|
throw new Error('error banner is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(banner.hidden).toBe(false));
|
||||||
|
expect(banner.textContent).toBe(t('dressErrorUnknownForm'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts';
|
||||||
|
import { t } from '../i18n/strings.ts';
|
||||||
|
import { clear, el } from './dom.ts';
|
||||||
|
import {
|
||||||
|
dressColorLabel,
|
||||||
|
dressColorOptions,
|
||||||
|
dressFormLabel,
|
||||||
|
dressFormOptions,
|
||||||
|
dressRulesError,
|
||||||
|
} from './dressRulesUi.ts';
|
||||||
|
import { fillSelect } from './staffingUi.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* School-wide dress code. Edits queue for the next work morning; today's rules stay visible.
|
||||||
|
*/
|
||||||
|
export class DressRulesPanel {
|
||||||
|
readonly element: HTMLElement;
|
||||||
|
|
||||||
|
private readonly error = el('p', { class: 'staffing__error' });
|
||||||
|
private readonly currentTitle = el('h3', { class: 'panel__section-title' });
|
||||||
|
private readonly currentStudents = el('p', { class: 'rules__summary' });
|
||||||
|
private readonly currentStaff = el('p', { class: 'rules__summary' });
|
||||||
|
private readonly pendingTitle = el('h3', { class: 'panel__section-title' });
|
||||||
|
private readonly pendingHint = el('p', { class: 'panel__meta' });
|
||||||
|
private readonly studentsFormLabel = el('span', { class: 'people__label' });
|
||||||
|
private readonly studentsFormSelect = el('select', { class: 'input people__input' });
|
||||||
|
private readonly studentsColorLabel = el('span', { class: 'people__label' });
|
||||||
|
private readonly studentsColorSelect = el('select', { class: 'input people__input' });
|
||||||
|
private readonly staffFormLabel = el('span', { class: 'people__label' });
|
||||||
|
private readonly staffFormSelect = el('select', { class: 'input people__input' });
|
||||||
|
private readonly staffColorLabel = el('span', { class: 'people__label' });
|
||||||
|
private readonly staffColorSelect = el('select', { class: 'input people__input' });
|
||||||
|
private readonly applyButton = el('button', { class: 'button', type: 'button' });
|
||||||
|
|
||||||
|
private readonly studentsLegend: HTMLElement;
|
||||||
|
private readonly staffLegend: HTMLElement;
|
||||||
|
|
||||||
|
private schoolId: number | null = null;
|
||||||
|
private rules: DressRules | null = null;
|
||||||
|
private loadToken = 0;
|
||||||
|
private busy = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.error.hidden = true;
|
||||||
|
this.applyButton.addEventListener('click', () => void this.apply());
|
||||||
|
|
||||||
|
this.element = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'panel__body rules', hidden: true },
|
||||||
|
this.error,
|
||||||
|
el('div', { class: 'panel__section' }, this.currentTitle, this.currentStudents, this.currentStaff),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'panel__section' },
|
||||||
|
this.pendingTitle,
|
||||||
|
this.pendingHint,
|
||||||
|
el(
|
||||||
|
'fieldset',
|
||||||
|
{ class: 'rules__fieldset' },
|
||||||
|
el('legend', { class: 'people__section-title', text: '' }),
|
||||||
|
el(
|
||||||
|
'label',
|
||||||
|
{ class: 'people__field' },
|
||||||
|
this.studentsFormLabel,
|
||||||
|
this.studentsFormSelect,
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
'label',
|
||||||
|
{ class: 'people__field' },
|
||||||
|
this.studentsColorLabel,
|
||||||
|
this.studentsColorSelect,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
'fieldset',
|
||||||
|
{ class: 'rules__fieldset' },
|
||||||
|
el('legend', { class: 'people__section-title', text: '' }),
|
||||||
|
el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect),
|
||||||
|
el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect),
|
||||||
|
),
|
||||||
|
this.applyButton,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const legends = this.element.querySelectorAll('fieldset.rules__fieldset > legend');
|
||||||
|
const studentsLegend = legends.item(0);
|
||||||
|
const staffLegend = legends.item(1);
|
||||||
|
if (!(studentsLegend instanceof HTMLElement) || !(staffLegend instanceof HTMLElement)) {
|
||||||
|
throw new Error('rules fieldset legends are missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.studentsLegend = studentsLegend;
|
||||||
|
this.staffLegend = staffLegend;
|
||||||
|
this.localize();
|
||||||
|
}
|
||||||
|
|
||||||
|
localize(): void {
|
||||||
|
this.currentTitle.textContent = t('rulesCurrentTitle');
|
||||||
|
this.pendingTitle.textContent = t('rulesPendingTitle');
|
||||||
|
this.pendingHint.textContent = t('rulesPendingHint');
|
||||||
|
this.studentsFormLabel.textContent = t('rulesStudentsForm');
|
||||||
|
this.studentsColorLabel.textContent = t('rulesStudentsColor');
|
||||||
|
this.staffFormLabel.textContent = t('rulesStaffForm');
|
||||||
|
this.staffColorLabel.textContent = t('rulesStaffColor');
|
||||||
|
this.applyButton.textContent = t('rulesApply');
|
||||||
|
this.studentsLegend.textContent = t('rulesStudentsRole');
|
||||||
|
this.staffLegend.textContent = t('rulesStaffRole');
|
||||||
|
fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value);
|
||||||
|
fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value);
|
||||||
|
fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value);
|
||||||
|
fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value);
|
||||||
|
this.paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
show(schoolId: number): void {
|
||||||
|
if (this.schoolId !== schoolId) {
|
||||||
|
this.schoolId = schoolId;
|
||||||
|
this.rules = null;
|
||||||
|
this.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {
|
||||||
|
this.element.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reveal(): void {
|
||||||
|
this.element.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reload(): Promise<void> {
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
if (schoolId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = ++this.loadToken;
|
||||||
|
try {
|
||||||
|
const rules = await fetchDressRules(schoolId);
|
||||||
|
if (token !== this.loadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rules = rules;
|
||||||
|
this.paint();
|
||||||
|
} catch {
|
||||||
|
if (token !== this.loadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showError(t('rulesLoadFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private paint(): void {
|
||||||
|
const rules = this.rules;
|
||||||
|
this.applyButton.disabled = this.busy || rules === null;
|
||||||
|
|
||||||
|
if (rules === null) {
|
||||||
|
this.currentStudents.textContent = '—';
|
||||||
|
this.currentStaff.textContent = '—';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentStudents.textContent = t('rulesRoleSummary', {
|
||||||
|
role: t('rulesStudentsRole'),
|
||||||
|
form: dressFormLabel(rules.students.form),
|
||||||
|
color: dressColorLabel(rules.students.color),
|
||||||
|
});
|
||||||
|
this.currentStaff.textContent = t('rulesRoleSummary', {
|
||||||
|
role: t('rulesStaffRole'),
|
||||||
|
form: dressFormLabel(rules.staff.form),
|
||||||
|
color: dressColorLabel(rules.staff.color),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingStudents = rules.pendingStudents ?? rules.students;
|
||||||
|
const pendingStaff = rules.pendingStaff ?? rules.staff;
|
||||||
|
fillSelect(this.studentsFormSelect, dressFormOptions(), pendingStudents.form);
|
||||||
|
fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color);
|
||||||
|
fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form);
|
||||||
|
fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async apply(): Promise<void> {
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
const rules = this.rules;
|
||||||
|
if (schoolId === null || rules === null || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const students = this.pair(this.studentsFormSelect, this.studentsColorSelect);
|
||||||
|
const staff = this.pair(this.staffFormSelect, this.staffColorSelect);
|
||||||
|
const body: { students?: DressRulePair; staff?: DressRulePair } = {};
|
||||||
|
if (!samePair(students, rules.pendingStudents ?? rules.students)) {
|
||||||
|
body.students = students;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!samePair(staff, rules.pendingStaff ?? rules.staff)) {
|
||||||
|
body.staff = staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.students === undefined && body.staff === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = true;
|
||||||
|
this.clearError();
|
||||||
|
this.paint();
|
||||||
|
try {
|
||||||
|
this.rules = await setDressRules(schoolId, body);
|
||||||
|
this.paint();
|
||||||
|
} catch (error) {
|
||||||
|
this.showError(dressRulesError(error));
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
this.paint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pair(formSelect: HTMLSelectElement, colorSelect: HTMLSelectElement): DressRulePair {
|
||||||
|
return { form: formSelect.value, color: colorSelect.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
private showError(message: string): void {
|
||||||
|
this.error.hidden = false;
|
||||||
|
this.error.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearError(): void {
|
||||||
|
this.error.hidden = true;
|
||||||
|
this.error.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function samePair(left: DressRulePair, right: DressRulePair): boolean {
|
||||||
|
return left.form === right.form && left.color === right.color;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ApiError } from '../net/api.ts';
|
||||||
|
import { t } from '../i18n/strings.ts';
|
||||||
|
|
||||||
|
export const DRESS_FORMS = ['regular', 'short', 'strict'] as const;
|
||||||
|
export const DRESS_COLORS = ['free', 'noBright', 'whiteTopBlackBottom'] as const;
|
||||||
|
|
||||||
|
export type DressForm = (typeof DRESS_FORMS)[number];
|
||||||
|
export type DressColor = (typeof DRESS_COLORS)[number];
|
||||||
|
|
||||||
|
export function dressFormOptions(): readonly { value: DressForm; label: string }[] {
|
||||||
|
return DRESS_FORMS.map((value) => ({ value, label: dressFormLabel(value) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dressColorOptions(): readonly { value: DressColor; label: string }[] {
|
||||||
|
return DRESS_COLORS.map((value) => ({ value, label: dressColorLabel(value) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dressFormLabel(form: string): string {
|
||||||
|
switch (form) {
|
||||||
|
case 'regular':
|
||||||
|
return t('dressFormRegular');
|
||||||
|
case 'short':
|
||||||
|
return t('dressFormShort');
|
||||||
|
case 'strict':
|
||||||
|
return t('dressFormStrict');
|
||||||
|
default:
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dressColorLabel(color: string): string {
|
||||||
|
switch (color) {
|
||||||
|
case 'free':
|
||||||
|
return t('dressColorFree');
|
||||||
|
case 'noBright':
|
||||||
|
return t('dressColorNoBright');
|
||||||
|
case 'whiteTopBlackBottom':
|
||||||
|
return t('dressColorWhiteTopBlackBottom');
|
||||||
|
default:
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dressRulesError(error: unknown): string {
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
return t('rulesSaveFailed');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (error.code) {
|
||||||
|
case 'unknown-form':
|
||||||
|
return t('dressErrorUnknownForm');
|
||||||
|
case 'unknown-color':
|
||||||
|
return t('dressErrorUnknownColor');
|
||||||
|
default:
|
||||||
|
return error.message.length > 0 ? error.message : t('rulesSaveFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
fetchPerson,
|
fetchPerson,
|
||||||
fetchGameStatus,
|
fetchGameStatus,
|
||||||
|
fetchDressRules,
|
||||||
fetchStaffing,
|
fetchStaffing,
|
||||||
fetchTimetable,
|
fetchTimetable,
|
||||||
hireStaff,
|
hireStaff,
|
||||||
@@ -24,6 +25,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
fetchStaffing: vi.fn(),
|
fetchStaffing: vi.fn(),
|
||||||
fetchTimetable: vi.fn(),
|
fetchTimetable: vi.fn(),
|
||||||
|
fetchDressRules: vi.fn(),
|
||||||
fetchPerson: vi.fn(),
|
fetchPerson: vi.fn(),
|
||||||
fetchGameStatus: vi.fn().mockResolvedValue({
|
fetchGameStatus: vi.fn().mockResolvedValue({
|
||||||
tick: 0,
|
tick: 0,
|
||||||
@@ -215,6 +217,7 @@ describe('ManagementPanel uncovered', () => {
|
|||||||
setLocale('en');
|
setLocale('en');
|
||||||
vi.mocked(fetchStaffing).mockReset();
|
vi.mocked(fetchStaffing).mockReset();
|
||||||
vi.mocked(fetchTimetable).mockReset();
|
vi.mocked(fetchTimetable).mockReset();
|
||||||
|
vi.mocked(fetchDressRules).mockReset();
|
||||||
vi.mocked(fetchStaffing).mockResolvedValue({
|
vi.mocked(fetchStaffing).mockResolvedValue({
|
||||||
...staffing(),
|
...staffing(),
|
||||||
applicants: [],
|
applicants: [],
|
||||||
@@ -230,6 +233,12 @@ describe('ManagementPanel uncovered', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
||||||
|
vi.mocked(fetchDressRules).mockResolvedValue({
|
||||||
|
students: { form: 'regular', color: 'noBright' },
|
||||||
|
staff: { form: 'regular', color: 'noBright' },
|
||||||
|
pendingStudents: null,
|
||||||
|
pendingStaff: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -248,3 +257,55 @@ describe('ManagementPanel uncovered', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ManagementPanel rules tab', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setLocale('en');
|
||||||
|
vi.mocked(fetchStaffing).mockReset();
|
||||||
|
vi.mocked(fetchTimetable).mockReset();
|
||||||
|
vi.mocked(fetchDressRules).mockReset();
|
||||||
|
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
|
||||||
|
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
|
||||||
|
vi.mocked(fetchDressRules).mockResolvedValue({
|
||||||
|
students: { form: 'regular', color: 'noBright' },
|
||||||
|
staff: { form: 'regular', color: 'noBright' },
|
||||||
|
pendingStudents: null,
|
||||||
|
pendingStaff: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
setLocale(initialLocale);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps staffing and the timetable grid after visiting rules', async () => {
|
||||||
|
const panel = new ManagementPanel();
|
||||||
|
document.body.append(panel.listElement, panel.cardElement);
|
||||||
|
panel.show(2);
|
||||||
|
await vi.waitFor(() => expect(fetchStaffing).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const rulesTab = [...panel.listElement.querySelectorAll('.panel__tab')].find(
|
||||||
|
(button) => button.textContent === t('manageTabRules'),
|
||||||
|
);
|
||||||
|
if (!(rulesTab instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error('rules tab is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
rulesTab.click();
|
||||||
|
expect(panel.listElement.querySelector('.staffing')).toBeNull();
|
||||||
|
|
||||||
|
const staffTab = [...panel.listElement.querySelectorAll('.panel__tab')].find(
|
||||||
|
(button) => button.textContent === t('manageTabStaff'),
|
||||||
|
);
|
||||||
|
if (!(staffTab instanceof HTMLButtonElement)) {
|
||||||
|
throw new Error('staff tab is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
staffTab.click();
|
||||||
|
await vi.waitFor(() => expect(panel.listElement.querySelector('.staffing')).not.toBeNull());
|
||||||
|
expect(panel.listElement.textContent).toContain(t('staffHired'));
|
||||||
|
expect(panel.listElement.textContent).toContain(t('timetableTitle'));
|
||||||
|
expect(panel.listElement.querySelector('.timetable__grid')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { getLocale } from '../i18n/locale.ts';
|
import { getLocale } from '../i18n/locale.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
import { ApplicantsDialog } from './applicantsDialog.ts';
|
import { ApplicantsDialog } from './applicantsDialog.ts';
|
||||||
|
import { DressRulesPanel } from './dressRulesPanel.ts';
|
||||||
import { clear, el } from './dom.ts';
|
import { clear, el } from './dom.ts';
|
||||||
import { formatPersonPlace } from './personCard.ts';
|
import { formatPersonPlace } from './personCard.ts';
|
||||||
import { PersonCardHost } from './personCardHost.ts';
|
import { PersonCardHost } from './personCardHost.ts';
|
||||||
@@ -28,6 +29,10 @@ export class ManagementPanel {
|
|||||||
readonly listElement: HTMLElement;
|
readonly listElement: HTMLElement;
|
||||||
readonly cardElement: HTMLElement;
|
readonly cardElement: HTMLElement;
|
||||||
|
|
||||||
|
private readonly staffTab = el('button', { class: 'panel__tab', type: 'button' });
|
||||||
|
private readonly rulesTab = el('button', { class: 'panel__tab', type: 'button' });
|
||||||
|
private readonly staffPane = el('div', { class: 'panel__body staffing' });
|
||||||
|
private readonly rulesPanel = new DressRulesPanel();
|
||||||
private readonly error = el('p', { class: 'staffing__error' });
|
private readonly error = el('p', { class: 'staffing__error' });
|
||||||
private readonly money = el('dl', { class: 'staffing__money' });
|
private readonly money = el('dl', { class: 'staffing__money' });
|
||||||
private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' });
|
private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' });
|
||||||
@@ -65,13 +70,14 @@ export class ManagementPanel {
|
|||||||
private locate: ((id: string) => string) | null = null;
|
private locate: ((id: string) => string) | null = null;
|
||||||
private poolDialog: ApplicantsDialog | null = null;
|
private poolDialog: ApplicantsDialog | null = null;
|
||||||
private readonly cardHost = new PersonCardHost();
|
private readonly cardHost = new PersonCardHost();
|
||||||
|
private manageTab: 'staff' | 'rules' = 'staff';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.error.hidden = true;
|
this.error.hidden = true;
|
||||||
this.applicantsButton.addEventListener('click', () => void this.openApplicants());
|
this.applicantsButton.addEventListener('click', () => void this.openApplicants());
|
||||||
this.listElement = el(
|
this.staffTab.addEventListener('click', () => this.showManageTab('staff'));
|
||||||
'div',
|
this.rulesTab.addEventListener('click', () => this.showManageTab('rules'));
|
||||||
{ class: 'panel__body staffing' },
|
this.staffPane.append(
|
||||||
this.error,
|
this.error,
|
||||||
this.money,
|
this.money,
|
||||||
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
|
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
|
||||||
@@ -96,6 +102,13 @@ export class ManagementPanel {
|
|||||||
this.timetableGrid.element,
|
this.timetableGrid.element,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
this.listElement = el(
|
||||||
|
'div',
|
||||||
|
{},
|
||||||
|
el('div', { class: 'panel__tabs' }, this.staffTab, this.rulesTab),
|
||||||
|
this.staffPane,
|
||||||
|
this.rulesPanel.element,
|
||||||
|
);
|
||||||
this.cardElement = this.card;
|
this.cardElement = this.card;
|
||||||
this.classSelect.addEventListener('change', () => {
|
this.classSelect.addEventListener('change', () => {
|
||||||
this.classId = this.classSelect.value || null;
|
this.classId = this.classSelect.value || null;
|
||||||
@@ -105,6 +118,8 @@ export class ManagementPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localize(): void {
|
localize(): void {
|
||||||
|
this.staffTab.textContent = t('manageTabStaff');
|
||||||
|
this.rulesTab.textContent = t('manageTabRules');
|
||||||
this.uncoveredTitle.textContent = t('staffUncovered');
|
this.uncoveredTitle.textContent = t('staffUncovered');
|
||||||
this.applicantsTitle.textContent = t('staffApplicants');
|
this.applicantsTitle.textContent = t('staffApplicants');
|
||||||
this.applicantsButton.textContent = t('staffApplicantsOpen');
|
this.applicantsButton.textContent = t('staffApplicantsOpen');
|
||||||
@@ -116,6 +131,8 @@ export class ManagementPanel {
|
|||||||
this.timetableGrid.localize();
|
this.timetableGrid.localize();
|
||||||
this.personGrid.localize();
|
this.personGrid.localize();
|
||||||
this.personTimetableTitle.textContent = t('timetableTitle');
|
this.personTimetableTitle.textContent = t('timetableTitle');
|
||||||
|
this.rulesPanel.localize();
|
||||||
|
this.paintManageTabs();
|
||||||
this.paint();
|
this.paint();
|
||||||
this.poolDialog?.localize();
|
this.poolDialog?.localize();
|
||||||
if (this.selectedId !== null) {
|
if (this.selectedId !== null) {
|
||||||
@@ -133,12 +150,43 @@ export class ManagementPanel {
|
|||||||
this.staffing = null;
|
this.staffing = null;
|
||||||
this.timetable = null;
|
this.timetable = null;
|
||||||
this.classId = null;
|
this.classId = null;
|
||||||
|
this.manageTab = 'staff';
|
||||||
this.clearError();
|
this.clearError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.paintManageTabs();
|
||||||
|
|
||||||
this.timetableGrid.attach(schoolId);
|
this.timetableGrid.attach(schoolId);
|
||||||
this.cardHost.attach(schoolId);
|
this.cardHost.attach(schoolId);
|
||||||
void this.reload();
|
void this.reload();
|
||||||
|
if (this.manageTab === 'rules') {
|
||||||
|
this.rulesPanel.show(schoolId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showManageTab(tab: 'staff' | 'rules'): void {
|
||||||
|
this.manageTab = tab;
|
||||||
|
this.paintManageTabs();
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
if (tab === 'rules' && schoolId !== null) {
|
||||||
|
this.rulesPanel.show(schoolId);
|
||||||
|
this.paintCard(null);
|
||||||
|
} else if (this.selectedId !== null) {
|
||||||
|
void this.openCard(this.selectedId);
|
||||||
|
} else {
|
||||||
|
this.paintCard(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintManageTabs(): void {
|
||||||
|
this.staffTab.classList.toggle('panel__tab--active', this.manageTab === 'staff');
|
||||||
|
this.rulesTab.classList.toggle('panel__tab--active', this.manageTab === 'rules');
|
||||||
|
this.staffPane.hidden = this.manageTab !== 'staff';
|
||||||
|
if (this.manageTab === 'rules') {
|
||||||
|
this.rulesPanel.reveal();
|
||||||
|
} else {
|
||||||
|
this.rulesPanel.hide();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocate(locate: (id: string) => string): void {
|
setLocate(locate: (id: string) => string): void {
|
||||||
@@ -402,6 +450,12 @@ export class ManagementPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private paintCard(card: PersonCard | null): void {
|
private paintCard(card: PersonCard | null): void {
|
||||||
|
if (this.manageTab === 'rules') {
|
||||||
|
clear(this.card);
|
||||||
|
this.card.append(el('p', { class: 'panel__empty', text: t('rulesPickHint') }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (card === null) {
|
if (card === null) {
|
||||||
clear(this.card);
|
clear(this.card);
|
||||||
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ public static class Appropriateness
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rules.Color.Equals(ColorPolicies.WhiteTopBlackBottom, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (def.Layers.Contains(ApparelLayers.Top, StringComparer.Ordinal)
|
||||||
|
&& !color.Equals("White", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.Layers.Contains(ApparelLayers.Bottom, StringComparer.Ordinal)
|
||||||
|
&& !color.Equals("Black", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,38 @@ public class DressRulesApiTests(AppHostFixture fixture)
|
|||||||
Assert.Equal(before.Worn.Select(row => row.DefName), after.Worn.Select(row => row.DefName));
|
Assert.Equal(before.Worn.Select(row => row.DefName), after.Worn.Select(row => row.DefName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnknownForm_Returns400()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Плохая форма", Start, seed: 35);
|
||||||
|
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/dress-rules",
|
||||||
|
new { students = new { form = "tuxedo", color = "noBright" } },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
Assert.Equal("unknown-form", await SchoolApiTests.ProblemCodeAsync(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UnknownColor_Returns400()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Плохой цвет", Start, seed: 35);
|
||||||
|
|
||||||
|
using var response = await client.PostAsJsonAsync(
|
||||||
|
$"/api/schools/{school.Id}/dress-rules",
|
||||||
|
new { students = new { form = "regular", color = "neon" } },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
Assert.Equal("unknown-color", await SchoolApiTests.ProblemCodeAsync(response));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record DressRulesDto(DressRulePairDto Students, DressRulePairDto Staff);
|
private sealed record DressRulesDto(DressRulePairDto Students, DressRulePairDto Staff);
|
||||||
|
|
||||||
private sealed record DressRulePairDto(string Form, string Color);
|
private sealed record DressRulePairDto(string Form, string Color);
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ public class AppropriatenessTests
|
|||||||
Assert.True(Appropriateness.SkirtLengthFits(context, catalog.Things["ShortSkirt"]));
|
Assert.True(Appropriateness.SkirtLengthFits(context, catalog.Things["ShortSkirt"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WhiteTopBlackBottom_RejectsWrongTopOrBottom()
|
||||||
|
{
|
||||||
|
var catalog = LoadCatalog();
|
||||||
|
var rules = new DressRulePair(FormPolicies.Regular, ColorPolicies.WhiteTopBlackBottom);
|
||||||
|
Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Shirt"], "Gray"));
|
||||||
|
Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Shirt"], "White"));
|
||||||
|
Assert.False(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Jeans"], "Blue"));
|
||||||
|
Assert.True(Appropriateness.ColorAllowed(catalog, rules, catalog.Things["Jeans"], "Black"));
|
||||||
|
}
|
||||||
|
|
||||||
private static DefCatalog LoadCatalog()
|
private static DefCatalog LoadCatalog()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
|||||||
@@ -152,6 +152,63 @@ public class AppropriatenessSimulationTests
|
|||||||
Assert.Equal(0, BrightWornCount(school));
|
Assert.Equal(0, BrightWornCount(school));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PendingNoBright_AfterMorning_RemovesBrightWornColours()
|
||||||
|
{
|
||||||
|
using var school = Open(TuesdayMorning, seed: 31);
|
||||||
|
MorningDress.Apply(school);
|
||||||
|
school.DressRules = school.DressRules with
|
||||||
|
{
|
||||||
|
PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.Free),
|
||||||
|
};
|
||||||
|
AdvanceMorning(school);
|
||||||
|
Assert.True(BrightWornCount(school) > 0, "Free colours should allow at least one bright item.");
|
||||||
|
|
||||||
|
school.DressRules = school.DressRules with
|
||||||
|
{
|
||||||
|
PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.NoBright),
|
||||||
|
};
|
||||||
|
AdvanceMorning(school);
|
||||||
|
Assert.Equal(0, BrightWornCount(school));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WhiteTopBlackBottom_AfterMorning_WhiteShirtsOnStudents()
|
||||||
|
{
|
||||||
|
using var school = Open(TuesdayMorning, seed: 31);
|
||||||
|
MorningDress.Apply(school);
|
||||||
|
school.DressRules = school.DressRules with
|
||||||
|
{
|
||||||
|
PendingStudents = new DressRulePair(FormPolicies.Regular, ColorPolicies.WhiteTopBlackBottom),
|
||||||
|
};
|
||||||
|
AdvanceMorning(school);
|
||||||
|
|
||||||
|
var checkedAny = false;
|
||||||
|
foreach (var pupil in school.Roster!.People.Where(person => person.IsStudent))
|
||||||
|
{
|
||||||
|
foreach (var item in pupil.Items.Where(row => row.Location == ItemLocations.Worn))
|
||||||
|
{
|
||||||
|
if (!school.Catalog!.Things[item.Def].Layers.Any(layer =>
|
||||||
|
layer.Equals(ApparelLayers.Top, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkedAny = true;
|
||||||
|
Assert.Equal("White", item.Color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(checkedAny, "Expected at least one pupil with a top-layer shirt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AdvanceMorning(School school)
|
||||||
|
{
|
||||||
|
var before = school.Clock.Time;
|
||||||
|
school.Clock.JumpTo(before.Date.AddDays(1).AddHours(6));
|
||||||
|
ApparelWear.Apply(school, gameMinutes: 0, before);
|
||||||
|
}
|
||||||
|
|
||||||
private static int BrightWornCount(School school)
|
private static int BrightWornCount(School school)
|
||||||
{
|
{
|
||||||
var count = 0;
|
var count = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user