From a5551c2ad93cd8ffb5db998d39a720712c5bcbe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Fran=C3=A7a?= Date: Thu, 30 Apr 2026 20:09:49 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A7=AA=20test:=20add=20unit=20tests?= =?UTF-8?q?=20for=20questionnaire=20scoring=20and=20JSON=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/export.test.js | 431 +++++++++++++++++++++++++++ __tests__/questionnaires.test.js | 497 +++++++++++++++++++++++++++++++ 2 files changed, 928 insertions(+) create mode 100644 __tests__/export.test.js create mode 100644 __tests__/questionnaires.test.js diff --git a/__tests__/export.test.js b/__tests__/export.test.js new file mode 100644 index 0000000..6d695fc --- /dev/null +++ b/__tests__/export.test.js @@ -0,0 +1,431 @@ +/** + * __tests__/export.test.js + * + * Tests for the JSON export / import consistency of storage/storage.js. + * + * Coverage: + * 1. exportToJSON — output structure (top-level keys, entry shape, field formats) + * 2. exportToJSON — questionnaire section shape + * 3. Field-format validation — clock fields, duration fields, medication arrays + * 4. Round-trip fidelity — export → parse → importFromJSON → re-export preserves entries + * 5. exportToJSON returns null when there is nothing to export + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + exportToJSON, + importFromJSON, + loadEntries, + saveQuestionnaire, + clearAll, +} from '../storage/storage'; + +// Reset in-memory AsyncStorage between tests. +beforeEach(async () => { + await AsyncStorage.clear(); + jest.clearAllMocks(); +}); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +/** + * A realistic morning entry that exercises every field shape: + * - clock fields (mq1, mq6, mq7) → { hour, minute } + * - duration fields (mq3, mq5, mq8b) → { hours, minutes } + * - medication array (mq10b) → [{ id, name, dose, times }] + */ +const MORNING_ENTRY = { + id: '2024-01-15-morning', + type: 'morning', + date: '2024-01-15', + completedAt: '2024-01-15T08:00:00.000Z', + answers: { + mq1: { hour: 23, minute: 0 }, // bedtime + mq3: { hours: 0, minutes: 15 }, // sleep onset latency + mq4: 'yes', + mq4b: 2, + mq5: { hours: 0, minutes: 30 }, // WASO + mq6: { hour: 7, minute: 0 }, // final wake (unused in metrics but stored) + mq7: { hour: 7, minute: 30 }, // rise time + mq8: 'yes', + mq8b: { hours: 0, minutes: 20 }, // early waking duration + mq9: 1, + mq10b: [ + { id: 1705000000000, name: 'Melatonin', dose: '5 mg', times: ['22:00'] }, + ], + mq11: 3, + mq12: 4, + }, +}; + +const EVENING_ENTRY = { + id: '2024-01-14-evening', + type: 'evening', + date: '2024-01-14', + completedAt: '2024-01-14T22:00:00.000Z', + answers: { + eq1: 'yes', + eq1b: { hours: 0, minutes: 45 }, // nap duration + eq2: 2, + eq3: 0, + eq4b: [], // no evening medications + }, +}; + +const ESS_RESULT = { + id: 'ess', + completedAt: '2024-01-15T09:00:00.000Z', + answers: { ess1:2, ess2:1, ess3:2, ess4:0, ess5:1, ess6:0, ess7:1, ess8:2 }, + score: 9, +}; + +// Seed AsyncStorage with both diary entries +const seedEntries = async () => { + await AsyncStorage.setItem('entries', JSON.stringify([MORNING_ENTRY, EVENING_ENTRY])); +}; + +// ─── 1. Top-level export structure ──────────────────────────────────────────── + +describe('exportToJSON — top-level structure', () => { + it('returns null when there are no entries and no questionnaire results', async () => { + expect(await exportToJSON('Lucas')).toBeNull(); + }); + + it('returns a non-null string when entries exist', async () => { + await seedEntries(); + const raw = await exportToJSON('Lucas'); + expect(raw).not.toBeNull(); + expect(typeof raw).toBe('string'); + }); + + it('is valid JSON', async () => { + await seedEntries(); + const raw = await exportToJSON('Lucas'); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + it('contains all required top-level keys', async () => { + await seedEntries(); + const obj = JSON.parse(await exportToJSON('Lucas')); + expect(obj).toHaveProperty('participant'); + expect(obj).toHaveProperty('researchCode'); + expect(obj).toHaveProperty('exportedAt'); + expect(obj).toHaveProperty('entries'); + expect(obj).toHaveProperty('questionnaires'); + }); + + it('participant matches the name passed in', async () => { + await seedEntries(); + const obj = JSON.parse(await exportToJSON('Lucas')); + expect(obj.participant).toBe('Lucas'); + }); + + it('exportedAt is a valid ISO 8601 timestamp', async () => { + await seedEntries(); + const obj = JSON.parse(await exportToJSON('Lucas')); + expect(new Date(obj.exportedAt).toISOString()).toBe(obj.exportedAt); + }); + + it('entries is an array', async () => { + await seedEntries(); + const obj = JSON.parse(await exportToJSON('Lucas')); + expect(Array.isArray(obj.entries)).toBe(true); + }); + + it('questionnaires is an array', async () => { + await seedEntries(); + const obj = JSON.parse(await exportToJSON('Lucas')); + expect(Array.isArray(obj.questionnaires)).toBe(true); + }); +}); + +// ─── 2. Per-entry shape ─────────────────────────────────────────────────────── + +describe('exportToJSON — diary entry shape', () => { + beforeEach(seedEntries); + + const getEntries = async (name = 'Lucas') => { + const obj = JSON.parse(await exportToJSON(name)); + return obj.entries; + }; + + it('each entry has id, type, date, completedAt, answers', async () => { + const entries = await getEntries(); + for (const e of entries) { + expect(e).toHaveProperty('id'); + expect(e).toHaveProperty('type'); + expect(e).toHaveProperty('date'); + expect(e).toHaveProperty('completedAt'); + expect(e).toHaveProperty('answers'); + } + }); + + it('type is "morning" or "evening"', async () => { + const entries = await getEntries(); + for (const e of entries) { + expect(['morning', 'evening']).toContain(e.type); + } + }); + + it('date matches YYYY-MM-DD format', async () => { + const entries = await getEntries(); + const re = /^\d{4}-\d{2}-\d{2}$/; + for (const e of entries) { + expect(e.date).toMatch(re); + } + }); + + it('completedAt is a valid ISO timestamp', async () => { + const entries = await getEntries(); + for (const e of entries) { + expect(new Date(e.completedAt).toISOString()).toBe(e.completedAt); + } + }); + + it('id is composed of date and type separated by a hyphen', async () => { + const entries = await getEntries(); + for (const e of entries) { + expect(e.id).toBe(`${e.date}-${e.type}`); + } + }); +}); + +// ─── 3. Field format validation ─────────────────────────────────────────────── + +describe('exportToJSON — answer field formats', () => { + beforeEach(seedEntries); + + const getMorning = async () => { + const obj = JSON.parse(await exportToJSON('Lucas')); + return obj.entries.find((e) => e.type === 'morning').answers; + }; + + const getEvening = async () => { + const obj = JSON.parse(await exportToJSON('Lucas')); + return obj.entries.find((e) => e.type === 'evening').answers; + }; + + // Clock fields: { hour: number, minute: number } + it('mq1 (bedtime) is a clock object with hour and minute keys', async () => { + const a = await getMorning(); + expect(a.mq1).toHaveProperty('hour'); + expect(a.mq1).toHaveProperty('minute'); + expect(typeof a.mq1.hour).toBe('number'); + expect(typeof a.mq1.minute).toBe('number'); + }); + + it('mq7 (rise time) is a clock object', async () => { + const a = await getMorning(); + expect(a.mq7).toHaveProperty('hour'); + expect(a.mq7).toHaveProperty('minute'); + }); + + // Duration fields: { hours: number, minutes: number } + it('mq3 (SOL) is a duration object with hours and minutes keys', async () => { + const a = await getMorning(); + expect(a.mq3).toHaveProperty('hours'); + expect(a.mq3).toHaveProperty('minutes'); + expect(typeof a.mq3.hours).toBe('number'); + expect(typeof a.mq3.minutes).toBe('number'); + }); + + it('mq5 (WASO) is a duration object', async () => { + const a = await getMorning(); + expect(a.mq5).toHaveProperty('hours'); + expect(a.mq5).toHaveProperty('minutes'); + }); + + it('mq8b (early waking duration) is a duration object when present', async () => { + const a = await getMorning(); + if (a.mq8b !== undefined) { + expect(a.mq8b).toHaveProperty('hours'); + expect(a.mq8b).toHaveProperty('minutes'); + } + }); + + it('eq1b (nap duration) is a duration object when present', async () => { + const a = await getEvening(); + if (a.eq1b !== undefined) { + expect(a.eq1b).toHaveProperty('hours'); + expect(a.eq1b).toHaveProperty('minutes'); + } + }); + + // Medication arrays + it('mq10b is an array', async () => { + const a = await getMorning(); + expect(Array.isArray(a.mq10b)).toBe(true); + }); + + it('eq4b is an array', async () => { + const a = await getEvening(); + expect(Array.isArray(a.eq4b)).toBe(true); + }); + + it('medication entries have id, name, dose, and times fields', async () => { + const a = await getMorning(); + for (const med of a.mq10b) { + expect(med).toHaveProperty('id'); + expect(med).toHaveProperty('name'); + expect(med).toHaveProperty('dose'); + expect(med).toHaveProperty('times'); + expect(Array.isArray(med.times)).toBe(true); + } + }); + + it('clock hour values are integers in range 0–23', async () => { + const a = await getMorning(); + for (const key of ['mq1', 'mq7']) { + if (a[key]) { + expect(a[key].hour).toBeGreaterThanOrEqual(0); + expect(a[key].hour).toBeLessThanOrEqual(23); + expect(Number.isInteger(a[key].hour)).toBe(true); + } + } + }); + + it('clock minute values are integers in range 0–59', async () => { + const a = await getMorning(); + for (const key of ['mq1', 'mq7']) { + if (a[key]) { + expect(a[key].minute).toBeGreaterThanOrEqual(0); + expect(a[key].minute).toBeLessThanOrEqual(59); + expect(Number.isInteger(a[key].minute)).toBe(true); + } + } + }); +}); + +// ─── 4. Questionnaire section shape ────────────────────────────────────────── + +describe('exportToJSON — questionnaire section', () => { + beforeEach(async () => { + await seedEntries(); + await saveQuestionnaire('ess', ESS_RESULT.answers, ESS_RESULT.score); + }); + + it('includes questionnaire results in the export', async () => { + const obj = JSON.parse(await exportToJSON('Lucas')); + expect(obj.questionnaires.length).toBeGreaterThan(0); + }); + + it('each questionnaire result has id, completedAt, answers, score', async () => { + const obj = JSON.parse(await exportToJSON('Lucas')); + for (const q of obj.questionnaires) { + expect(q).toHaveProperty('id'); + expect(q).toHaveProperty('completedAt'); + expect(q).toHaveProperty('answers'); + expect(q).toHaveProperty('score'); + } + }); + + it('score is a number', async () => { + const obj = JSON.parse(await exportToJSON('Lucas')); + for (const q of obj.questionnaires) { + expect(typeof q.score).toBe('number'); + } + }); + + it('answers is an object', async () => { + const obj = JSON.parse(await exportToJSON('Lucas')); + for (const q of obj.questionnaires) { + expect(typeof q.answers).toBe('object'); + expect(q.answers).not.toBeNull(); + } + }); +}); + +// ─── 5. Round-trip fidelity ─────────────────────────────────────────────────── + +describe('exportToJSON — round-trip fidelity', () => { + it('entries survive an export → import → re-export round trip', async () => { + await seedEntries(); + + // Export + const firstExport = JSON.parse(await exportToJSON('Lucas')); + + // Clear store and re-import + await AsyncStorage.clear(); + await importFromJSON(firstExport, 'replace'); + + // Re-export + const secondExport = JSON.parse(await exportToJSON('Lucas')); + + // Both exports should contain the same number of entries + expect(secondExport.entries.length).toBe(firstExport.entries.length); + + // Entry IDs and dates should match exactly + const firstIds = firstExport.entries.map((e) => e.id).sort(); + const secondIds = secondExport.entries.map((e) => e.id).sort(); + expect(secondIds).toEqual(firstIds); + }); + + it('clock field shapes are preserved after round-trip', async () => { + await seedEntries(); + const firstExport = JSON.parse(await exportToJSON('Lucas')); + + await AsyncStorage.clear(); + await importFromJSON(firstExport, 'replace'); + + const entries = await loadEntries(); + const morning = entries.find((e) => e.type === 'morning'); + expect(morning.answers.mq1).toHaveProperty('hour'); + expect(morning.answers.mq1).toHaveProperty('minute'); + expect(morning.answers.mq3).toHaveProperty('hours'); + expect(morning.answers.mq3).toHaveProperty('minutes'); + }); + + it('medication array is preserved after round-trip', async () => { + await seedEntries(); + const firstExport = JSON.parse(await exportToJSON('Lucas')); + + await AsyncStorage.clear(); + await importFromJSON(firstExport, 'replace'); + + const entries = await loadEntries(); + const morning = entries.find((e) => e.type === 'morning'); + expect(Array.isArray(morning.answers.mq10b)).toBe(true); + expect(morning.answers.mq10b[0].name).toBe('Melatonin'); + }); + + it('questionnaire results survive a round trip', async () => { + await seedEntries(); + await saveQuestionnaire('ess', ESS_RESULT.answers, ESS_RESULT.score); + + const firstExport = JSON.parse(await exportToJSON('Lucas')); + + await AsyncStorage.clear(); + await importFromJSON(firstExport, 'replace'); + + const secondExport = JSON.parse(await exportToJSON('Lucas')); + const ess = secondExport.questionnaires.find((q) => q.id === 'ess'); + expect(ess).toBeDefined(); + expect(ess.score).toBe(9); + }); +}); + +// ─── 6. importFromJSON — validation edge cases ──────────────────────────────── + +describe('importFromJSON — JSON validation', () => { + it('accepts a flat entries array (legacy format without wrapper object)', async () => { + const flat = [MORNING_ENTRY]; + // validateImport handles both Array and { entries: Array } shapes + const result = await importFromJSON(flat, 'merge'); + expect(result.imported).toBe(1); + }); + + it('accepts an empty entries array without throwing', async () => { + const result = await importFromJSON({ entries: [] }, 'merge'); + expect(result.imported).toBe(0); + }); + + it('silently skips malformed questionnaire results during import', async () => { + const parsed = { + entries: [MORNING_ENTRY], + questionnaires: [ + { id: 'ess' }, // missing answers and score — should be skipped silently + ], + }; + await expect(importFromJSON(parsed, 'merge')).resolves.not.toThrow(); + }); +}); diff --git a/__tests__/questionnaires.test.js b/__tests__/questionnaires.test.js new file mode 100644 index 0000000..99837cd --- /dev/null +++ b/__tests__/questionnaires.test.js @@ -0,0 +1,497 @@ +/** + * __tests__/questionnaires.test.js + * + * Unit tests for the score() and interpret() functions of every active + * one-time research questionnaire defined in data/questionnaires.js. + * + * Coverage strategy per instrument: + * 1. score() with all items at their minimum value + * 2. score() with all items at their maximum value + * 3. One or more hand-verified reference cases (known answers → known score) + * 4. interpret() returns the correct band label at every threshold boundary + * + * PSQI additionally tests each of the 7 component-score sub-calculations + * in isolation before testing the global score. + * + * MCTQ tests the MSFsc and social-jetlag (SJL) derivations numerically, + * and verifies that interpret() selects the right chronotype label. + */ + +import { ESS, ISI, DBAS16, MEQ, PSQI, RUSATED, STOPBANG, MCTQ } from '../data/questionnaires'; + +// ─── ESS ────────────────────────────────────────────────────────────────────── + +describe('ESS — score()', () => { + it('returns 0 when all items are 0', () => { + const a = {}; + [1,2,3,4,5,6,7,8].forEach((n) => { a[`ess${n}`] = 0; }); + expect(ESS.score(a)).toBe(0); + }); + + it('returns 24 when all items are 3', () => { + const a = {}; + [1,2,3,4,5,6,7,8].forEach((n) => { a[`ess${n}`] = 3; }); + expect(ESS.score(a)).toBe(24); + }); + + it('sums item values correctly for a mixed response set', () => { + // ess1=2, ess2=1, ess3=3, ess4=0, ess5=2, ess6=1, ess7=2, ess8=1 → 12 + const a = { ess1:2, ess2:1, ess3:3, ess4:0, ess5:2, ess6:1, ess7:2, ess8:1 }; + expect(ESS.score(a)).toBe(12); + }); + + it('treats missing items as 0', () => { + expect(ESS.score({})).toBe(0); + }); +}); + +describe('ESS — interpret()', () => { + it('Normal at score 0', () => expect(ESS.interpret(0).label).toBe('Normal')); + it('Normal at score 7', () => expect(ESS.interpret(7).label).toBe('Normal')); + it('Borderline at score 8', () => expect(ESS.interpret(8).label).toBe('Borderline')); + it('Borderline at score 9', () => expect(ESS.interpret(9).label).toBe('Borderline')); + it('Excessive at score 10', () => expect(ESS.interpret(10).label).toBe('Excessive')); + it('Excessive at score 15', () => expect(ESS.interpret(15).label).toBe('Excessive')); + it('Severe at score 16', () => expect(ESS.interpret(16).label).toBe('Severe')); + it('Severe at score 24', () => expect(ESS.interpret(24).label).toBe('Severe')); +}); + +// ─── ISI ────────────────────────────────────────────────────────────────────── + +describe('ISI — score()', () => { + it('returns 0 when all items are 0', () => { + const a = {}; + [1,2,3,4,5,6,7].forEach((n) => { a[`isi${n}`] = 0; }); + expect(ISI.score(a)).toBe(0); + }); + + it('returns 28 when all items are 4', () => { + const a = {}; + [1,2,3,4,5,6,7].forEach((n) => { a[`isi${n}`] = 4; }); + expect(ISI.score(a)).toBe(28); + }); + + it('sums item values correctly for a mixed response set', () => { + // 3+2+1+3+2+1+2 = 14 + const a = { isi1:3, isi2:2, isi3:1, isi4:3, isi5:2, isi6:1, isi7:2 }; + expect(ISI.score(a)).toBe(14); + }); + + it('treats missing items as 0', () => { + expect(ISI.score({})).toBe(0); + }); +}); + +describe('ISI — interpret()', () => { + it('No clinically significant insomnia at score 0', () => expect(ISI.interpret(0).label).toBe('No clinically significant insomnia')); + it('No clinically significant insomnia at score 7', () => expect(ISI.interpret(7).label).toBe('No clinically significant insomnia')); + it('Subthreshold insomnia at score 8', () => expect(ISI.interpret(8).label).toBe('Subthreshold insomnia')); + it('Subthreshold insomnia at score 14', () => expect(ISI.interpret(14).label).toBe('Subthreshold insomnia')); + it('Clinical insomnia (moderate) at score 15', () => expect(ISI.interpret(15).label).toBe('Clinical insomnia (moderate)')); + it('Clinical insomnia (moderate) at score 21', () => expect(ISI.interpret(21).label).toBe('Clinical insomnia (moderate)')); + it('Clinical insomnia (severe) at score 22', () => expect(ISI.interpret(22).label).toBe('Clinical insomnia (severe)')); + it('Clinical insomnia (severe) at score 28', () => expect(ISI.interpret(28).label).toBe('Clinical insomnia (severe)')); +}); + +// ─── DBAS-16 ────────────────────────────────────────────────────────────────── + +describe('DBAS-16 — score()', () => { + it('returns 0.0 when all items are 0', () => { + const a = {}; + for (let n = 1; n <= 16; n++) a[`dbas${n}`] = 0; + expect(DBAS16.score(a)).toBe(0); + }); + + it('returns 10.0 when all items are 10', () => { + const a = {}; + for (let n = 1; n <= 16; n++) a[`dbas${n}`] = 10; + expect(DBAS16.score(a)).toBe(10); + }); + + it('computes mean item score to 1 decimal place', () => { + // All items = 5 → mean = 5.0 + const a = {}; + for (let n = 1; n <= 16; n++) a[`dbas${n}`] = 5; + expect(DBAS16.score(a)).toBe(5.0); + }); + + it('rounds correctly for a non-integer mean', () => { + // sum = 16 items × varied values totalling 72 → mean = 4.5 + const a = {}; + for (let n = 1; n <= 16; n++) a[`dbas${n}`] = n <= 8 ? 4 : 5; // 8×4 + 8×5 = 72 + expect(DBAS16.score(a)).toBe(4.5); + }); + + it('treats missing items as 0 in mean', () => { + // Only dbas1 = 8, rest missing (treated as 0) → 8/16 = 0.5 + expect(DBAS16.score({ dbas1: 8 })).toBe(0.5); + }); +}); + +describe('DBAS-16 — interpret()', () => { + it('Within normal range at score 0', () => expect(DBAS16.interpret(0).label).toBe('Within normal range')); + it('Within normal range at score 4', () => expect(DBAS16.interpret(4).label).toBe('Within normal range')); + it('Clinically relevant at score 4.1', () => expect(DBAS16.interpret(4.1).label).toBe('Clinically relevant')); + it('Clinically relevant at score 10', () => expect(DBAS16.interpret(10).label).toBe('Clinically relevant')); +}); + +// ─── MEQ ────────────────────────────────────────────────────────────────────── + +describe('MEQ — score()', () => { + it('sums all item values correctly', () => { + // Build a known-score response: all items at their minimum option value + // meq1–18 vary; meq19 minimum = 0. Minimum possible total = 18 (items 1–18 each + // contribute their lowest available option) + 0 (meq19) = depends on item options. + // Use a flat answer of 1 for items 1–18 and 0 for meq19 → sum = 18 + const a = {}; + for (let n = 1; n <= 18; n++) a[`meq${n}`] = 1; + a['meq19'] = 0; + expect(MEQ.score(a)).toBe(18); + }); + + it('treats missing items as 0', () => { + expect(MEQ.score({})).toBe(0); + }); + + it('returns 86 for a fully morning-type response (max options per item)', () => { + // Published MEQ maximum is 86 (Horne & Östberg, 1976). + // NOTE: this test currently fails — the implementation scores 83 because + // some item option weights deviate from the original instrument. + // This test is intentionally left failing to flag the discrepancy. + const maxA = { + meq1: 5, meq2: 5, meq3: 4, meq4: 4, meq5: 4, + meq6: 4, meq7: 4, meq8: 4, meq9: 4, meq10: 5, + meq11: 4, meq12: 4, meq13: 4, meq14: 4, meq15: 4, + meq16: 4, meq17: 5, meq18: 5, meq19: 6, + }; + expect(MEQ.score(maxA)).toBe(86); + }); +}); + +describe('MEQ — interpret()', () => { + it('Definite evening type at score 16', () => expect(MEQ.interpret(16).label).toBe('Definite evening type')); + it('Definite evening type at score 30', () => expect(MEQ.interpret(30).label).toBe('Definite evening type')); + it('Moderate evening type at score 31', () => expect(MEQ.interpret(31).label).toBe('Moderate evening type')); + it('Moderate evening type at score 41', () => expect(MEQ.interpret(41).label).toBe('Moderate evening type')); + it('Intermediate type at score 42', () => expect(MEQ.interpret(42).label).toBe('Intermediate type')); + it('Intermediate type at score 58', () => expect(MEQ.interpret(58).label).toBe('Intermediate type')); + it('Moderate morning type at score 59', () => expect(MEQ.interpret(59).label).toBe('Moderate morning type')); + it('Moderate morning type at score 69', () => expect(MEQ.interpret(69).label).toBe('Moderate morning type')); + it('Definite morning type at score 70', () => expect(MEQ.interpret(70).label).toBe('Definite morning type')); + it('Definite morning type at score 86', () => expect(MEQ.interpret(86).label).toBe('Definite morning type')); +}); + +// ─── PSQI ───────────────────────────────────────────────────────────────────── + +/** + * PSQI scoring reference: + * C1 — Subjective sleep quality (psqi9, 0–3) + * C2 — Sleep latency (psqi2 minutes + psqi5a, combined 0–3) + * C3 — Sleep duration (psqi4 hours, 0–3) + * C4 — Habitual sleep efficiency (derived from psqi1, psqi3, psqi4, 0–3) + * C5 — Sleep disturbance (sum of psqi5b–5i, 0–3) + * C6 — Use of sleeping medication (psqi6, 0–3) + * C7 — Daytime dysfunction (psqi7 + psqi8, combined 0–3) + * Global = C1+C2+C3+C4+C5+C6+C7 (0–21) + */ + +const psqiAllGood = { + psqi1: { hour: 23, minute: 0 }, // bedtime 23:00 + psqi2: 10, // SOL 10 min → solScore 0 + psqi3: { hour: 7, minute: 0 }, // rise 07:00 + psqi4: 7.5, // 7.5 h sleep → C3=0 + psqi5a: 0, psqi5b: 0, psqi5c: 0, psqi5d: 0, + psqi5e: 0, psqi5f: 0, psqi5g: 0, psqi5h: 0, psqi5i: 0, + psqi6: 0, + psqi7: 0, + psqi8: 0, + psqi9: 0, // "Very good" +}; + +describe('PSQI — score() component verification', () => { + it('C1: psqi9=0 contributes 0 to global', () => { + const a = { ...psqiAllGood, psqi9: 0 }; + // With all else at best values → global should be 0 + expect(PSQI.score(a)).toBe(0); + }); + + it('C1: psqi9=3 contributes 3 to global', () => { + const a = { ...psqiAllGood, psqi9: 3 }; + expect(PSQI.score(a)).toBe(3); + }); + + it('C2: SOL 16–30 min + psqi5a=0 → C2=1', () => { + // solScore=1, q5a=0, c2raw=1 → c2=1 + const a = { ...psqiAllGood, psqi2: 20, psqi5a: 0 }; + expect(PSQI.score(a)).toBe(1); + }); + + it('C2: SOL >60 min + psqi5a=3 → C2=3', () => { + // solScore=3, q5a=3, c2raw=6 → c2=3 + const a = { ...psqiAllGood, psqi2: 90, psqi5a: 3 }; + expect(PSQI.score(a)).toBe(3); + }); + + it('C3: 7.5 h → C3=0', () => { + const a = { ...psqiAllGood, psqi4: 7.5 }; + expect(PSQI.score(a)).toBe(0); + }); + + it('C3: 6 h → C3=1 (global=2 because C4 also degrades)', () => { + // psqi4=6: C3=1, but HSE=(6*60/480)*100=75% also drops C4 to 1 → global=2 + const a = { ...psqiAllGood, psqi4: 6 }; + expect(PSQI.score(a)).toBe(2); + }); + + it('C3: 4.9 h → C3=3 (global=6 because C4 also degrades)', () => { + // psqi4=4.9: C3=3, HSE=61.25% → C4=3 → global=6 + const a = { ...psqiAllGood, psqi4: 4.9 }; + expect(PSQI.score(a)).toBe(6); + }); + + it('C4: TIB=480 min, sleep=7.5h (450 min) → HSE=93.75% → C4=0', () => { + // bedtime 23:00, rise 07:00, TIB=480 min, psqi4=7.5h + // HSE = (7.5*60)/480 * 100 = 93.75 → C4=0 + const a = { ...psqiAllGood }; // defaults already satisfy this + expect(PSQI.score(a)).toBe(0); + }); + + it('C4: TIB=480 min, sleep=3h → HSE=37.5% → C4=3 (global=6)', () => { + // psqi4=3: C3=3 (<5h) and HSE=37.5% → C4=3 → global=6 + const a = { ...psqiAllGood, psqi4: 3 }; + expect(PSQI.score(a)).toBe(6); + }); + + it('C5: all disturbance items=0 → C5=0', () => { + expect(PSQI.score(psqiAllGood)).toBe(0); + }); + + it('C5: all 8 disturbance items=3 → distSum=24 → C5=3', () => { + const a = { + ...psqiAllGood, + psqi5b:3, psqi5c:3, psqi5d:3, psqi5e:3, + psqi5f:3, psqi5g:3, psqi5h:3, psqi5i:3, + }; + expect(PSQI.score(a)).toBe(3); + }); + + it('C6: psqi6=2 contributes 2 to global', () => { + const a = { ...psqiAllGood, psqi6: 2 }; + expect(PSQI.score(a)).toBe(2); + }); + + it('C7: psqi7=2, psqi8=2 → c7raw=4 → C7=2', () => { + const a = { ...psqiAllGood, psqi7: 2, psqi8: 2 }; + expect(PSQI.score(a)).toBe(2); + }); + + it('C7: psqi7=3, psqi8=3 → c7raw=6 → C7=3', () => { + const a = { ...psqiAllGood, psqi7: 3, psqi8: 3 }; + expect(PSQI.score(a)).toBe(3); + }); +}); + +describe('PSQI — score() global reference cases', () => { + it('returns 0 for a best-case "very good sleeper" response', () => { + expect(PSQI.score(psqiAllGood)).toBe(0); + }); + + it('returns 21 for a worst-case response', () => { + // C1=3, C2=3 (sol>60+5a=3), C3=3 (<5h), C4=3 (<65%), C5=3 (dist=24), + // C6=3, C7=3 (7+8=6) + const worst = { + psqi1: { hour: 23, minute: 0 }, + psqi2: 90, // SOL >60 min → solScore=3 + psqi3: { hour: 7, minute: 0 }, + psqi4: 3, // <5h → C3=3 + psqi5a: 3, + psqi5b: 3, psqi5c: 3, psqi5d: 3, psqi5e: 3, + psqi5f: 3, psqi5g: 3, psqi5h: 3, psqi5i: 3, + psqi6: 3, + psqi7: 3, + psqi8: 3, + psqi9: 3, + }; + expect(PSQI.score(worst)).toBe(21); + }); +}); + +describe('PSQI — interpret()', () => { + it('Good sleep quality at score 0', () => expect(PSQI.interpret(0).label).toBe('Good sleep quality')); + it('Good sleep quality at score 4', () => expect(PSQI.interpret(4).label).toBe('Good sleep quality')); + it('Poor sleep quality at score 5', () => expect(PSQI.interpret(5).label).toBe('Poor sleep quality')); + it('Poor sleep quality at score 10', () => expect(PSQI.interpret(10).label).toBe('Poor sleep quality')); + it('Severe sleep difficulties at score 11', () => expect(PSQI.interpret(11).label).toBe('Severe sleep difficulties')); + it('Severe sleep difficulties at score 21', () => expect(PSQI.interpret(21).label).toBe('Severe sleep difficulties')); +}); + +// ─── RU-SATED ───────────────────────────────────────────────────────────────── + +describe('RU-SATED — score()', () => { + it('returns 0 when all items are 0', () => { + const a = {}; + for (let n = 1; n <= 7; n++) a[`rus${n}`] = 0; + expect(RUSATED.score(a)).toBe(0); + }); + + it('returns 14 when all items are 2', () => { + const a = {}; + for (let n = 1; n <= 7; n++) a[`rus${n}`] = 2; + expect(RUSATED.score(a)).toBe(14); + }); + + it('sums item values correctly', () => { + // 0+1+2+0+1+2+1 = 7 + const a = { rus1:0, rus2:1, rus3:2, rus4:0, rus5:1, rus6:2, rus7:1 }; + expect(RUSATED.score(a)).toBe(7); + }); + + it('treats missing items as 0', () => { + expect(RUSATED.score({})).toBe(0); + }); +}); + +describe('RU-SATED — interpret()', () => { + it('Poor sleep health at score 0', () => expect(RUSATED.interpret(0).label).toBe('Poor sleep health')); + it('Poor sleep health at score 4', () => expect(RUSATED.interpret(4).label).toBe('Poor sleep health')); + it('Moderate sleep health at score 5', () => expect(RUSATED.interpret(5).label).toBe('Moderate sleep health')); + it('Moderate sleep health at score 9', () => expect(RUSATED.interpret(9).label).toBe('Moderate sleep health')); + it('Good sleep health at score 10', () => expect(RUSATED.interpret(10).label).toBe('Good sleep health')); + it('Good sleep health at score 14', () => expect(RUSATED.interpret(14).label).toBe('Good sleep health')); +}); + +// ─── STOP-BANG ──────────────────────────────────────────────────────────────── + +describe('STOP-BANG — score()', () => { + it('returns 0 when all answers are "no"', () => { + const a = { sb_s:'no', sb_t:'no', sb_o:'no', sb_p:'no', sb_b:'no', sb_a:'no', sb_n:'no', sb_g:'no' }; + expect(STOPBANG.score(a)).toBe(0); + }); + + it('returns 8 when all answers are "yes"', () => { + const a = { sb_s:'yes', sb_t:'yes', sb_o:'yes', sb_p:'yes', sb_b:'yes', sb_a:'yes', sb_n:'yes', sb_g:'yes' }; + expect(STOPBANG.score(a)).toBe(8); + }); + + it('counts only "yes" answers', () => { + // 3 yes, 5 no + const a = { sb_s:'yes', sb_t:'no', sb_o:'yes', sb_p:'no', sb_b:'yes', sb_a:'no', sb_n:'no', sb_g:'no' }; + expect(STOPBANG.score(a)).toBe(3); + }); + + it('treats missing or non-yes values as 0', () => { + expect(STOPBANG.score({})).toBe(0); + expect(STOPBANG.score({ sb_s: undefined, sb_t: null })).toBe(0); + }); +}); + +describe('STOP-BANG — interpret()', () => { + it('Low OSA risk at score 0', () => expect(STOPBANG.interpret(0).label).toBe('Low OSA risk')); + it('Low OSA risk at score 2', () => expect(STOPBANG.interpret(2).label).toBe('Low OSA risk')); + it('Intermediate OSA risk at score 3', () => expect(STOPBANG.interpret(3).label).toBe('Intermediate OSA risk')); + it('Intermediate OSA risk at score 4', () => expect(STOPBANG.interpret(4).label).toBe('Intermediate OSA risk')); + it('High OSA risk at score 5', () => expect(STOPBANG.interpret(5).label).toBe('High OSA risk')); + it('High OSA risk at score 8', () => expect(STOPBANG.interpret(8).label).toBe('High OSA risk')); +}); + +// ─── MCTQ ───────────────────────────────────────────────────────────────────── + +/** + * Reference computation for a canonical intermediate-chronotype participant: + * Workdays (5/7): + * Bedtime 23:00, SOL 15 min → sleep onset 23:15 + * Wake 07:00 → SD_w = 7h 45m = 7.75h, MSW = 23.25 + 7.75/2 = 27.125 → 3.125 h + * Free days (2/7): + * Bedtime 00:30 (post-midnight) → stored as hour:0, minute:30 → treated as 24.5 + * SOL 15 min → sleep onset 24.75 + * Wake 08:30 → 32.5 (+ 24 because <12 on raw) → SD_f = 32.5 - 24.75 = 7.75h + * MSF = 24.75 + 7.75/2 = 28.625 → 4.625 h + * SD_week = (7.75*5 + 7.75*2)/7 = 7.75, deficit = 7.75 - 7.75 = 0 → MSFsc = MSF = 4.625 + * SJL = |MSF - MSW| = |4.625 - 3.125| = 1.5 h + */ + +const mctqIntermediate = { + mctq_wd: 5, + mctq_bt_w: { hour: 23, minute: 0 }, + mctq_sl_w: 15, + mctq_wt_w: { hour: 7, minute: 0 }, + mctq_bt_f: { hour: 0, minute: 30 }, + mctq_sl_f: 15, + mctq_wt_f: { hour: 8, minute: 30 }, +}; + +describe('MCTQ — score()', () => { + it('returns an object with msf_sc and sjl keys', () => { + const result = MCTQ.score(mctqIntermediate); + expect(result).toHaveProperty('msf_sc'); + expect(result).toHaveProperty('sjl'); + }); + + it('computes MSFsc ≈ 4.63 for the intermediate reference case', () => { + const { msf_sc } = MCTQ.score(mctqIntermediate); + expect(msf_sc).toBeCloseTo(4.63, 1); + }); + + it('computes SJL ≈ 1.5 h for the intermediate reference case', () => { + const { sjl } = MCTQ.score(mctqIntermediate); + expect(sjl).toBeCloseTo(1.5, 1); + }); + + it('returns zero SJL when workday and free-day schedules are identical', () => { + const a = { + mctq_wd: 5, + mctq_bt_w: { hour: 23, minute: 0 }, + mctq_sl_w: 15, + mctq_wt_w: { hour: 7, minute: 0 }, + mctq_bt_f: { hour: 23, minute: 0 }, + mctq_sl_f: 15, + mctq_wt_f: { hour: 7, minute: 0 }, + }; + // Both schedules identical → SJL should be 0 + expect(MCTQ.score(a).sjl).toBeCloseTo(0, 2); + }); + + it('handles 0 workdays (only free days) without crashing', () => { + const a = { ...mctqIntermediate, mctq_wd: 0 }; + expect(() => MCTQ.score(a)).not.toThrow(); + }); + + it('handles 7 workdays (no free days) without crashing', () => { + const a = { ...mctqIntermediate, mctq_wd: 7 }; + expect(() => MCTQ.score(a)).not.toThrow(); + }); + + it('MSFsc is within the 0–24 clock range', () => { + const { msf_sc } = MCTQ.score(mctqIntermediate); + expect(msf_sc).toBeGreaterThanOrEqual(0); + expect(msf_sc).toBeLessThan(24); + }); +}); + +describe('MCTQ — interpret()', () => { + // interpret receives the score object { msf_sc, sjl } + it('Extremely early chronotype when msf_sc < 0.5', () => { + expect(MCTQ.interpret({ msf_sc: 0.3, sjl: 0 }).label).toBe('Extremely early chronotype'); + }); + + it('Early chronotype when msf_sc = 1.5', () => { + expect(MCTQ.interpret({ msf_sc: 1.5, sjl: 0 }).label).toBe('Early chronotype'); + }); + + it('Intermediate chronotype when msf_sc = 3.0', () => { + expect(MCTQ.interpret({ msf_sc: 3.0, sjl: 0 }).label).toBe('Intermediate chronotype'); + }); + + it('Late chronotype when msf_sc = 4.63 (reference case)', () => { + expect(MCTQ.interpret({ msf_sc: 4.63, sjl: 1.5 }).label).toBe('Late chronotype'); + }); + + it('Extremely late chronotype when msf_sc = 6.0', () => { + expect(MCTQ.interpret({ msf_sc: 6.0, sjl: 2 }).label).toBe('Extremely late chronotype'); + }); + + it('description mentions social jetlag value', () => { + const { description } = MCTQ.interpret({ msf_sc: 3.0, sjl: 2.5 }); + expect(description).toContain('2.5'); + }); +}); From 2733034378272648b4e5d8f1ea40409ed67d1b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Fran=C3=A7a?= Date: Thu, 30 Apr 2026 20:26:37 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A7=AA=20test(meq):=20clarify=20max?= =?UTF-8?q?=20score=20discrepancy=20=E2=80=94=20fix=20belongs=20in=20spec?= =?UTF-8?q?=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/questionnaires.test.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/__tests__/questionnaires.test.js b/__tests__/questionnaires.test.js index 99837cd..67afcdb 100644 --- a/__tests__/questionnaires.test.js +++ b/__tests__/questionnaires.test.js @@ -155,9 +155,11 @@ describe('MEQ — score()', () => { it('returns 86 for a fully morning-type response (max options per item)', () => { // Published MEQ maximum is 86 (Horne & Östberg, 1976). - // NOTE: this test currently fails — the implementation scores 83 because - // some item option weights deviate from the original instrument. - // This test is intentionally left failing to flag the discrepancy. + // NOTE: this test currently fails — the implementation scores 83. + // The 3-point gap is due to items 1, 2, and 10 having 5 response options + // in the current spec (circadia-bio/sleep-questionnaires) rather than the + // 6-option versions in the original paper. The fix belongs in the spec repo; + // this test is left failing to flag the discrepancy. const maxA = { meq1: 5, meq2: 5, meq3: 4, meq4: 4, meq5: 4, meq6: 4, meq7: 4, meq8: 4, meq9: 4, meq10: 5, From 8360a22fc95329210f07b83ff4ddc93eb51722ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Fran=C3=A7a?= Date: Thu, 30 Apr 2026 20:27:36 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=A7=20chore:=20sync=20package-lock?= =?UTF-8?q?.json=20to=20v1.1.0-beta.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 914309e..7fe87a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sleepdiaries", - "version": "1.0.0-beta.1", + "version": "1.1.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sleepdiaries", - "version": "1.0.0-beta.1", + "version": "1.1.0-beta.2", "dependencies": { "@expo/metro-runtime": "~55.0.6", "@expo/vector-icons": "^15.0.2", From deacdfb8bcd23a17702d6eadbfe185ff4e155367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20Fran=C3=A7a?= Date: Thu, 30 Apr 2026 20:45:28 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20fix(meq):=20correct=20item?= =?UTF-8?q?=2011=20and=2012=20option=20scores=20to=20match=20original=20pa?= =?UTF-8?q?per?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/questionnaires.test.js | 9 +++------ data/questionnaires.js | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/__tests__/questionnaires.test.js b/__tests__/questionnaires.test.js index 67afcdb..56ee9cf 100644 --- a/__tests__/questionnaires.test.js +++ b/__tests__/questionnaires.test.js @@ -155,15 +155,12 @@ describe('MEQ — score()', () => { it('returns 86 for a fully morning-type response (max options per item)', () => { // Published MEQ maximum is 86 (Horne & Östberg, 1976). - // NOTE: this test currently fails — the implementation scores 83. - // The 3-point gap is due to items 1, 2, and 10 having 5 response options - // in the current spec (circadia-bio/sleep-questionnaires) rather than the - // 6-option versions in the original paper. The fix belongs in the spec repo; - // this test is left failing to flag the discrepancy. + // Items 11 and 12 use non-sequential scoring (6/4/2/0 and 0/2/3/5 respectively) + // per the original paper. const maxA = { meq1: 5, meq2: 5, meq3: 4, meq4: 4, meq5: 4, meq6: 4, meq7: 4, meq8: 4, meq9: 4, meq10: 5, - meq11: 4, meq12: 4, meq13: 4, meq14: 4, meq15: 4, + meq11: 6, meq12: 5, meq13: 4, meq14: 4, meq15: 4, meq16: 4, meq17: 5, meq18: 5, meq19: 6, }; expect(MEQ.score(maxA)).toBe(86); diff --git a/data/questionnaires.js b/data/questionnaires.js index 920d6d3..2664e87 100644 --- a/data/questionnaires.js +++ b/data/questionnaires.js @@ -298,11 +298,11 @@ export const MEQ = { }, { id: 'meq11', number: 11, text: 'You want to be at your peak for a 2-hour mentally exhausting test. Which testing time would you choose?', type: 'single_choice', - options: [{ value: 4, label: '8:00–10:00 AM' }, { value: 3, label: '11:00 AM–1:00 PM' }, { value: 2, label: '3:00–5:00 PM' }, { value: 1, label: '7:00–9:00 PM' }], + options: [{ value: 6, label: '8:00–10:00 AM' }, { value: 4, label: '11:00 AM–1:00 PM' }, { value: 2, label: '3:00–5:00 PM' }, { value: 0, label: '7:00–9:00 PM' }], }, { id: 'meq12', number: 12, text: 'If you went to bed at 11:00 PM, at what level of tiredness would you be?', type: 'single_choice', - options: [{ value: 1, label: 'Not at all tired' }, { value: 2, label: 'A little tired' }, { value: 3, label: 'Fairly tired' }, { value: 4, label: 'Very tired' }], + options: [{ value: 0, label: 'Not at all tired' }, { value: 2, label: 'A little tired' }, { value: 3, label: 'Fairly tired' }, { value: 5, label: 'Very tired' }], }, { id: 'meq13', number: 13, text: 'You have gone to bed several hours later than usual, but there is no need to get up at any particular time the next morning. Which is most likely?', type: 'single_choice',