fix(timetable): make ?day query the source of truth with validation and fallback
Per Phase A finding A6 — the previous three-watcher Pinia-store design had
no validation. Landing on /events/{e}/timetable?day=DOES_NOT_EXIST quietly
set store.activeDayId to that bogus value and showed an empty page.
Cross-org sub-event IDs were silently accepted (backend OrganisationScope
returned an empty perf list, so the UI looked broken without telling the
user).
New design (Session 4 follow-up Step 5):
- src/composables/timetable/useActiveDay.ts (NEW)
- The URL `?day` is the source of truth; Pinia does NOT hold this value.
- `activeDayId` is a computed: queryDay if it appears in `validIds`,
else the first valid id, else null when the list is empty.
- One corrective watcher (immediate:true, flush:'post') quietly rewrites
the URL when `?day` is missing or invalid; runs after Vue settles and
after validIds has been recomputed from a fresh fetch.
- `setActiveDay(id)` is the user-driven entry point — calls replace().
- Cross-org IDs are blocked transparently: OrganisationScope keeps them
out of validIds, so they fail the .includes() check and fall back.
- src/stores/useTimetableStore.ts
- Removed `activeDayId` state and `setActiveDay()` action; the store
docstring now documents that day-state lives at the URL.
- src/pages/events/[id]/timetable/index.vue
- Replaced the three watchers + onMounted bootstrap with one
`useActiveDay({ queryDay, validIds, replace })` call. The day-change
side-effect watcher (clear drag, deselect performance) stays.
- VTabs binds dayIdRef + setActiveDay directly.
- tests/unit/pages/timetableDaySync.test.ts (NEW, 9 tests)
- Valid ?day=X → activeDayId=X, no URL rewrite.
- Missing / invalid / cross-org ?day → fallback + URL replaced once.
- Empty validIds → activeDayId=null, URL untouched.
- setActiveDay(id) → calls replace.
- setActiveDay(null) → no-op.
- External URL change (browser back) → activeDayId follows.
- validIds populated AFTER mount → fallback fires correctly.
- tests/unit/stores/useTimetableStore.test.ts: assert that activeDayId
and setActiveDay are GONE from the store surface.
Test count: 324 → 333.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
165
apps/app/tests/unit/pages/timetableDaySync.test.ts
Normal file
165
apps/app/tests/unit/pages/timetableDaySync.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { useActiveDay } from '@/composables/timetable/useActiveDay'
|
||||
|
||||
/**
|
||||
* The `?day` source-of-truth contract (Session 4 follow-up Step 5).
|
||||
*
|
||||
* useActiveDay is mounted inside a host component so the watcher actually
|
||||
* runs (composables that use watch() need an active component instance).
|
||||
*/
|
||||
|
||||
interface HostExposed {
|
||||
activeDayId: () => string | null
|
||||
setActiveDay: (id: string | null) => void
|
||||
setQueryDay: (id: string | null) => void
|
||||
setValidIds: (ids: string[]) => void
|
||||
replaceMock: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function mountSync(initialQuery: string | null, initialValidIds: string[]) {
|
||||
const queryDay = ref<string | null>(initialQuery)
|
||||
const validIds = ref<string[]>(initialValidIds)
|
||||
|
||||
const replaceMock = vi.fn((id: string) => {
|
||||
queryDay.value = id
|
||||
})
|
||||
|
||||
const host = defineComponent({
|
||||
setup(_, { expose }) {
|
||||
const { activeDayId, setActiveDay } = useActiveDay({
|
||||
queryDay,
|
||||
validIds,
|
||||
replace: replaceMock,
|
||||
})
|
||||
|
||||
expose({
|
||||
activeDayId: () => activeDayId.value,
|
||||
setActiveDay,
|
||||
setQueryDay: (v: string | null) => { queryDay.value = v },
|
||||
setValidIds: (v: string[]) => { validIds.value = v },
|
||||
replaceMock,
|
||||
})
|
||||
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = mount(host)
|
||||
|
||||
return { wrapper, vm: wrapper.vm as unknown as HostExposed, replaceMock }
|
||||
}
|
||||
|
||||
describe('useActiveDay — ?day source-of-truth', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('valid ?day=X returns X without rewriting the URL', async () => {
|
||||
const { vm, replaceMock } = mountSync('day_2', ['day_1', 'day_2', 'day_3'])
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(vm.activeDayId()).toBe('day_2')
|
||||
expect(replaceMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('missing ?day → fallback to first valid id + URL replaced once', async () => {
|
||||
const { vm, replaceMock } = mountSync(null, ['day_1', 'day_2', 'day_3'])
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(vm.activeDayId()).toBe('day_1')
|
||||
expect(replaceMock).toHaveBeenCalledTimes(1)
|
||||
expect(replaceMock).toHaveBeenCalledWith('day_1')
|
||||
})
|
||||
|
||||
it('invalid ?day=DOES_NOT_EXIST → fallback + URL corrected', async () => {
|
||||
const { vm, replaceMock } = mountSync('day_bogus', ['day_1', 'day_2'])
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(vm.activeDayId()).toBe('day_1')
|
||||
expect(replaceMock).toHaveBeenCalledWith('day_1')
|
||||
})
|
||||
|
||||
it('cross-org ?day (id absent from validIds) → fallback to first valid', async () => {
|
||||
// Backend OrganisationScope keeps the cross-org sub-event out of the
|
||||
// returned list; useActiveDay treats it identically to "doesn't exist".
|
||||
const { vm, replaceMock } = mountSync('day_other_org', ['day_a'])
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(vm.activeDayId()).toBe('day_a')
|
||||
expect(replaceMock).toHaveBeenCalledWith('day_a')
|
||||
})
|
||||
|
||||
it('empty validIds → activeDayId is null and URL is not touched', async () => {
|
||||
const { vm, replaceMock } = mountSync('day_1', [])
|
||||
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(vm.activeDayId()).toBeNull()
|
||||
expect(replaceMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setActiveDay(id) calls replace with the new id', async () => {
|
||||
const { vm, replaceMock } = mountSync('day_1', ['day_1', 'day_2'])
|
||||
|
||||
await nextTick()
|
||||
|
||||
vm.setActiveDay('day_2')
|
||||
await nextTick()
|
||||
|
||||
expect(replaceMock).toHaveBeenLastCalledWith('day_2')
|
||||
expect(vm.activeDayId()).toBe('day_2')
|
||||
})
|
||||
|
||||
it('setActiveDay(null) is a no-op', async () => {
|
||||
const { vm, replaceMock } = mountSync('day_1', ['day_1', 'day_2'])
|
||||
|
||||
await nextTick()
|
||||
replaceMock.mockClear()
|
||||
|
||||
vm.setActiveDay(null)
|
||||
await nextTick()
|
||||
|
||||
expect(replaceMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('external URL change (browser back) propagates to activeDayId', async () => {
|
||||
const { vm } = mountSync('day_1', ['day_1', 'day_2'])
|
||||
|
||||
await nextTick()
|
||||
expect(vm.activeDayId()).toBe('day_1')
|
||||
|
||||
vm.setQueryDay('day_2')
|
||||
await nextTick()
|
||||
|
||||
expect(vm.activeDayId()).toBe('day_2')
|
||||
})
|
||||
|
||||
it('validIds populated AFTER mount triggers fallback if ?day was missing', async () => {
|
||||
const { vm, replaceMock } = mountSync(null, [])
|
||||
|
||||
await nextTick()
|
||||
expect(vm.activeDayId()).toBeNull()
|
||||
expect(replaceMock).not.toHaveBeenCalled()
|
||||
|
||||
vm.setValidIds(['day_1', 'day_2'])
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(vm.activeDayId()).toBe('day_1')
|
||||
expect(replaceMock).toHaveBeenCalledWith('day_1')
|
||||
})
|
||||
})
|
||||
@@ -41,11 +41,14 @@ describe('useTimetableStore', () => {
|
||||
expect(store.isStatusVisible(ArtistEngagementStatus.CONFIRMED)).toBe(true)
|
||||
})
|
||||
|
||||
it('setActiveDay updates and selectPerformance maps to id', () => {
|
||||
it('selectPerformance maps to id and clears on null', () => {
|
||||
// activeDayId / setActiveDay intentionally REMOVED from the store
|
||||
// (Step 5: ?day URL is the source of truth, page derives via computed).
|
||||
const store = useTimetableStore()
|
||||
|
||||
store.setActiveDay('day_1')
|
||||
expect(store.activeDayId).toBe('day_1')
|
||||
expect((store as unknown as { activeDayId?: string }).activeDayId).toBeUndefined()
|
||||
expect((store as unknown as { setActiveDay?: unknown }).setActiveDay).toBeUndefined()
|
||||
|
||||
store.selectPerformance('p1')
|
||||
expect(store.selectedPerformanceId).toBe('p1')
|
||||
store.selectPerformance(null)
|
||||
|
||||
Reference in New Issue
Block a user