cf6623de67
CI / test (push) Has been cancelled
Adds two settings exposed in the PWA frame-settings sheet:
- rotationMode (enum: random | least_recently_shown | oldest_upload |
newest_upload). Default oldest_upload preserves the legacy
hard-coded sort, so existing devices behave identically until the
user changes it.
- prioritizeNeverShown (bool). When set, the candidate set is narrowed
to never-shown images first (if any exist) before the mode runs —
useful for "burn through new uploads before re-shuffling the catalog."
RotationService pipeline:
1. Pull approved/ready pool.
2. Drop the last `uniquenessWindow` served (existing).
3. If prioritizeNeverShown AND any candidates have never been served,
narrow to those.
4. Apply the selection mode.
Backend: enum, entity columns + accessors, migration, serializer,
PATCH validator. Frontend: types, stores, settings sheet section
(dropdown + checkbox), test fixtures, save-flow test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.4 KiB
TypeScript
157 lines
5.4 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { mount } from '@vue/test-utils'
|
|
import DevicePicker from '@/components/DevicePicker.vue'
|
|
import type { Device } from '@/types'
|
|
|
|
// Stub child components DevicePicker wraps
|
|
vi.mock('@/components/BaseBottomSheet.vue', () => ({
|
|
default: {
|
|
name: 'BaseBottomSheet',
|
|
template: '<div class="bottom-sheet-stub"><slot /></div>',
|
|
props: ['modelValue', 'label'],
|
|
emits: ['update:modelValue'],
|
|
},
|
|
}))
|
|
vi.mock('@/components/BaseButton.vue', () => ({
|
|
default: {
|
|
name: 'BaseButton',
|
|
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
|
props: ['variant', 'disabled'],
|
|
emits: ['click'],
|
|
},
|
|
}))
|
|
|
|
const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
|
id: 1,
|
|
mac: 'AA:BB:CC:DD:EE:FF',
|
|
name: 'Living Room',
|
|
orientation: 'landscape',
|
|
rotationIntervalMinutes: 60,
|
|
wakeTimes: [],
|
|
timezone: 'America/Chicago',
|
|
uniquenessWindow: 30,
|
|
rotationMode: 'oldest_upload',
|
|
prioritizeNeverShown: false,
|
|
linkedAt: '2026-01-01T00:00:00Z',
|
|
lastSeenAt: null,
|
|
nextPollExpectedAt: null,
|
|
lockedImageId: null,
|
|
currentImageId: null,
|
|
...overrides,
|
|
})
|
|
|
|
describe('DevicePicker', () => {
|
|
const devices = [
|
|
makeDevice({ id: 1, name: 'Living Room' }),
|
|
makeDevice({ id: 2, name: 'Bedroom' }),
|
|
]
|
|
|
|
function mountPicker(selected: number[] = []) {
|
|
return mount(DevicePicker, {
|
|
props: {
|
|
modelValue: true,
|
|
devices,
|
|
selected,
|
|
},
|
|
})
|
|
}
|
|
|
|
// DP-01: Selecting a device emits update:selected with the device added
|
|
it('checking a device emits update:selected with device id added', async () => {
|
|
const wrapper = mountPicker([])
|
|
|
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
|
// Click the first checkbox (Living Room, id=1)
|
|
await checkboxes[0].trigger('change')
|
|
|
|
const emitted = wrapper.emitted('update:selected')
|
|
expect(emitted).toBeTruthy()
|
|
expect(emitted![0][0]).toEqual([1])
|
|
})
|
|
|
|
// DP-02: Deselecting a device emits update:selected with device id removed
|
|
it('unchecking a device emits update:selected with device id removed', async () => {
|
|
// Start with both selected
|
|
const wrapper = mountPicker([1, 2])
|
|
|
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
|
// Click the first checkbox (Living Room, id=1) — it's currently checked, so this deselects
|
|
await checkboxes[0].trigger('change')
|
|
|
|
const emitted = wrapper.emitted('update:selected')
|
|
expect(emitted).toBeTruthy()
|
|
// Should emit [2] — Living Room removed
|
|
expect(emitted![0][0]).toEqual([2])
|
|
})
|
|
|
|
// DP-03: Checkboxes reflect the selected prop
|
|
it('checkboxes are checked for ids in selected prop', async () => {
|
|
const wrapper = mountPicker([2])
|
|
|
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
|
expect((checkboxes[0].element as HTMLInputElement).checked).toBe(false) // id=1 not selected
|
|
expect((checkboxes[1].element as HTMLInputElement).checked).toBe(true) // id=2 selected
|
|
})
|
|
|
|
// DP-04: Confirm button disabled when nothing selected
|
|
it('confirm button is disabled when selected is empty', async () => {
|
|
const wrapper = mountPicker([])
|
|
const btn = wrapper.find('button')
|
|
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
|
|
// DP-05: Confirm button enabled when at least one device selected
|
|
it('confirm button is enabled when a device is selected', async () => {
|
|
const wrapper = mountPicker([1])
|
|
const btn = wrapper.find('button')
|
|
expect((btn.element as HTMLButtonElement).disabled).toBe(false)
|
|
})
|
|
|
|
// DP-06: Device names are rendered
|
|
it('renders all device names', () => {
|
|
const wrapper = mountPicker([])
|
|
expect(wrapper.text()).toContain('Living Room')
|
|
expect(wrapper.text()).toContain('Bedroom')
|
|
})
|
|
|
|
// DP-07: Clicking the enabled confirm button emits 'confirm'
|
|
it('emits confirm when the confirm button is clicked', async () => {
|
|
const wrapper = mountPicker([1])
|
|
await wrapper.find('button').trigger('click')
|
|
expect(wrapper.emitted('confirm')).toBeTruthy()
|
|
})
|
|
|
|
// DP-08: Confirm label adapts to selection count (singular / plural / none)
|
|
it('renders the singular confirm label for one selected device', () => {
|
|
const wrapper = mountPicker([1])
|
|
expect(wrapper.find('button').text()).toBe('Add to 1 frame')
|
|
})
|
|
|
|
it('renders the plural confirm label for multiple selected devices', () => {
|
|
const wrapper = mountPicker([1, 2])
|
|
expect(wrapper.find('button').text()).toBe('Add to 2 frames')
|
|
})
|
|
|
|
it('renders the no-selection confirm label when nothing is picked', () => {
|
|
const wrapper = mountPicker([])
|
|
expect(wrapper.find('button').text()).toBe('Add to frame')
|
|
})
|
|
|
|
// DP-09: Uploading state — label changes and button is disabled
|
|
it('shows uploading label and disables the button while uploading', () => {
|
|
const wrapper = mount(DevicePicker, {
|
|
props: { modelValue: true, devices, selected: [1], uploading: true },
|
|
})
|
|
const btn = wrapper.find('button')
|
|
expect(btn.text()).toBe('Uploading…')
|
|
expect((btn.element as HTMLButtonElement).disabled).toBe(true)
|
|
})
|
|
|
|
// DP-10: Forwards update:modelValue from the wrapped sheet
|
|
it('forwards update:modelValue from the wrapped sheet', async () => {
|
|
const wrapper = mountPicker([])
|
|
await wrapper.findComponent({ name: 'BaseBottomSheet' }).vm.$emit('update:modelValue', false)
|
|
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
|
})
|
|
})
|