diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9ecb9b3..e8a0574 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta ## Current Status: Feature Complete -**Test Coverage:** 1014 unit tests (51 files) + 175 E2E tests (12 files) = 1189 total tests +**Test Coverage:** 1014 unit tests (51 files) + 180 E2E tests (12 files) = 1194 total tests All P0-P5 items are complete. The project is feature complete. @@ -97,16 +97,16 @@ All P0-P5 items are complete. The project is feature complete. | PeriodDateModal | 22 | Period input modal | | Skeletons | 29 | Loading states with shimmer | -### E2E Tests (12 files, 175 tests) +### E2E Tests (12 files, 180 tests) | File | Tests | Coverage | |------|-------|----------| | smoke.spec.ts | 3 | Basic app functionality | | auth.spec.ts | 14 | Login, protected routes | -| dashboard.spec.ts | 24 | Dashboard display, overrides | -| settings.spec.ts | 15 | Settings form, validation | +| dashboard.spec.ts | 40 | Dashboard display, overrides, accessibility | +| settings.spec.ts | 26 | Settings form, validation, persistence | | garmin.spec.ts | 12 | Garmin connection, expiry warnings | | period-logging.spec.ts | 19 | Period history, logging, modal flows | -| calendar.spec.ts | 21 | Calendar view, ICS feed | +| calendar.spec.ts | 30 | Calendar view, ICS feed, content validation | | decision-engine.spec.ts | 8 | Decision priority chain | | cycle.spec.ts | 11 | Cycle tracking | | history.spec.ts | 7 | History page | @@ -130,8 +130,8 @@ These are optional enhancements to improve E2E coverage. Not required for featur | File | Additional Tests | Focus Area | |------|------------------|------------| | auth.spec.ts | +6 | OIDC flow, session persistence | -| calendar.spec.ts | +13 | ICS content validation, responsive | -| settings.spec.ts | +6 | Persistence, timezone changes | +| calendar.spec.ts | +4 | Responsive behavior, accessibility | +| settings.spec.ts | +1 | Error recovery on failed save | | garmin.spec.ts | +4 | Token refresh, network error recovery | --- @@ -149,6 +149,7 @@ These are optional enhancements to improve E2E coverage. Not required for featur ## Revision History +- 2026-01-13: Added 5 settings persistence E2E tests (notification time, timezone, multi-field persistence) - 2026-01-13: Added 5 period-logging E2E tests (modal flow, future date restriction, edit/delete flows) - 2026-01-13: Added 5 Garmin E2E tests (expiry warnings, expired state, persistence, reconnection) - 2026-01-13: Condensed plan after feature completion (reduced from 1514 to ~170 lines) diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index aebb168..57f8e69 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -414,5 +414,241 @@ test.describe("settings", () => { const isDisabledAfter = await saveButton.isDisabled(); expect(isDisabledAfter).toBe(false); }); + + test("notification time changes persist after page reload", async ({ + page, + }) => { + const notificationTimeInput = page.getByLabel(/notification time/i); + const isVisible = await notificationTimeInput + .isVisible() + .catch(() => false); + + if (!isVisible) { + test.skip(); + return; + } + + // Get current value + const originalValue = await notificationTimeInput.inputValue(); + + // Set a different valid time (toggle between 08:00 and 09:00) + const newValue = originalValue === "08:00" ? "09:00" : "08:00"; + await notificationTimeInput.fill(newValue); + + // Save + const saveButton = page.getByRole("button", { name: /save/i }); + await saveButton.click(); + await page.waitForTimeout(1500); + + // Reload the page + await page.reload(); + await page.waitForLoadState("networkidle"); + + // Check the value persisted + const notificationTimeAfter = page.getByLabel(/notification time/i); + const afterValue = await notificationTimeAfter.inputValue(); + + expect(afterValue).toBe(newValue); + + // Restore original value + await notificationTimeAfter.fill(originalValue); + await saveButton.click(); + await page.waitForTimeout(500); + }); + + test("timezone changes persist after page reload", async ({ page }) => { + const timezoneInput = page.getByLabel(/timezone/i); + const isVisible = await timezoneInput.isVisible().catch(() => false); + + if (!isVisible) { + test.skip(); + return; + } + + // Get current value + const originalValue = await timezoneInput.inputValue(); + + // Set a different timezone (toggle between two common timezones) + const newValue = + originalValue === "America/New_York" + ? "America/Los_Angeles" + : "America/New_York"; + await timezoneInput.fill(newValue); + + // Save + const saveButton = page.getByRole("button", { name: /save/i }); + await saveButton.click(); + await page.waitForTimeout(1500); + + // Reload the page + await page.reload(); + await page.waitForLoadState("networkidle"); + + // Check the value persisted + const timezoneAfter = page.getByLabel(/timezone/i); + const afterValue = await timezoneAfter.inputValue(); + + expect(afterValue).toBe(newValue); + + // Restore original value + await timezoneAfter.fill(originalValue); + await saveButton.click(); + await page.waitForTimeout(500); + }); + + test("multiple settings changes persist after page reload", async ({ + page, + }) => { + const cycleLengthInput = page.getByLabel(/cycle length/i); + const notificationTimeInput = page.getByLabel(/notification time/i); + const timezoneInput = page.getByLabel(/timezone/i); + + const cycleLengthVisible = await cycleLengthInput + .isVisible() + .catch(() => false); + const notificationTimeVisible = await notificationTimeInput + .isVisible() + .catch(() => false); + const timezoneVisible = await timezoneInput + .isVisible() + .catch(() => false); + + if (!cycleLengthVisible || !notificationTimeVisible || !timezoneVisible) { + test.skip(); + return; + } + + // Get all original values + const originalCycleLength = await cycleLengthInput.inputValue(); + const originalNotificationTime = await notificationTimeInput.inputValue(); + const originalTimezone = await timezoneInput.inputValue(); + + // Set different values for all fields + const newCycleLength = originalCycleLength === "28" ? "30" : "28"; + const newNotificationTime = + originalNotificationTime === "08:00" ? "09:00" : "08:00"; + const newTimezone = + originalTimezone === "America/New_York" + ? "America/Los_Angeles" + : "America/New_York"; + + await cycleLengthInput.fill(newCycleLength); + await notificationTimeInput.fill(newNotificationTime); + await timezoneInput.fill(newTimezone); + + // Save all changes at once + const saveButton = page.getByRole("button", { name: /save/i }); + await saveButton.click(); + await page.waitForTimeout(1500); + + // Reload the page + await page.reload(); + await page.waitForLoadState("networkidle"); + + // Verify all values persisted + const cycleLengthAfter = page.getByLabel(/cycle length/i); + const notificationTimeAfter = page.getByLabel(/notification time/i); + const timezoneAfter = page.getByLabel(/timezone/i); + + expect(await cycleLengthAfter.inputValue()).toBe(newCycleLength); + expect(await notificationTimeAfter.inputValue()).toBe( + newNotificationTime, + ); + expect(await timezoneAfter.inputValue()).toBe(newTimezone); + + // Restore all original values + await cycleLengthAfter.fill(originalCycleLength); + await notificationTimeAfter.fill(originalNotificationTime); + await timezoneAfter.fill(originalTimezone); + await saveButton.click(); + await page.waitForTimeout(500); + }); + + test("cycle length persistence verifies exact saved value", async ({ + page, + }) => { + const cycleLengthInput = page.getByLabel(/cycle length/i); + const isVisible = await cycleLengthInput.isVisible().catch(() => false); + + if (!isVisible) { + test.skip(); + return; + } + + // Get current value + const originalValue = await cycleLengthInput.inputValue(); + + // Set a specific different valid value + const newValue = originalValue === "28" ? "31" : "28"; + await cycleLengthInput.fill(newValue); + + // Save + const saveButton = page.getByRole("button", { name: /save/i }); + await saveButton.click(); + await page.waitForTimeout(1500); + + // Reload the page + await page.reload(); + await page.waitForLoadState("networkidle"); + + // Check the exact value persisted (not just range validation) + const cycleLengthAfter = page.getByLabel(/cycle length/i); + const afterValue = await cycleLengthAfter.inputValue(); + + expect(afterValue).toBe(newValue); + + // Restore original value + await cycleLengthAfter.fill(originalValue); + await saveButton.click(); + await page.waitForTimeout(500); + }); + + test("settings form shows correct values after save without reload", async ({ + page, + }) => { + const cycleLengthInput = page.getByLabel(/cycle length/i); + const notificationTimeInput = page.getByLabel(/notification time/i); + + const cycleLengthVisible = await cycleLengthInput + .isVisible() + .catch(() => false); + const notificationTimeVisible = await notificationTimeInput + .isVisible() + .catch(() => false); + + if (!cycleLengthVisible || !notificationTimeVisible) { + test.skip(); + return; + } + + // Get original values + const originalCycleLength = await cycleLengthInput.inputValue(); + const originalNotificationTime = await notificationTimeInput.inputValue(); + + // Change values + const newCycleLength = originalCycleLength === "28" ? "29" : "28"; + const newNotificationTime = + originalNotificationTime === "08:00" ? "10:00" : "08:00"; + + await cycleLengthInput.fill(newCycleLength); + await notificationTimeInput.fill(newNotificationTime); + + // Save + const saveButton = page.getByRole("button", { name: /save/i }); + await saveButton.click(); + await page.waitForTimeout(1500); + + // Verify values are still showing the new values without reload + expect(await cycleLengthInput.inputValue()).toBe(newCycleLength); + expect(await notificationTimeInput.inputValue()).toBe( + newNotificationTime, + ); + + // Restore original values + await cycleLengthInput.fill(originalCycleLength); + await notificationTimeInput.fill(originalNotificationTime); + await saveButton.click(); + await page.waitForTimeout(500); + }); }); });