mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21:09 +02:00
test(cycles): integration tests with fake-indexeddb
24 tests covering the complex store interactions that pure-function tests cannot reach: - cyclesStore.createCycle auto-closes the previous open cycle and computes length, but leaves future cycles untouched when backfilling - cyclesStore.setPeriodEnd separates "end of bleeding" from endDate - dayLogsStore.logDay upserts per date (no duplicates even across multiple partial updates) - Auto-start cycle fires on bleeding flow with no history or after a closed cycle >= 10 days old, but NOT for spotting or mid-cycle bleeds - Auto-end period sets periodEndDate after 2 dry days, does not re-trigger on already-ended cycles - Symptom reference counters adjust correctly when a log is created, updated (adds/removes symptoms), and deleted - autoAssignCycle retroactively attaches orphan logs to a newly created cycle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f7a5bb841e
commit
b97e2b5c6e
1 changed files with 297 additions and 0 deletions
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* Integration tests for cycles stores against a real (fake) IndexedDB.
|
||||
*
|
||||
* Covers the complex interactions that pure-function tests cannot:
|
||||
* - cyclesStore auto-closes the previous open cycle when a new one starts
|
||||
* - dayLogsStore upserts per date (no duplicates)
|
||||
* - dayLogsStore auto-creates a cycle on first bleeding log
|
||||
* - dayLogsStore auto-sets periodEndDate after 2 dry days
|
||||
* - symptomsStore.touchSymptoms increments/decrements ref counts
|
||||
* - dayLogsStore updates symptom counters when symptoms change on an existing log
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
|
||||
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
|
||||
vi.mock('$lib/triggers/inline-suggest', () => ({
|
||||
checkInlineSuggestion: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { setCurrentUserId } from '$lib/data/current-user';
|
||||
import { cyclesStore } from './cycles.svelte';
|
||||
import { dayLogsStore } from './dayLogs.svelte';
|
||||
import { symptomsStore } from './symptoms.svelte';
|
||||
import type { LocalCycle, LocalCycleDayLog, LocalCycleSymptom } from '../types';
|
||||
|
||||
const cycleTable = () => db.table<LocalCycle>('cycles');
|
||||
const dayLogTable = () => db.table<LocalCycleDayLog>('cycleDayLogs');
|
||||
const symptomTable = () => db.table<LocalCycleSymptom>('cycleSymptoms');
|
||||
|
||||
const iso = (dateStr: string) => dateStr; // alias for readability
|
||||
|
||||
async function resetCyclesTables() {
|
||||
await cycleTable().clear();
|
||||
await dayLogTable().clear();
|
||||
await symptomTable().clear();
|
||||
await db.table('_pendingChanges').clear();
|
||||
await db.table('_activity').clear();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
setCurrentUserId('test-user');
|
||||
await resetCyclesTables();
|
||||
});
|
||||
|
||||
describe('cyclesStore.createCycle', () => {
|
||||
it('creates a single open cycle when none exists', async () => {
|
||||
const cycle = await cyclesStore.createCycle({ startDate: iso('2026-01-01') });
|
||||
expect(cycle.startDate).toBe('2026-01-01');
|
||||
expect(cycle.endDate).toBeNull();
|
||||
expect(cycle.length).toBeNull();
|
||||
|
||||
const stored = await cycleTable().toArray();
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].id).toBe(cycle.id);
|
||||
});
|
||||
|
||||
it('auto-closes the previous open cycle and computes length', async () => {
|
||||
const first = await cyclesStore.createCycle({ startDate: iso('2026-01-01') });
|
||||
await cyclesStore.createCycle({ startDate: iso('2026-01-29') });
|
||||
|
||||
const firstStored = await cycleTable().get(first.id);
|
||||
expect(firstStored?.endDate).toBe('2026-01-28'); // day before new start
|
||||
expect(firstStored?.length).toBe(28);
|
||||
});
|
||||
|
||||
it('does not touch cycles whose startDate is >= the new cycle', async () => {
|
||||
// Backfilling an older cycle should NOT close a future one
|
||||
const future = await cyclesStore.createCycle({ startDate: iso('2026-03-01') });
|
||||
await cyclesStore.createCycle({ startDate: iso('2026-02-01') });
|
||||
|
||||
const futureStored = await cycleTable().get(future.id);
|
||||
expect(futureStored?.endDate).toBeNull();
|
||||
expect(futureStored?.length).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cyclesStore.setPeriodEnd', () => {
|
||||
it('sets periodEndDate without affecting endDate', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.setPeriodEnd(c.id, '2026-04-05');
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
expect(stored?.periodEndDate).toBe('2026-04-05');
|
||||
expect(stored?.endDate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cyclesStore.deleteCycle', () => {
|
||||
it('soft-deletes via deletedAt', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.deleteCycle(c.id);
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
expect(stored?.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayLogsStore.logDay — upsert behavior', () => {
|
||||
it('creates a single log for a new date', async () => {
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', flow: 'light' });
|
||||
|
||||
const logs = await dayLogTable().toArray();
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].logDate).toBe('2026-04-07');
|
||||
expect(logs[0].flow).toBe('light');
|
||||
});
|
||||
|
||||
it('updates the existing log for the same date (no duplicate)', async () => {
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', flow: 'light' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', mood: 'good' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', temperature: 36.6 });
|
||||
|
||||
const logs = (await dayLogTable().toArray()).filter((l) => !l.deletedAt);
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].flow).toBe('light');
|
||||
expect(logs[0].mood).toBe('good');
|
||||
expect(logs[0].temperature).toBe(36.6);
|
||||
});
|
||||
|
||||
it('assigns cycleId when a matching cycle exists', async () => {
|
||||
await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-05', mood: 'good' });
|
||||
|
||||
const log = (await dayLogTable().toArray())[0];
|
||||
expect(log.cycleId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('leaves cycleId null when no cycle covers the date', async () => {
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', mood: 'good' });
|
||||
const log = (await dayLogTable().toArray())[0];
|
||||
expect(log.cycleId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayLogsStore.logDay — auto-start cycle', () => {
|
||||
it('creates a new cycle on first bleeding log with no history', async () => {
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', flow: 'medium' });
|
||||
|
||||
const cycles = await cycleTable().toArray();
|
||||
expect(cycles).toHaveLength(1);
|
||||
expect(cycles[0].startDate).toBe('2026-04-07');
|
||||
|
||||
// And the log itself is attached to that cycle
|
||||
const log = (await dayLogTable().toArray())[0];
|
||||
expect(log.cycleId).toBe(cycles[0].id);
|
||||
});
|
||||
|
||||
it('does NOT create a new cycle for spotting', async () => {
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', flow: 'spotting' });
|
||||
const cycles = await cycleTable().toArray();
|
||||
expect(cycles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT create a new cycle during an open cycle', async () => {
|
||||
await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
// Mid-cycle bleeding should NOT spawn a second cycle
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-10', flow: 'medium' });
|
||||
|
||||
const cycles = await cycleTable().toArray();
|
||||
expect(cycles).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates a new cycle when the previous is closed and far enough apart', async () => {
|
||||
const first = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.setPeriodEnd(first.id, '2026-04-05');
|
||||
|
||||
// 15 days after periodEndDate — well beyond MIN_GAP (10)
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-20', flow: 'medium' });
|
||||
|
||||
const cycles = (await cycleTable().toArray()).filter((c) => !c.deletedAt);
|
||||
expect(cycles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does NOT create a new cycle if bleeding is too soon after period end', async () => {
|
||||
const first = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.setPeriodEnd(first.id, '2026-04-05');
|
||||
|
||||
// Only 8 days after — treated as mid-cycle spotting
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-13', flow: 'medium' });
|
||||
|
||||
const cycles = (await cycleTable().toArray()).filter((c) => !c.deletedAt);
|
||||
expect(cycles).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayLogsStore.logDay — auto-end period', () => {
|
||||
it('sets periodEndDate after 2 dry days following bleeding', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-02', flow: 'medium' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-03', flow: 'light' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-04', flow: 'none' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-05', flow: 'none' });
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
expect(stored?.periodEndDate).toBe('2026-04-03');
|
||||
});
|
||||
|
||||
it('does NOT set periodEndDate after only 1 dry day', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-02', flow: 'none' });
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
expect(stored?.periodEndDate).toBeNull();
|
||||
});
|
||||
|
||||
it('does not overwrite an already-set periodEndDate', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.setPeriodEnd(c.id, '2026-04-03');
|
||||
|
||||
// Logging more none days should not re-trigger
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-10', flow: 'none' });
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
expect(stored?.periodEndDate).toBe('2026-04-03');
|
||||
});
|
||||
});
|
||||
|
||||
describe('symptomsStore.touchSymptoms', () => {
|
||||
it('increments count for existing symptoms', async () => {
|
||||
const sym = await symptomsStore.createSymptom({ name: 'Krämpfe', category: 'physical' });
|
||||
await symptomsStore.touchSymptoms([sym.id], +1);
|
||||
await symptomsStore.touchSymptoms([sym.id], +1);
|
||||
|
||||
const stored = await symptomTable().get(sym.id);
|
||||
expect(stored?.count).toBe(2);
|
||||
});
|
||||
|
||||
it('decrements count but never goes below zero', async () => {
|
||||
const sym = await symptomsStore.createSymptom({ name: 'Kopfschmerzen' });
|
||||
await symptomsStore.touchSymptoms([sym.id], -5);
|
||||
|
||||
const stored = await symptomTable().get(sym.id);
|
||||
expect(stored?.count).toBe(0);
|
||||
});
|
||||
|
||||
it('skips unknown IDs silently', async () => {
|
||||
await expect(symptomsStore.touchSymptoms(['does-not-exist'], +1)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayLogsStore.logDay — symptom counter integration', () => {
|
||||
it('increments counters when adding new symptoms', async () => {
|
||||
const cramps = await symptomsStore.createSymptom({ name: 'Krämpfe' });
|
||||
const headache = await symptomsStore.createSymptom({ name: 'Kopfschmerzen' });
|
||||
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', symptoms: [cramps.id, headache.id] });
|
||||
|
||||
expect((await symptomTable().get(cramps.id))?.count).toBe(1);
|
||||
expect((await symptomTable().get(headache.id))?.count).toBe(1);
|
||||
});
|
||||
|
||||
it('adjusts counters when symptoms change on an existing log', async () => {
|
||||
const cramps = await symptomsStore.createSymptom({ name: 'Krämpfe' });
|
||||
const headache = await symptomsStore.createSymptom({ name: 'Kopfschmerzen' });
|
||||
const bloating = await symptomsStore.createSymptom({ name: 'Blähbauch' });
|
||||
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', symptoms: [cramps.id, headache.id] });
|
||||
// Remove headache, add bloating
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', symptoms: [cramps.id, bloating.id] });
|
||||
|
||||
expect((await symptomTable().get(cramps.id))?.count).toBe(1); // unchanged
|
||||
expect((await symptomTable().get(headache.id))?.count).toBe(0); // removed
|
||||
expect((await symptomTable().get(bloating.id))?.count).toBe(1); // added
|
||||
});
|
||||
|
||||
it('decrements counters when deleting a log', async () => {
|
||||
const cramps = await symptomsStore.createSymptom({ name: 'Krämpfe' });
|
||||
const log = await dayLogsStore.logDay({ logDate: '2026-04-07', symptoms: [cramps.id] });
|
||||
|
||||
expect((await symptomTable().get(cramps.id))?.count).toBe(1);
|
||||
|
||||
await dayLogsStore.deleteLog(log.id);
|
||||
expect((await symptomTable().get(cramps.id))?.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayLogsStore.autoAssignCycle', () => {
|
||||
it('retroactively attaches orphan logs to the right cycle', async () => {
|
||||
// Log something before any cycle exists
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', mood: 'good' });
|
||||
const orphan = (await dayLogTable().toArray())[0];
|
||||
expect(orphan.cycleId).toBeNull();
|
||||
|
||||
// Now create a cycle that should claim that day
|
||||
const cycle = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.autoAssignCycle();
|
||||
|
||||
const reattached = await dayLogTable().get(orphan.id);
|
||||
expect(reattached?.cycleId).toBe(cycle.id);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue