From a9ad014bd159712f1520949c958fad2f4d4ec864 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Fri, 8 May 2026 14:22:46 -0400 Subject: [PATCH] test: tighten coverage to 99.69% backend / 98.62% frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Started: 89.08% backend / 97.01% frontend lines. Landed: 99.69% backend / 98.62% frontend. Closed gaps targeted at logic gates, branches, and assumption boundaries that real users hit. Each test exercises a use case the production code actually serves; nothing here is line-padding. Backend additions: - DeviceModelTest: pin landscape vs portrait dimension swap, plus the nativeWidth/Height "ignore orientation" contract the firmware relies on. - DeviceApiControllerTest: validation branches the PWA forms can't even produce (raw API misuse) — non-array wakeTimes, non-int entries, invalid rotation mode, invalid timezone, empty name, invalid orientation, other-user PATCH returns 404. Plus full /preview coverage: 404 for other-user / no-current / no-asset / missing-file / soft-deleted, and happy paths for landscape AND portrait (the rotateImage(90) branch). - ImageApiControllerTest: cropOrientation now exercised on both upload and reprocess paths. - TokenActionControllerTest: TK-01c covers the bad-device-id "continue" branch in submit. - RenderImageMessageHandlerTest: explicit portrait test pins the rotateImage(-90) branch and the 192,000-byte EPD-native bin shape. - SeedFakeDevicesCommandTest: 4 cases covering missing-user, fresh create, idempotent re-run, and --remove path. The dev seed command is load-bearing for the multi-frame UI; a silent break would surface a week later. - RerenderAssetsCommandTest: reset + dispatch path, no-assets path. Frontend additions: - FrameCardTest: lastSync-only and nextSync-only rendering branches. - HomeView.test: * + Add time fallback path when all 9 default candidates are taken. * Multi-day "in Nd" nextSync formatting (offline / huge-interval case). * Medium-horizon (5h) nextSync formats as clock-time + day label. * visibilitychange triggers a silent re-fetch. * add-photo handler creates input + navigates to /upload after pick. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/test/components/FrameCard.test.ts | 23 ++ frontend/src/test/views/HomeView.test.ts | 129 ++++++++ .../Controller/DeviceApiControllerTest.php | 280 ++++++++++++++++++ .../Controller/ImageApiControllerTest.php | 4 + .../Controller/TokenActionControllerTest.php | 38 +++ .../Command/RerenderAssetsCommandTest.php | 80 +++++ .../Command/SeedFakeDevicesCommandTest.php | 88 ++++++ .../RenderImageMessageHandlerTest.php | 32 ++ tests/Unit/Enum/DeviceModelTest.php | 38 +++ 9 files changed, 712 insertions(+) create mode 100644 tests/Integration/Command/RerenderAssetsCommandTest.php create mode 100644 tests/Integration/Command/SeedFakeDevicesCommandTest.php create mode 100644 tests/Unit/Enum/DeviceModelTest.php diff --git a/frontend/src/test/components/FrameCard.test.ts b/frontend/src/test/components/FrameCard.test.ts index c9afa75..a92ce18 100644 --- a/frontend/src/test/components/FrameCard.test.ts +++ b/frontend/src/test/components/FrameCard.test.ts @@ -53,6 +53,29 @@ describe('FrameCard', () => { expect(wrapper.find('.frame-card__sync-line').exists()).toBe(false) }) + // Cover the lastSync-without-nextSync branch: only the "synced X ago" span + // renders, no separator, no nextSync. + it('renders only lastSync when nextSync is absent', () => { + const wrapper = mount(FrameCard, { + props: { ...defaultProps, lastSync: '5m ago' }, + }) + const sync = wrapper.find('.frame-card__sync-line') + expect(sync.text()).toContain('synced 5m ago') + expect(sync.find('.frame-card__sync-sep').exists()).toBe(false) + }) + + // Inverse: nextSync without lastSync (a never-seen device that already has + // wakeTimes configured — the card should still preview the next slot). + it('renders only nextSync when lastSync is absent', () => { + const wrapper = mount(FrameCard, { + props: { ...defaultProps, nextSync: 'next sync ~6 AM tomorrow' }, + }) + const sync = wrapper.find('.frame-card__sync-line') + expect(sync.exists()).toBe(true) + expect(sync.text()).toContain('next sync ~6 AM tomorrow') + expect(sync.text()).not.toContain('synced') + }) + it('applies offline modifier class when status is offline', () => { const wrapper = mount(FrameCard, { props: { ...defaultProps, status: 'offline' }, diff --git a/frontend/src/test/views/HomeView.test.ts b/frontend/src/test/views/HomeView.test.ts index 4cf64a8..577026c 100644 --- a/frontend/src/test/views/HomeView.test.ts +++ b/frontend/src/test/views/HomeView.test.ts @@ -533,6 +533,41 @@ describe('HomeView', () => { // list and stay there during editing. Sorting only happens at save time // (server-side via setWakeTimes), so the user can always see "the one I // just added" without it jumping around. + // When all 9 DEFAULT_TIME_CANDIDATES are already in the list, addTime falls + // through to the 5-minute-step fallback loop. Pre-fill with all 9 defaults + // so the next +Add hits that branch. + it('+ Add time falls back to the next free 5-minute slot when defaults are exhausted', async () => { + const devicesStore = useDevicesStore() + const allDefaults = [ + 9 * 60, 18 * 60, 12 * 60, 21 * 60, 6 * 60, + 15 * 60, 7 * 60 + 30, 19 * 60 + 30, 0, + ] + devicesStore.devices = [makeDevice({ id: 5, wakeTimes: allDefaults })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 })) + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + + const before = wrapper.findAll('.home-view__time-row').length + await wrapper.find('.home-view__time-add').trigger('click') + await flushPromises() + + // Fallback added one more (the next free 5-min slot — first one not in + // the defaults set, which is `5` minutes since 0 is already there). + expect(wrapper.findAll('.home-view__time-row')).toHaveLength(before + 1) + + const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' }) + .find(b => b.text().toLowerCase().includes('sav'))! + await saveBtn.trigger('click') + await flushPromises() + expect(devicesStore.updateDevice).toHaveBeenCalledWith(5, expect.objectContaining({ + wakeTimes: [...allDefaults, 5], + })) + }) + it('+ Add time appends to the end and does NOT reorder existing entries', async () => { const devicesStore = useDevicesStore() // 8 PM is later than the first default candidate (9 AM). If the list @@ -686,6 +721,100 @@ describe('HomeView', () => { // The "next update" preview's legacy fallback path: device has lastSeenAt // but no nextPollExpectedAt yet (e.g. polled once before that column was // added). Should still produce a sensible label using lastSeenAt + interval. + // Multi-day nextSync (> 24h) — covers the "in Nd" return at the bottom + // of nextSyncLabel. Realistically a device that's been offline / never + // configured a wake time and has a huge interval set. + it('nextSync formats multi-day delays as "in Nd"', async () => { + const devicesStore = useDevicesStore() + const threeDaysOut = new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString() + devicesStore.devices = [makeDevice({ + id: 1, + wakeTimes: [], + rotationIntervalMinutes: 3 * 24 * 60, + lastSeenAt: new Date().toISOString(), + nextPollExpectedAt: threeDaysOut, + })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + const wrapper = mountView() + await flushPromises() + expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/in 3d/) + }) + + // Long-horizon nextSync: 5 hours away — covers the 1h-to-24h branch in + // nextSyncLabel that picks clock-time formatting over "in 5h". + it('nextSync formats medium-horizon delays with a clock time + day label', async () => { + const devicesStore = useDevicesStore() + const fiveHoursOut = new Date(Date.now() + 5 * 60 * 60_000).toISOString() + devicesStore.devices = [makeDevice({ + id: 1, + wakeTimes: [], + rotationIntervalMinutes: 5 * 60, + lastSeenAt: new Date().toISOString(), + nextPollExpectedAt: fiveHoursOut, + timezone: 'UTC', + })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + const wrapper = mountView() + await flushPromises() + + const next = wrapper.findComponent({ name: 'FrameCard' }).props('nextSync') as string + // Should NOT be the short-horizon "in Xm" form — we want clock-time. + expect(next).toMatch(/^next sync ~\d/) + // Day label is either today or tomorrow depending on wall-clock at run time. + expect(next).toMatch(/(today|tomorrow)/) + }) + + // Visibility-change handler triggers a silent fetchDevices when the PWA + // returns to the foreground. + it('foreground visibility-change re-fetches devices silently', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ id: 1 })] + const fetchSpy = vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + mountView() + await flushPromises() + fetchSpy.mockClear() // ignore the onMounted call + + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + document.dispatchEvent(new Event('visibilitychange')) + await flushPromises() + + expect(fetchSpy).toHaveBeenCalledWith({ silent: true }) + }) + + // Add-photo handler creates a hidden file input and (on file pick) navigates + // to /upload with the staged file in the upload store. + it('add-photo opens a file picker and navigates after a file is chosen', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ id: 7 })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + + let capturedInput: HTMLInputElement | null = null + const origCreate = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = origCreate(tag) as HTMLInputElement + if (tag === 'input') capturedInput = el + return el + }) + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('add-photo', 7) + + expect(capturedInput).not.toBeNull() + expect(capturedInput!.type).toBe('file') + + // Simulate the user picking a file. + const file = new File(['x'], 'pic.jpg', { type: 'image/jpeg' }) + Object.defineProperty(capturedInput!, 'files', { value: [file], configurable: true }) + capturedInput!.onchange?.(new Event('change')) + await flushPromises() + + expect(routerPush).toHaveBeenCalledWith('/upload') + }) + it('next-update preview falls back to lastSeenAt+interval when nextPollExpectedAt is null', async () => { const devicesStore = useDevicesStore() devicesStore.devices = [makeDevice({ diff --git a/tests/Functional/Controller/DeviceApiControllerTest.php b/tests/Functional/Controller/DeviceApiControllerTest.php index 4352163..e77612b 100644 --- a/tests/Functional/Controller/DeviceApiControllerTest.php +++ b/tests/Functional/Controller/DeviceApiControllerTest.php @@ -311,4 +311,284 @@ class DeviceApiControllerTest extends AppWebTestCase $this->assertResponseStatusCodeSame(404); } + + // ── Validation branch coverage for PATCH /api/devices/{id} ─────────── + + public function test_patch_rejects_non_array_wakeTimes(): void + { + $user = $this->createUser('vbad-array@example.com'); + $device = $this->makeDevice('AA:BB:CC:DD:EE:D1', $user); + $client = $this->loginAs($user); + $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['wakeTimes' => 'not-an-array'])); + $this->assertResponseStatusCodeSame(422); + } + + public function test_patch_rejects_non_integer_wakeTimes_entries(): void + { + $user = $this->createUser('vbad-non-int@example.com'); + $device = $this->makeDevice('AA:BB:CC:DD:EE:D2', $user); + $client = $this->loginAs($user); + // Float-as-string is neither int nor digit-only-string → rejected. + $client->request('PATCH', '/api/devices/' . $device->getId(), [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode(['wakeTimes' => ['1.5']])); + $this->assertResponseStatusCodeSame(422); + } + + public function test_patch_accepts_digit_string_wakeTimes_for_pwa_form_submits(): void + { + // The PWA's