feat: zlepšení šablon, přidány osobní údaje a spánkový režim

This commit is contained in:
Ondřej Anděl committed 2026-09-15 14:16:42 +02:00
1 parent a5711f3e1e
commit 21b01528e9
51 files changed
+3739 -504

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
process.env.CALORIE_PROVIDER = 'none';
import { applyWorkoutTemplate, deleteWorkoutTemplate, getWorkoutTemplates, saveWorkoutTemplate } from "../workouts";
import { getActivityDay } from "../activities";
import { getSettings, saveBasalCalories, saveProfile } from "../settings";
import { computeBasalCalories } from "../profile";
import { getDayOverview } from "../dayOverview";
import { saveSleep } from "../sleep";
import { resetMemoryStorage } from "../storage/memory";
import { ActivityUnit, Pohlavi, WorkoutItemKind } from "../../../types/gen/types.gen";
const LOGIN = 'tester';
const DATE = '2025-03-03';
/** Cvik jako položka šablony. */
const cvik = (name: string, quantity: number, rate: number) => ({
kind: WorkoutItemKind.CVIK,
name,
unit: ActivityUnit.OPAKOVANI,
quantity,
caloriesPer100Units: rate,
});
/** Odkaz na jinou šablonu. */
const odkaz = (templateRefId: string) => ({ kind: WorkoutItemKind.SABLONA, templateRefId });
beforeEach(() => resetMemoryStorage());
describe('skládání šablon z jiných šablon', () => {
it('šablona složená z jiné sečte kalorie obou', async () => {
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [
odkaz(rozcvicka.id),
cvik('Kliky', 60, 50),
]);
const workout = templates.find(t => t.name === 'Workout day 1')!;
// 40 dřepů × 0,4 kcal + 60 kliků × 0,5 kcal
expect(workout.estimatedCalories).toBe(46);
expect(workout.hasNested).toBe(true);
});
it('doplní název a kalorie vnořené šablony', async () => {
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
const workout = (await getWorkoutTemplates(LOGIN)).find(t => t.name === 'Workout day 1')!;
expect(workout.items[0].templateRefName).toBe('Rozcvička');
expect(workout.items[0].templateRefCalories).toBe(16);
});
it('použití rozbalí vnořenou šablonu na její cviky', async () => {
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [
cvik('Dřepy', 40, 40),
cvik('Výpady', 20, 30),
]);
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [
odkaz(rozcvicka.id),
cvik('Kliky', 60, 50),
]);
const workout = templates.find(t => t.name === 'Workout day 1')!;
await applyWorkoutTemplate(LOGIN, workout.id, DATE);
const day = await getActivityDay(LOGIN, DATE);
expect(day.entries.map(e => e.name)).toEqual(['Dřepy', 'Výpady', 'Kliky']);
expect(day.totalCalories).toBe(52);
});
it('počítá, kolik jiných šablon danou šablonu používá', async () => {
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 2', [odkaz(rozcvicka.id)]);
const updated = (await getWorkoutTemplates(LOGIN)).find(t => t.name === 'Rozcvička')!;
expect(updated.usedByCount).toBe(2);
});
it('nedovolí zanoření o dvě úrovně', async () => {
const [zaklad] = await saveWorkoutTemplate(LOGIN, undefined, 'Základ', [cvik('Dřepy', 40, 40)]);
const templates = await saveWorkoutTemplate(LOGIN, undefined, 'Střední', [odkaz(zaklad.id)]);
const stredni = templates.find(t => t.name === 'Střední')!;
// "Střední" už sama skládá jinou šablonu, takže ji nelze vnořit dál
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Vrchní', [odkaz(stredni.id)]))
.rejects.toThrow(/jen na jednu úroveň/);
});
it('nedovolí začít vnořovat v šabloně, kterou už někdo skládá', async () => {
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
const [jiny] = await saveWorkoutTemplate(LOGIN, undefined, 'Jiný blok', [cvik('Kliky', 60, 50)]);
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
// Rozcvičku už skládá Workout day 1, takže do ní nelze vnořit další šablonu
await expect(saveWorkoutTemplate(LOGIN, rozcvicka.id, 'Rozcvička', [odkaz(jiny.id)]))
.rejects.toThrow(/jen na jednu úroveň/);
});
it('nedovolí odkaz sám na sebe', async () => {
const [sablona] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
await expect(saveWorkoutTemplate(LOGIN, sablona.id, 'Rozcvička', [odkaz(sablona.id)]))
.rejects.toThrow(/sama na sebe/);
});
it('nedovolí odkaz na neexistující šablonu', async () => {
await expect(saveWorkoutTemplate(LOGIN, undefined, 'Workout', [odkaz('neexistuje')]))
.rejects.toThrow(/nebyla nalezena/);
});
it('nedovolí smazat šablonu, kterou skládá jiná', async () => {
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
await saveWorkoutTemplate(LOGIN, undefined, 'Workout day 1', [odkaz(rozcvicka.id)]);
await expect(deleteWorkoutTemplate(LOGIN, rozcvicka.id)).rejects.toThrow(/nelze smazat/);
});
it('šablonu bez vazeb smazat lze', async () => {
const [rozcvicka] = await saveWorkoutTemplate(LOGIN, undefined, 'Rozcvička', [cvik('Dřepy', 40, 40)]);
expect(await deleteWorkoutTemplate(LOGIN, rozcvicka.id)).toEqual([]);
});
it('šablonám uloženým bez druhu položky doplní CVIK', async () => {
// Podoba dat z doby, kdy skládání z jiných šablon ještě neexistovalo
const { default: getStorage } = await import("../storage");
await getStorage().setData('workouts:' + LOGIN, [{
id: 'stara', name: 'Stará šablona',
items: [{ name: 'Kliky', unit: ActivityUnit.OPAKOVANI, quantity: 60, caloriesPer100Units: 50 }],
}]);
const [template] = await getWorkoutTemplates(LOGIN);
expect(template.items[0].kind).toBe(WorkoutItemKind.CVIK);
expect(template.estimatedCalories).toBe(30);
expect(template.hasNested).toBe(false);
});
});
describe('tělesné údaje a bazální metabolismus', () => {
/** Muž, 90 kg, 185 cm, ročník 1990. */
const muz = { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185, weightGrams: 90_000 };
it('spočítá klidový výdej rovnicí Mifflin–St Jeor', () => {
const age = new Date().getFullYear() - 1990;
// 10 × 90 + 6,25 × 185 − 5 × věk + 5
const expected = Math.round(900 + 1156.25 - 5 * age + 5);
expect(computeBasalCalories(muz)).toBe(expected);
});
it('u ženy odečítá 161 místo přičtení 5', () => {
const zena = { ...muz, sex: Pohlavi.ZENA };
expect(computeBasalCalories(muz)! - computeBasalCalories(zena)!).toBe(166);
});
it('neúplný profil klidový výdej nespočítá', () => {
expect(computeBasalCalories(undefined)).toBeUndefined();
expect(computeBasalCalories({ sex: Pohlavi.MUZ, heightCm: 185 })).toBeUndefined();
});
it('uložený profil se propíše do nastavení i do bilance dne', async () => {
await saveProfile(LOGIN, muz);
const settings = await getSettings(LOGIN);
expect(settings.computedBasalCalories).toBe(computeBasalCalories(muz));
expect(settings.effectiveBasalCalories).toBe(computeBasalCalories(muz));
const overview = await getDayOverview(LOGIN, DATE);
expect(overview.energy.basal).toBe(computeBasalCalories(muz));
expect(overview.energy.hasBasal).toBe(true);
});
it('ručně zadaný klidový výdej má přednost před spočítaným', async () => {
await saveProfile(LOGIN, muz);
await saveBasalCalories(LOGIN, 1800);
const settings = await getSettings(LOGIN);
expect(settings.computedBasalCalories).toBe(computeBasalCalories(muz));
expect(settings.effectiveBasalCalories).toBe(1800);
expect((await getDayOverview(LOGIN, DATE)).energy.basal).toBe(1800);
});
it('po smazání ručního údaje se vrátí spočítaný z profilu', async () => {
await saveProfile(LOGIN, muz);
await saveBasalCalories(LOGIN, 1800);
await saveBasalCalories(LOGIN, 0);
expect((await getSettings(LOGIN)).effectiveBasalCalories).toBe(computeBasalCalories(muz));
});
it('odmítne nesmyslné tělesné údaje', async () => {
await expect(saveProfile(LOGIN, { ...muz, heightCm: 5 })).rejects.toThrow(/mimo rozsah/);
await expect(saveProfile(LOGIN, { ...muz, weightGrams: 5_000_000 })).rejects.toThrow(/mimo rozsah/);
await expect(saveProfile(LOGIN, { ...muz, sex: 'JINE' })).rejects.toThrow(/pohlaví/);
});
it('uložení profilu nepřepíše sazby podniků', async () => {
const { saveSourceRate } = await import("../settings");
await saveSourceRate(LOGIN, 'TechTower', 4400);
await saveProfile(LOGIN, muz);
expect((await getSettings(LOGIN)).sourceRates).toHaveLength(1);
});
});
describe('spánek', () => {
it('uloží se ke dni a vrátí v přehledu', async () => {
await saveSleep(LOGIN, DATE, 450);
expect((await getDayOverview(LOGIN, DATE)).sleepMinutes).toBe(450);
});
it('nulová hodnota záznam odstraní', async () => {
await saveSleep(LOGIN, DATE, 450);
await saveSleep(LOGIN, DATE, 0);
expect((await getDayOverview(LOGIN, DATE)).sleepMinutes).toBeUndefined();
});
it('do energetické bilance nevstupuje', async () => {
await saveProfile(LOGIN, { sex: Pohlavi.MUZ, birthYear: 1990, heightCm: 185, weightGrams: 90_000 });
const bezSpanku = await getDayOverview(LOGIN, DATE);
await saveSleep(LOGIN, DATE, 450);
const seSpankem = await getDayOverview(LOGIN, DATE);
// Klidový výdej pokrývá celých 24 hodin včetně spánku, bilance se měnit nesmí
expect(seSpankem.energy).toEqual(bezSpanku.energy);
});
it('odmítne nesmyslnou délku', async () => {
await expect(saveSleep(LOGIN, DATE, 2000)).rejects.toThrow(/mimo rozsah/);
await expect(saveSleep(LOGIN, 'včera', 450)).rejects.toThrow(/platné datum/);
});
it('je oddělený mezi uživateli', async () => {
await saveSleep(LOGIN, DATE, 450);
expect((await getDayOverview('nekdo-jiny', DATE)).sleepMinutes).toBeUndefined();
});
});