diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b999b57..a9beaf41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,10 +164,6 @@ jobs: working-directory: apps/qc-app run: npm ci --workspaces=false - - name: Install local QC utils package - working-directory: apps/qc-app - run: npm install ../../packages/qc-utils --no-save --package-lock=false --workspaces=false - - name: Run QC app typecheck working-directory: apps/qc-app run: npm run typecheck @@ -395,10 +391,6 @@ jobs: working-directory: apps/qc-app run: npm ci --workspaces=false - - name: Install local QC utils package - working-directory: apps/qc-app - run: npm install ../../packages/qc-utils --no-save --package-lock=false --workspaces=false - - name: Install Playwright browsers working-directory: apps/qc-app run: npx playwright install --with-deps chromium firefox diff --git a/apps/data-management/e2e/specs/orchestration.spec.ts b/apps/data-management/e2e/specs/orchestration.spec.ts index 0f83bb9a..7029e6ca 100644 --- a/apps/data-management/e2e/specs/orchestration.spec.ts +++ b/apps/data-management/e2e/specs/orchestration.spec.ts @@ -1,17 +1,25 @@ -import { expect, test } from '@playwright/test' +import { expect, test, type Page } from '@playwright/test' import { authenticateSession } from '../support/auth' import { fixtures, users } from '../support/fixtures' import { chooseOverlayOption, selectWorkspace } from '../support/ui' test.describe('orchestration', () => { + async function selectDataConnection(page: Page) { + await page + .locator('.sidebar-item--connection') + .filter({ hasText: fixtures.orchestration.dataConnectionName }) + .click() + } + test('orchestration page loads seeded workspace orchestration data', async ({ page, }) => { await authenticateSession(page, users.owner.email, users.owner.password) - await page.goto('/orchestration') + await page.goto('/orchestration/ingestion') await selectWorkspace(page, fixtures.workspaces.private.name) + await selectDataConnection(page) await expect(page.getByText('Job orchestration', { exact: true })).toBeVisible() await expect( @@ -29,8 +37,9 @@ test.describe('orchestration', () => { }) => { await authenticateSession(page, users.owner.email, users.owner.password) - await page.goto('/orchestration') + await page.goto('/orchestration/ingestion') await selectWorkspace(page, fixtures.workspaces.private.name) + await selectDataConnection(page) await expect(page.getByText('Job orchestration', { exact: true })).toBeVisible() @@ -55,8 +64,9 @@ test.describe('orchestration', () => { page, }) => { await authenticateSession(page, users.owner.email, users.owner.password) - await page.goto('/orchestration') + await page.goto('/orchestration/ingestion') await selectWorkspace(page, fixtures.workspaces.private.name) + await selectDataConnection(page) await expect( page.getByRole('heading', { @@ -68,6 +78,7 @@ test.describe('orchestration', () => { await expect(page.getByText(fixtures.orchestration.dataConnectionName)).toHaveCount(0) await selectWorkspace(page, fixtures.workspaces.private.name) + await selectDataConnection(page) await expect( page.getByRole('heading', { name: fixtures.orchestration.dataConnectionName, diff --git a/apps/data-management/e2e/specs/sites.spec.ts b/apps/data-management/e2e/specs/sites.spec.ts index 3f27c023..79954fbf 100644 --- a/apps/data-management/e2e/specs/sites.spec.ts +++ b/apps/data-management/e2e/specs/sites.spec.ts @@ -188,6 +188,41 @@ test.describe('sites and workspaces', () => { await expect(page).toHaveURL(/\/sites$/) }) + test('canceling or dismissing site edits discards unsaved changes', async ({ + page, + }) => { + const stamp = Date.now() + const unsavedProvince = `Unsaved Province ${stamp}` + const unsavedCounty = `Unsaved County ${stamp}` + + await authenticateSession(page, users.owner.email, users.owner.password) + await page.goto(`/sites/${fixtures.things.mutablePublic.id}`) + + await page.getByTestId('edit-site-button').click() + await page.getByLabel('State/Province/Region').fill(unsavedProvince) + await page.getByRole('button', { name: 'Cancel' }).click() + + await expect(page.getByText(unsavedProvince, { exact: true })).toHaveCount(0) + + await page.getByTestId('edit-site-button').click() + await expect(page.getByLabel('State/Province/Region')).not.toHaveValue( + unsavedProvince + ) + await page.getByRole('button', { name: 'Cancel' }).click() + + await page.getByTestId('edit-site-button').click() + await page.getByLabel('County/District').fill(unsavedCounty) + await page.keyboard.press('Escape') + + await expect(page.getByText(unsavedCounty, { exact: true })).toHaveCount(0) + + await page.getByTestId('edit-site-button').click() + await expect(page.getByLabel('County/District')).not.toHaveValue( + unsavedCounty + ) + await page.getByRole('button', { name: 'Cancel' }).click() + }) + test('owner can edit, toggle privacy for, and delete a site with datastream CRUD', async ({ page, browser, diff --git a/apps/data-management/e2e/specs/transfers.spec.ts b/apps/data-management/e2e/specs/transfers.spec.ts index 200a558e..c3e1886b 100644 --- a/apps/data-management/e2e/specs/transfers.spec.ts +++ b/apps/data-management/e2e/specs/transfers.spec.ts @@ -40,6 +40,14 @@ async function deleteWorkspace(page: Page, name: string) { await expect(page.getByRole('cell', { name, exact: true })).toHaveCount(0) } +function pendingTransferRow(page: Page, name: string) { + return page + .getByRole('row', { name: new RegExp(name) }) + .filter({ + has: page.getByRole('button', { name: 'Accept transfer' }), + }) +} + test.describe('workspace transfers', () => { test.use({ storageState: { cookies: [], origins: [] } }) @@ -55,14 +63,18 @@ test.describe('workspace transfers', () => { await page.getByRole('button', { name: 'Pending workspace transfer' }).click() + const transferRow = pendingTransferRow(page, fixtures.workspaces.transfer.name) await expect( - page.getByRole('cell', { name: fixtures.workspaces.transfer.name, exact: true }) + transferRow.getByRole('cell', { + name: fixtures.workspaces.transfer.name, + exact: true, + }) ).toBeVisible() await expect( - page.getByRole('button', { name: 'Accept transfer' }) + transferRow.getByRole('button', { name: 'Accept transfer' }) ).toBeVisible() await expect( - page.getByRole('button', { name: 'Cancel transfer' }) + transferRow.getByRole('button', { name: 'Cancel transfer' }) ).toBeVisible() }) @@ -88,15 +100,12 @@ test.describe('workspace transfers', () => { await targetPage.goto('/orchestration') await targetPage.getByRole('button', { name: 'Pending workspace transfer' }).click() - const pendingRow = targetPage.getByRole('row', { - name: new RegExp(workspaceName), - }) + const pendingRow = pendingTransferRow(targetPage, workspaceName) await expect(pendingRow).toBeVisible() await pendingRow.getByRole('button', { name: 'Cancel transfer' }).click() await expect(pendingRow).toHaveCount(0) - await page.reload() - await page.getByRole('button', { name: 'Workspaces', exact: true }).click() + await page.goto('/orchestration/workspaces') await deleteWorkspace(page, workspaceName) await targetContext.close() @@ -124,9 +133,7 @@ test.describe('workspace transfers', () => { await targetPage.goto('/orchestration') await targetPage.getByRole('button', { name: 'Pending workspace transfer' }).click() - const pendingRow = targetPage.getByRole('row', { - name: new RegExp(workspaceName), - }) + const pendingRow = pendingTransferRow(targetPage, workspaceName) await expect(pendingRow).toBeVisible() await pendingRow.getByRole('button', { name: 'Accept transfer' }).click() await expect(pendingRow).toHaveCount(0) @@ -136,8 +143,8 @@ test.describe('workspace transfers', () => { await expect(ownedRow).toBeVisible() await expect(ownedRow).toContainText('Owner') - await page.reload() - await page.getByRole('button', { name: 'Workspaces', exact: true }).click() + await page.goto('/orchestration/workspaces') + await expect(page.getByRole('button', { name: 'Add workspace' })).toBeVisible() await expect(page.getByRole('cell', { name: workspaceName, exact: true })).toHaveCount(0) await deleteWorkspace(targetPage, workspaceName) diff --git a/apps/data-management/e2e/specs/visualization.spec.ts b/apps/data-management/e2e/specs/visualization.spec.ts index bd2ba502..9d82b07c 100644 --- a/apps/data-management/e2e/specs/visualization.spec.ts +++ b/apps/data-management/e2e/specs/visualization.spec.ts @@ -13,9 +13,22 @@ test.describe("visualization", () => { const workspaceFilter = page .getByRole("combobox", { name: "Workspaces" }) .first(); + const viewport = page.viewportSize(); + const box = await workspaceFilter.boundingBox(); + const isWithinViewport = + !!box && + !!viewport && + box.width > 0 && + box.height > 0 && + box.x + box.width > 0 && + box.y + box.height > 0 && + box.x < viewport.width && + box.y < viewport.height; + if ( (await workspaceFilter.count()) > 0 && - (await workspaceFilter.isVisible()) + (await workspaceFilter.isVisible()) && + isWithinViewport ) { return; } diff --git a/apps/data-management/e2e/support/ui.ts b/apps/data-management/e2e/support/ui.ts index f8940b9b..4638f9a5 100644 --- a/apps/data-management/e2e/support/ui.ts +++ b/apps/data-management/e2e/support/ui.ts @@ -29,8 +29,19 @@ export async function chooseAutocompleteOption( optionText: string ) { const field = page.getByRole('combobox', { name: label }).first() - await field.click() - await field.fill(optionText) + const container = field.locator('xpath=ancestor::*[@role="combobox"][1]') + await expect(field).toBeVisible() + await container.scrollIntoViewIfNeeded() + const toggle = container.getByRole('button').last() + if ((await toggle.count()) > 0) { + await toggle.click({ force: true }) + } + await field.evaluate((element) => { + ;(element as HTMLInputElement).focus() + }) + await page.keyboard.press('Control+A') + await page.keyboard.press('Backspace') + await page.keyboard.type(optionText) await chooseOverlayOption(page, optionText) } diff --git a/apps/data-management/package-lock.json b/apps/data-management/package-lock.json index 0c27ead5..171c633c 100644 --- a/apps/data-management/package-lock.json +++ b/apps/data-management/package-lock.json @@ -49,7 +49,7 @@ }, "../../packages/hydroserver-ts": { "name": "@hydroserver/client", - "version": "1.0.0", + "version": "1.0.1", "license": "BSD-3-Clause", "devDependencies": { "@types/node": "^24.1.0", @@ -57,6 +57,8 @@ "jsdom": "^22.1.0", "openapi-zod-client": "^1.18.3", "p-limit": "^6.2.0", + "rollup": "^4.61.0", + "rollup-plugin-dts": "^6.5.1", "tsx": "^4.16.2", "typescript": "^5.8.3", "vite": "^7.3.1", diff --git a/apps/data-management/src/App.vue b/apps/data-management/src/App.vue index c1da9fb8..6785a972 100644 --- a/apps/data-management/src/App.vue +++ b/apps/data-management/src/App.vue @@ -1,24 +1,44 @@ diff --git a/apps/data-management/src/components/Workspace/WorkspaceToolbar.vue b/apps/data-management/src/components/Workspace/WorkspaceToolbar.vue index 21b684e1..d855e4c0 100644 --- a/apps/data-management/src/components/Workspace/WorkspaceToolbar.vue +++ b/apps/data-management/src/components/Workspace/WorkspaceToolbar.vue @@ -327,6 +327,7 @@ import { computed, ref, watch } from 'vue' import WorkspaceFormCard from '@/components/Workspace/WorkspaceFormCard.vue' import DeleteWorkspaceCard from './DeleteWorkspaceCard.vue' import WorkspaceAccessControl from '@/components/Workspace/AccessControl/WorkspaceAccessControl.vue' +import { isAppInitializing } from '@/bootstrap/appInitialization' import { storeToRefs } from 'pinia' import { useWorkspaceStore } from '@/store/workspaces' import PermissionTooltip from '../PermissionTooltip.vue' @@ -364,7 +365,7 @@ const { setWorkspaces } = useWorkspaceStore() const { hasPermission, getUserRoleName } = useWorkspacePermissions() const { user } = storeToRefs(useUserStore()) -const openWorkspaceTable = ref(!workspaces.value.length) +const openWorkspaceTable = ref(false) const openTransferTable = ref(false) const openCreate = ref(false) const openEdit = ref(false) @@ -373,11 +374,22 @@ const openAccessControl = ref(false) const search = ref('') const activeItem = ref({} as Workspace) const showWorkspaceHelp = ref(false) +const initializedWorkspaceTableState = ref(false) const canCreateWorkspace = computed(() => ['admin', 'standard'].includes(user.value?.accountType ?? '') ) +watch( + [isAppInitializing, workspaces], + ([appInitializing, currentWorkspaces]) => { + if (initializedWorkspaceTableState.value || appInitializing) return + initializedWorkspaceTableState.value = true + if (!currentWorkspaces.length) openWorkspaceTable.value = true + }, + { immediate: true } +) + const selectedWorkspaceId = ref('') watch( selectedWorkspaceId, @@ -412,11 +424,13 @@ function openDialog( * always be the source of truth. This function syncs the table items with the db and global workspaces. */ const refreshWorkspaces = async (workspace?: Workspace) => { - const res = await hs.workspaces.listItems({ + const keepWorkspaceTableOpen = openWorkspaceTable.value + const res = await hs.workspaces.listAllItems({ is_associated: true, - fetch_all: true, + expand_related: true, }) setWorkspaces(res) + openWorkspaceTable.value = keepWorkspaceTableOpen if ( workspace && @@ -456,7 +470,7 @@ async function onDelete() { const res = await hs.workspaces.delete(activeItem.value.id) if (res.ok) { Snackbar.success('Workspace deleted') - refreshWorkspaces() + await refreshWorkspaces() } else Snackbar.error(res.message) } diff --git a/apps/data-management/src/main.ts b/apps/data-management/src/main.ts index 176050e9..2a46ec7a 100644 --- a/apps/data-management/src/main.ts +++ b/apps/data-management/src/main.ts @@ -6,55 +6,22 @@ import { createApp } from 'vue' import App from './App.vue' import router from './router/router' import vuetify from '@/plugins/vuetify' -import { createPinia, storeToRefs } from 'pinia' +import { createPinia } from 'pinia' import { injectClarity } from '@/plugins/clarity' import { injectGoogleAnalytics } from '@/plugins/googleAnalytics' import { settings } from '@/config/settings' -import hs, { createHydroServer, User } from '@hydroserver/client' -import { useVocabularyStore } from './composables/useVocabulary' -import { useWorkspaceStore } from '@/store/workspaces' -import { useUserStore } from './store/user' +import { startAppInitialization } from '@/bootstrap/appInitialization' const app = createApp(App) const pinia = createPinia() -const hydroServerHost = - import.meta.env.VITE_APP_PROXY_BASE_URL - ? '' - : import.meta.env.DEV - ? 'http://127.0.0.1:8000' - : '' -async function initializeApp() { +function initializeApp() { app.use(pinia) - // The session must be initialized before the router because some of the routes depend on the session state for access control - await createHydroServer({ host: hydroServerHost }) - - const vocabularyStore = useVocabularyStore() - await Promise.all([vocabularyStore.fetchAllVocabularies()]) - - const { user } = storeToRefs(useUserStore()) - - // Avoid spamming the console with an expected 401 before a user logs in. - // The session snapshot already tells us whether an authenticated user exists. - user.value = new User() - if (hs.session.isAuthenticated) { - const res = await hs.user.get() - user.value = res.ok && res.status !== 401 ? res.data : new User() - } - - if (hs.session.isAuthenticated) { - try { - const workspacesResponse = await hs.workspaces.listAllItems({ - is_associated: true, - expand_related: true, - }) - const { setWorkspaces } = useWorkspaceStore() - setWorkspaces(workspacesResponse) - } catch (error) { - console.error('Error fetching workspaces', error) - } - } + // Start client initialization before the router's first navigation. The + // router guard waits for the client, while the rest of the bootstrap can + // finish after the app mounts. + const initialization = startAppInitialization() app.use(router) app.use(vuetify) @@ -67,6 +34,10 @@ async function initializeApp() { settings.analyticsConfiguration.googleAnalyticsMeasurementId ) app.mount('#app') + + return initialization } -initializeApp() +void initializeApp().catch((error) => { + console.error('Error initializing app', error) +}) diff --git a/apps/data-management/src/pages/SiteDetails.vue b/apps/data-management/src/pages/SiteDetails.vue index 0575c862..cc5bc7f8 100644 --- a/apps/data-management/src/pages/SiteDetails.vue +++ b/apps/data-management/src/pages/SiteDetails.vue @@ -131,8 +131,9 @@ > Edit site information - + - + + + +

No workspace selected

+

+ Create or join a workspace to register and manage sites. +

+
+ (null) +let currentThingsRequest = 0 const matchesFilterCriteria = ( thing: ThingSiteSummary, @@ -229,8 +254,22 @@ const onClickRegisterSite = () => { ) } -watch(selectedWorkspace, async (ws) => { - await loadThings() +const syncThingsToSelectedWorkspace = async () => { + const workspaceId = selectedWorkspace.value?.id ?? null + if (workspaceId === lastLoadedWorkspaceId.value) return + + lastLoadedWorkspaceId.value = workspaceId + await loadThings(workspaceId) +} + +watch(selectedWorkspace, async (workspace) => { + if (!hasResolvedInitialWorkspaces.value) return + + if (!workspace) { + showSiteForm.value = false + } + + await syncThingsToSelectedWorkspace() }) const filteredThings = computed(() => { @@ -281,26 +320,65 @@ const siteDetailsRoute = (id: string) => ({ params: { id }, }) -const loadThings = async () => { - const res = await hs.things.listSiteSummaries(selectedWorkspace.value!.id) - workspaceThings.value = res.ok ? res.data : [] +const loadThings = async ( + workspaceId = selectedWorkspace.value?.id ?? null +) => { + currentThingsRequest += 1 + const requestId = currentThingsRequest + + if (!workspaceId) { + workspaceThings.value = [] + return + } + + try { + const res = await hs.things.listSiteSummaries(workspaceId) + if ( + requestId !== currentThingsRequest || + selectedWorkspace.value?.id !== workspaceId + ) + return + + if (!res.ok) { + workspaceThings.value = [] + console.error('Error fetching sites', res.status) + return + } + + workspaceThings.value = res.data + } catch (error) { + if (requestId !== currentThingsRequest) return + + workspaceThings.value = [] + console.error('Error fetching sites', error) + } +} + +const refreshWorkspaces = async () => { + const workspaceRes = await hs.workspaces.listAllItems({ + is_associated: true, + expand_related: true, + }) + setWorkspaces(workspaceRes) } onMounted(async () => { - if (selectedWorkspace.value == null) { - const [workspaceRes] = await Promise.all([ - hs.workspaces.listAllItems({ is_associated: true, expand_related: true }), - ]) - setWorkspaces(workspaceRes) - } else { - const [thingsRes, workspaceRes] = await Promise.all([ - hs.things.listSiteSummaries(selectedWorkspace.value.id), - hs.workspaces.listAllItems({ is_associated: true, expand_related: true }), - ]) - setWorkspaces(workspaceRes) - workspaceThings.value = thingsRes.ok ? thingsRes.data : [] + try { + if (isAppInitializing.value) { + await startAppInitialization() + } + + if (!hasBootstrappedWorkspaces.value) { + await refreshWorkspaces() + } + } catch (error) { + console.error('Error fetching workspaces', error) + workspaceThings.value = [] + } finally { + hasResolvedInitialWorkspaces.value = true + await syncThingsToSelectedWorkspace() + isPageLoaded.value = true } - isPageLoaded.value = true }) diff --git a/apps/data-management/src/pages/__tests__/Sites.spec.ts b/apps/data-management/src/pages/__tests__/Sites.spec.ts new file mode 100644 index 00000000..ceb21de5 --- /dev/null +++ b/apps/data-management/src/pages/__tests__/Sites.spec.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { flushPromises, shallowMount } from '@vue/test-utils' +import { useWorkspaceStore } from '@/store/workspaces' + +const { + hasBootstrappedWorkspacesState, + isAppInitializingState, + listAllItemsMock, + listThingSiteSummariesMock, + routerPushMock, +} = + vi.hoisted(() => ({ + hasBootstrappedWorkspacesState: { value: false }, + isAppInitializingState: { value: false }, + listAllItemsMock: vi.fn(), + listThingSiteSummariesMock: vi.fn(), + routerPushMock: vi.fn(), + })) + +vi.mock('@hydroserver/client', () => ({ + User: class User { + email = '' + }, + PermissionAction: { + Create: 'Create', + }, + PermissionResource: { + Thing: 'Thing', + }, + default: { + workspaces: { + listAllItems: listAllItemsMock, + }, + things: { + listSiteSummaries: listThingSiteSummariesMock, + }, + }, +})) + +vi.mock('@/bootstrap/appInitialization', () => ({ + hasBootstrappedWorkspaces: hasBootstrappedWorkspacesState, + isAppInitializing: isAppInitializingState, + startAppInitialization: vi.fn(), +})) + +vi.mock('@/components/Maps/OpenLayersMap.vue', () => ({ + default: { + name: 'OpenLayersMap', + template: '
', + }, +})) + +vi.mock('@/components/Site/SiteForm.vue', () => ({ + default: { + name: 'SiteForm', + template: '
', + }, +})) + +vi.mock('@/components/Site/SiteFilterToolbar.vue', () => ({ + default: { + name: 'SiteFilterToolbar', + template: '
', + }, +})) + +vi.mock('@/components/Workspace/WorkspaceToolbar.vue', () => ({ + default: { + name: 'WorkspaceToolbar', + template: '
', + }, +})) + +vi.mock('@/components/base/FullScreenLoader.vue', () => ({ + default: { + name: 'FullScreenLoader', + template: '
', + }, +})) + +vi.mock('@/utils/maps/markers', () => ({ + addColorToMarkers: (things: unknown[]) => things, +})) + +vi.mock('@/composables/useWorkspacePermissions', () => ({ + useWorkspacePermissions: () => ({ + hasPermission: () => true, + }), +})) + +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useRouter: () => ({ + push: routerPushMock, + }), + } +}) + +import Sites from '../Sites.vue' + +const mountSites = () => + shallowMount(Sites, { + global: { + stubs: { + 'v-btn-add': true, + }, + }, + }) + +describe('Sites page', () => { + beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + vi.restoreAllMocks() + hasBootstrappedWorkspacesState.value = false + isAppInitializingState.value = false + listAllItemsMock.mockReset() + listThingSiteSummariesMock.mockReset() + routerPushMock.mockReset() + }) + + it('clears a stale workspace selection without requesting site summaries', async () => { + const workspaceStore = useWorkspaceStore() + workspaceStore.selectedWorkspace = { + id: 'stale-workspace', + name: 'Stale workspace', + isPrivate: false, + } as any + + listAllItemsMock.mockResolvedValue([]) + + const wrapper = mountSites() + + await flushPromises() + + expect(listThingSiteSummariesMock).not.toHaveBeenCalled() + expect(workspaceStore.selectedWorkspace).toBeNull() + }) + + it('loads site summaries once after bootstrapped workspaces are available', async () => { + const workspaceStore = useWorkspaceStore() + workspaceStore.selectedWorkspace = { + id: 'workspace-1', + name: 'Workspace 1', + isPrivate: false, + } as any + + hasBootstrappedWorkspacesState.value = true + listThingSiteSummariesMock.mockResolvedValue({ ok: true, status: 200, data: [] }) + + mountSites() + + await flushPromises() + + expect(listThingSiteSummariesMock).toHaveBeenCalledTimes(1) + expect(listThingSiteSummariesMock).toHaveBeenCalledWith('workspace-1') + }) +}) diff --git a/apps/data-management/src/router/router.ts b/apps/data-management/src/router/router.ts index 8a237651..1f593a10 100644 --- a/apps/data-management/src/router/router.ts +++ b/apps/data-management/src/router/router.ts @@ -4,6 +4,10 @@ import { createRouter, createWebHistory, } from 'vue-router' +import { + isHydroServerReady, + waitForHydroServerInitialization, +} from '@/bootstrap/appInitialization' import { routes } from '@/router/routes' import hs from '@hydroserver/client' import { @@ -34,6 +38,9 @@ function updateDocumentTitle(matched: RouteRecordNormalized[]): void { router.beforeEach( async (to: RouteLocationNormalized, from: RouteLocationNormalized) => { + await waitForHydroServerInitialization() + if (!isHydroServerReady.value) return false + const { inEmailVerificationFlow, inProviderSignupFlow } = hs.session if (inEmailVerificationFlow && to.name !== 'VerifyEmail') { diff --git a/apps/data-management/src/store/photos.ts b/apps/data-management/src/store/photos.ts index 70f7d415..3a419a70 100644 --- a/apps/data-management/src/store/photos.ts +++ b/apps/data-management/src/store/photos.ts @@ -8,6 +8,11 @@ export const usePhotosStore = defineStore('photos', () => { const photosToDelete = ref([]) const loading = ref(false) + const resetPendingPhotos = () => { + newPhotos.value = [] + photosToDelete.value = [] + } + const uploadNewPhotos = async (thingId: string) => { if (!newPhotos.value.length) return @@ -45,8 +50,7 @@ export const usePhotosStore = defineStore('photos', () => { console.error('Error updating photos', error) } finally { loading.value = false - newPhotos.value = [] - photosToDelete.value = [] + resetPendingPhotos() } } @@ -55,6 +59,7 @@ export const usePhotosStore = defineStore('photos', () => { newPhotos, photosToDelete, loading, + resetPendingPhotos, updatePhotos, } }) diff --git a/apps/qc-app/.env.example b/apps/qc-app/.env.example new file mode 100644 index 00000000..9774480c --- /dev/null +++ b/apps/qc-app/.env.example @@ -0,0 +1,13 @@ +# Backend the dev server proxies /api to (e.g. a HydroServer instance). +VITE_APP_PROXY_BASE_URL= + +# Origin of the data-management app, which owns login/auth. In dev the two +# apps run on separate ports, so point this at data-management (e.g. +# http://127.0.0.1:1203). Leave unset in production, where the QC app is +# served by data-management under the same origin and /login resolves there. +VITE_APP_DATA_MANAGEMENT_URL= + +# Set to 1 to drop COOP/COEP headers when talking to a backend that doesn't +# serve Cross-Origin-Resource-Policy. Disables SharedArrayBuffer-backed +# workers (they fall back to inline execution). +VITE_APP_DISABLE_COOP= diff --git a/apps/qc-app/README.md b/apps/qc-app/README.md index cb004262..e947938c 100644 --- a/apps/qc-app/README.md +++ b/apps/qc-app/README.md @@ -14,11 +14,11 @@ This web app facilitates QC/QA for time series observations stored in a HydroSer The app is the operator's view of HydroServer's QC pipeline: -1. **Browse** — pick a workspace, filter datastreams by site / observed property / processing level, and plot up to five at once on a synchronized multi-axis chart. -2. **QC one stream at a time** — the first plotted stream is the QC target. The other plotted traces are read-only context. -3. **Filter / edit / add** — every operation (Value Threshold, Find Gaps, Persistence, Interpolate, Drift Correction, Fill Gaps, Add Points, etc.) commits a `HistoryItem` to a replayable edit history backed by [`@uwrl/qc-utils`](https://www.npmjs.com/package/@uwrl/qc-utils). -4. **Save / load a QC History** — export the history as a JSON document, replay it on the same datastream a week later, or templatize across stations. -5. **Submit** — push the quality-controlled observations back to HydroServer in `replace` mode. +1. **Browse**: pick a workspace, filter datastreams by site / observed property / processing level, and plot up to four at once on a synchronized multi-axis chart, plus the one you edit. +2. **QC one stream at a time**: the Edit button on a datastream row picks what to edit, separately from the plot checkboxes. The editor draws that stream over its raw source, with the plotted datastreams as read-only context. +3. **Filter / edit / add**: every operation (Value Threshold, Find Gaps, Persistence, Interpolate, Drift Correction, Fill Gaps, Add Points, etc.) commits a `HistoryItem` to a replayable edit history backed by [`@uwrl/qc-utils`](https://www.npmjs.com/package/@uwrl/qc-utils). +4. **Save / load a QC History**: export the history as a JSON document, replay it on the same datastream a week later, or templatize across stations. +5. **Save and commit**: save the edits to a QC session as a draft, then commit the session to push the quality-controlled observations to its managed datastream in `replace` mode. The heavy lifting (worker-parallelized typed-array kernels, calibration, history replay, save / load wire format) lives in `@uwrl/qc-utils`. This repo is the Vue / Vuetify / Pinia / Plotly UI plus the orchestration around it. @@ -39,7 +39,7 @@ During normal HydroServer development, open QC through the Data Management app a | Var | Required | Purpose | |----------------------------------|----------|-------------------------------------------------------------------------| -| `VITE_APP_DISABLE_COOP` | no | Drop the `Cross-Origin-Opener-Policy` + `Cross-Origin-Embedder-Policy` headers. Use only when the backend you're hitting doesn't serve `Cross-Origin-Resource-Policy` (older HydroServer deployments). The `qc-utils` worker layer falls back to inline kernels when SAB isn't available, so the app still works — just slower on large edits. | +| `VITE_APP_DISABLE_COOP` | no | Drop the `Cross-Origin-Opener-Policy` + `Cross-Origin-Embedder-Policy` headers. Use only when the backend you're hitting doesn't serve `Cross-Origin-Resource-Policy` (older HydroServer deployments). The `qc-utils` worker layer falls back to inline kernels when SAB isn't available, so the app still works (just slower on large edits). | | `VITE_APP_E2E_HOOKS` | no | Set to `1` to expose `window.__vbwTestHooks` for Playwright. CI sets this automatically. | Cross-origin isolation is on by default (`vite.config.ts` sends `Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy: require-corp`) so `SharedArrayBuffer` is available to the qc-utils worker pool. @@ -48,7 +48,7 @@ Cross-origin isolation is on by default (`vite.config.ts` sends `Cross-Origin-Op ``` src/ -├─ pages/ Top-level routed views (Visualize, Edit, Submit, Auth). +├─ pages/ Routed views: Home (the Select and Edit views) and Workspaces. ├─ components/ │ ├─ Navigation/ NavigationRail, EditDrawer, SelectDrawer. │ ├─ FilterPoints/ One panel per filter op (ValueThreshold, GapFinder, …). @@ -57,22 +57,29 @@ src/ ├─ composables/ │ ├─ useDataSelection.ts The bridge between Plotly's selectedpoints and the Pinia store. │ ├─ useFilterDispatch.ts Shared "open panel → run op → highlight result" sequence. -│ └─ useQcHistory.ts Wiring around qc-utils' serializeHistory / applyHistory. +│ ├─ useEditEntry.ts Entering and leaving the editor. +│ ├─ useEditSession.ts Open, save and commit a QC session. +│ ├─ useLeaveSession.ts The one question asked before any exit. +│ └─ useQcHistory.ts Download the QC history (qc-utils' serializeHistory). +├─ services/qualityControl/ The QC API workflow: sessions, saving, commit, past-session replay. ├─ store/ │ ├─ plotly.ts Plot ref, edit history, redraw, suppressedEchoSelection sentinel. │ ├─ dataVisualization.ts Selected datastream, plotted streams, selectedData. │ ├─ userInterface.ts Drawer state, operator + filter inputs, persisted prefs. +│ ├─ qcSession.ts The open history's sessions, which one is viewed, saved baseline. +│ ├─ workingCopies.ts Each in-progress session's replayed record, shared by both views. +│ ├─ observations.ts Per-datastream observation cache. │ └─ workspaces.ts Workspace list + selected workspace (persisted). ├─ utils/plotting/ │ ├─ events.ts plotly_click / plotly_relayout / mousemove handlers. │ ├─ relayout.ts Debounced viewport recomputation, tick alignment. -│ ├─ selected.ts handleSelected — translates Plotly selection into a SELECTION dispatch. +│ ├─ selected.ts handleSelected: translates Plotly selection into a SELECTION dispatch. │ ├─ staging.ts Ghost-fill markers + drag-resizable stage shape. -│ └─ plotly.ts Trace builders + low-level setSelectedPoints / clearSelection. +│ └─ plotly.ts Barrel over the modules above, plus options (trace builders) and operations. └─ router/ vue-router 5 setup with workspace + auth guards. ``` -The data flow for an edit is always: panel collects args → `useFilterDispatch` / `useQcHistory` calls `selectedSeries.data.dispatch(...)` (qc-utils) → qc-utils mutates typed arrays + appends a `HistoryItem` → `redraw()` pushes the new x / y into Plotly. The UI never touches the typed arrays directly. +The data flow for an edit is always: panel collects args → `useFilterDispatch` calls `selectedSeries.data.dispatch(...)` (qc-utils) → qc-utils mutates typed arrays + appends a `HistoryItem` → `redraw()` pushes the new x / y into Plotly. The UI never touches the typed arrays directly. ## Working with `@uwrl/qc-utils` locally @@ -100,7 +107,7 @@ Specs live next to source under `src/**/__tests__/`. Plotly is mocked at the mod ### End-to-end tests (Playwright) -End-to-end specs cover the QC golden path: load a datastream, apply a filter, apply an edit, submit. Browser matrix: **chromium** and **firefox**. WebKit is intentionally excluded — `SharedArrayBuffer` + COOP / COEP behaviour differs in Safari and needs separate validation. +End-to-end specs cover the QC golden path: load a datastream, apply a filter, apply an edit, save and commit the session. Browser matrix: **chromium** and **firefox**. WebKit is intentionally excluded: `SharedArrayBuffer` + COOP / COEP behaviour differs in Safari and needs separate validation. One-time setup: @@ -111,14 +118,14 @@ npx playwright install chromium firefox Run modes: ```bash -npm run e2e # headless — Chromium + Firefox (CI mode, fast for local) +npm run e2e # headless: Chromium + Firefox (CI mode, fast for local) npm run e2e:live # same-origin smoke via Data Management ``` For interactive debugging, append Playwright flags, e.g. `npm run e2e -- --ui` or `npm run e2e -- --headed`. -The Playwright config in `playwright.config.ts` auto-starts the QC Vite dev server at `http://127.0.0.1:15173` and reuses an existing one outside CI. The dev server is what serves the COOP / COEP headers `SharedArrayBuffer` needs — running e2e against a static `file://` build won't work. +The Playwright config in `playwright.config.ts` auto-starts the QC Vite dev server at `http://127.0.0.1:15173` and reuses an existing one outside CI. The dev server is what serves the COOP / COEP headers `SharedArrayBuffer` needs; running e2e against a static `file://` build won't work. The mocked specs intercept HydroServer routes via `page.route()` and serve fixture JSON, so most runs need no backend. The live golden-path spec expects both frontends to be running and enters QC through `http://127.0.0.1:1203/qc/`. diff --git a/apps/qc-app/docs/API_REFERENCE.md b/apps/qc-app/docs/API_REFERENCE.md index c7a17541..1aa4086d 100644 --- a/apps/qc-app/docs/API_REFERENCE.md +++ b/apps/qc-app/docs/API_REFERENCE.md @@ -1,6 +1,6 @@ # APIs & Interoperability -This is a reference for the surfaces a developer integrates against — +This is a reference for the surfaces a developer integrates against: both the in-app TypeScript surfaces (Pinia stores, composables, plotting utils) and the external HydroServer REST endpoints the app consumes. @@ -70,20 +70,27 @@ hs.datastreams.createObservations( fields: ['phenomenonTime', 'result'], data: dataX.map((ts, i) => [new Date(ts).toISOString(), dataY[i]]), }, - { mode: 'replace' } + { + mode: 'replace', + phenomenon_time_start: session.phenomenonTimeStart, + phenomenon_time_end: session.phenomenonTimeEnd, + } ) ``` -Replace mode tells HydroServer to overwrite any observation inside the -posted window. The QC App always submits the full edited window — -incremental submission is not implemented today. +Replace mode deletes every observation between `phenomenon_time_start` and +`phenomenon_time_end`, then inserts the posted ones. The QC App always sends +the session window as that range, so points deleted at the window's edges are +removed too, and a session that deleted every point commits an empty body. +Without the range, the backend falls back to the span of the posted +observations. Incremental submission is not implemented today. ### Result qualifier codes Listed via `fetchWorkspaceResultQualifiers`. The QC App tracks selected qualifiers in `store/qualifiers.ts`, but **does not yet serialize them on -submit** (see [QUALITY.md](./QUALITY.md) tech-debt section and the TODO -in `useQcSubmission.ts:42`). +commit** (see [QUALITY.md](./QUALITY.md) tech-debt section and the note +in `services/qualityControl/observationsBody.ts`). ## Internal: composables @@ -112,32 +119,291 @@ await setSelected([0, 1, 2, 5]) // dispatches SELECTION await clearSelected({ recordHistory: false }) // skip history append on cleanup ``` +### `useEditEntry()` + +```ts +const { enterEdit, startSessionOver, openEditor, leaveEdit, closeEditor } = + useEditEntry() + +const result = await enterEdit(managedId, window) +// result: 'editing' | 'needs-window' | 'not-managed' | 'superseded' | 'kept' +``` + +Sets the edit target and switches to the Edit view. Shared by the row Edit +flow (`StartEditingFlow.vue`), reload resume (`useResumeEditSession()` +below), and share-link hydration (the `ed` query param). + +- `enterEdit(managedId, window?, view?)`: sets the edit target + (`setEditTarget`), resolves its QC history via `beginEditing()`, and + resumes an in-progress session. With no session and no `window`, returns + `'needs-window'` so the caller opens `SessionWindowDialog.vue` (the + session-window step) and calls back in with the chosen window, or calls + `startSessionOver` instead; with a `window`, starts the session + immediately. `view` is the layout to land on, `Edit` by default; a share + link made from the Select view passes `Select`, so the session reopens + behind it. Called on the target already open, with no `window`, it only + shows that view: nothing about the session is re-entered. Taking over from + another open session first asks `useLeaveSession().requestLeave()`, and + returns `'kept'` when the user keeps the open one. The create step in + `StartEditingFlow.vue` asks earlier, through `closeEditor()`: a created + managed datastream is always a new target, so the takeover is certain and + the question must come before anything reaches the server. + When the load is superseded it returns `'superseded'`. It shows the + `ResumeSupersededError` message only when the same target is still open; a + newer entry taking over, or the editor being closed mid-load, ends it + quietly, since the user asked for that. +- `startSessionOver(window)`: starts a new session on the current edit + target over `window` (a `utils/timeRangePresets.ts` `TimeWindow`); backs + **Start new session** and the editor footer's **New session**. +- `openEditor()`: shows the Edit layout for the current target. Pure + navigation, used by the nav rail's Edit button and the Select view's + **Open editor**. A datastream picked from a row enters with + `view = DrawerType.Select`, so it lands on that preview first. +- `leaveEdit()`: returns to the Select view, clears the resume pointer and + the edit target (`clearEditTarget`). The user has already been asked by + then, or there was nothing to ask about. Switching views is not an exit and + does not use it. +- `closeEditor()`: what a user's exit calls. Runs the leave flow + (`useLeaveSession().requestLeave()`) and, once it resolves true, ends the + session with `leaveEdit()`. Returns false when the user chose to stay, so + the caller abandons its own exit. Backs the editor footer's **Close** and + the nav rail's Home and Log out. + +Entries can overlap (a reload resume and a click land close together); +whichever call sets the edit target last owns the view: a call that finds +the target changed after an `await` stands down without touching view +state, the target, or the resume pointer. + +### `useLeaveSession()` + +```ts +const { requestLeave } = useLeaveSession() +if (!(await requestLeave())) return // the user stayed; nothing changed +``` + +The one decision point for leaving an open edit session, used by every exit: +the editor footer's **Close**, the nav rail's Home, workspace switch and log +out, the row Edit button on a different datastream, and in-app navigation +(`leaveSessionGuard` in `router/guards.ts`). Switching between the Select and +Edit views is not an exit, so it never asks. The prompt state is module-wide +and `LeaveSessionDialog.vue`, mounted once in `App.vue`, shows it. + +- `leaveCase()`: which of the four situations applies right now. + `'none'` (nothing being edited, no session, or committed history being + viewed) leaves silently; `'unsaved'` has edits that never reached the + session; `'empty'` is a session holding no operations at all; `'saved'` is + a session whose work is all saved. +- `requestLeave()`: decides the case, shows the matching prompt and carries + the answer out. Resolves true when the caller may go on, false when the + user stays. A second request supersedes the first, whose caller stays put; + once an answer is being carried out (`leaveWork`), a second request is + refused outright rather than inheriting that answer. +- `saveAndLeave()` / `discardEditsAndLeave()`: the `'unsaved'` answers. + Saving is refused with no session open. Discarding that empties the session + falls through to the `'empty'` prompt rather than leaving silently. +- `keepSession()` / `discardSessionAndLeave()`: the `'empty'` answers. + `keepSession()` is also the `'saved'` answer, keeping the session as it is. + Discarding deletes the session through + `useManagedDatastreams().deleteSession`, and refuses if another + session was somehow built on it. A failed delete keeps the user in the + session. +- `cancelLeave()`: stay, with the zoom, staged band and unsaved edits intact. +- `forgetSession()`: drop the resume pointer only. For exits that unmount the + editor, where clearing the edit target would have the editor's URL writer + replace the route mid-navigation. +- `leavePrompt` / `leaveWork`: what the dialog renders (the case, the name of + the datastream being left, the unsaved count and whether saving is + possible), and which answer is currently running. + +### `useResumeEditSession()` + +```ts +useResumeEditSession(async (id) => { + await startEditing.value?.resume(id) // StartEditingFlow +}) +``` + +Reopens the editor after a page reload, using the persisted +`qcSession.resumeDatastreamId`: waits for the workspace catalog to arrive +(it's empty at mount), then calls the supplied callback with the managed +datastream id (normally wired to `StartEditingFlow`'s exposed `resume`, which +enters through `useEditEntry()` and opens the session-window step when there +is no session to continue). Resumes at +most once, and the chance closes as soon as the catalog lands: a pointer set +after that belongs to an entry already navigating on its own, and a later +catalog refresh (a commit rewrites the managed datastream) must not re-enter +behind the user. A pointer to a datastream missing from the catalog (deleted, +or another workspace) is dropped rather than retried. + +Note the watcher must not use Vue's `once` together with `immediate`: the +immediate call fires on the initial empty catalog and stops the watcher, so +the resume would never run on the cold reload it exists for. + +### `useUnsavedChangesWarning()` + +```ts +useUnsavedChangesWarning(hasUnsavedChanges) // Ref +``` + +Asks the browser for its native "leave site?" confirmation while the ref is +true, so a reload mid-session can't silently drop edits that never reached +the server. Registers on mount and removes the listener on unmount. Browsers +ignore any custom message and only honour the prompt once the user has +interacted with the page. + ### `useQcHistory()` ```ts -const { exportHistory, importHistory } = useQcHistory() +const { exportHistory } = useQcHistory() -await exportHistory() // downloads qc-history--.json -const report = await importHistory(file) -// report = { applied: 12, failed: [{ index, method, error }, ...] } +await exportHistory() // downloads qc-history--.json ``` -`exportHistory` reads the current wall-clock window from -`useDataVisStore`. `importHistory` fetches the script's authored window -into the active datastream **before** replay (selection-coupled ops -reference indices against this windowed dataset). +Serializes the edit record's history with the session window (the viewed or +in-progress session, or the loaded range when there is none). Download only; +the app has no import. -### `useQcSubmission()` +### `useEditLock()` + +`src/composables/useEditLock.ts`. The one answer to "can the edit record take +a new operation right now?", read by every surface that starts an edit: the +Operations drawer, `OperationPanel`, plot selections (`handleSelected`, +`clearSelected`) and the data table's cells and Save button. + +| Name | Kind | Type | Notes | +|------------|----------|---------------------------------------|-------| +| `editLock` | computed | `'readOnly' \| 'preview' \| null` | `readOnly` while a committed session is on screen (`useQcSessionStore().isReadOnly`), else `preview` while an earlier history step is shown (`usePlotlyStore().previewIndex`), else `null`. | + +### `useEditSession()` + +Orchestrates the server-backed QC session workflow against the +`services/qualityControl/` glue: ```ts -const { submitQcEdits } = useQcSubmission() -await submitQcEdits() // POST observations with mode=replace, clear history +const { + beginEditing, startSession, saveDraft, discardUnsavedEdits, commit, + hasUnsavedChanges, unsavedEditCount, +} = useEditSession() ``` -Single-shot: guards on (selectedSeries + qcDatastream + non-empty -history), serializes `[phenomenonTime, result]` rows, POSTs with -`mode: 'replace'`, surfaces a Snackbar, and clears the history in place -on success. +- `beginEditing()`: resolves the QC history for the QC datastream, loads + its sessions and resumes the in-progress one. Returns `'resumed'`, + `'needs-session'` when there is none to resume, or `'not-managed'` when + the datastream isn't QC-managed yet. Resuming rebuilds the shared working + copy; if that rebuild is superseded it throws `ResumeSupersededError` + rather than asking for a session, and `enterEdit` returns to the Select + view. Entries can overlap, so it also throws + `ResumeSupersededError` when the edit target changes during any of its + fetches, and it never writes the session store (source, sessions) for a + target it no longer owns: sessions are fetched with `fetchSessions` and + applied only while still the owner. +- `startSession(spec)`: creates a session and copies the source window in. + When the history already has an in-progress session it resumes that one + through the same replay instead (throwing `ResumeSupersededError` the same + way), so saved draft operations are never dropped. The same ownership rule + guards its session reload. +- `saveDraft()`: persists the record's edit operations to the session + (append-only reconcile). +- `discardUnsavedEdits()`: drops edits made since the last save and restores + edited comments. +- `commit()`: saves, checks that the source window's checksum + (`datastreams.getObservationsChecksum`) still matches the one the session + started with, pushes observations (`mode: 'replace'` over the session + window), then locks the session and reloads the sessions. A failed push + throws before the session is locked, so it stays in progress. If + another target took over the editor while they reloaded, it leaves the + session store and the saved-edits baseline alone and still resolves, since + the commit itself went through. +- `hasUnsavedChanges` / `unsavedEditCount`: the working copy compared with + the saved-edits baseline (`qcSession.savedEdits` / `savedComments`). The + baseline lives in the store, so every caller agrees: the editor footer and + the leave flow both read it. + +### `useCreateManagedDatastream()` + +```ts +const { create } = useCreateManagedDatastream() +const { managedDatastream, history } = await create({ + source, + processingLevelId, + name, + description, + status, // omitted to create the datastream without one + sensorId, // the datastream's method +}) +``` + +`description`, `status` and `sensorId` are writable on a datastream create +(see the client's `writableKeys`); there is no separate method field, since a +datastream's method is its sensor. They are passed as overrides, so they win +over the values copied from the source. + +Delegates to the tested `createManagedDatastream` orchestration with the +live client (`hs.datastreams` + `hs.qualityControlHistories`). The datastream +is created with `expand_related: true`, so `managedDatastream` comes back in +the same `Datastream & DatastreamExtended` shape as the `datastreams` catalog +and can be appended to it directly. Without the flag the 201 body is the flat +model (FK ids only) and catalog consumers reading `ds.processingLevel.id` +would break. + +### `useManagedDatastreams()` + +```ts +const { loadForSource } = useManagedDatastreams() +const options = await loadForSource(sourceDatastreamId) +// options: [{ historyId, managed, sessions }] +``` + +Resolves a source datastream's managed (QC) datastreams from the loaded QC +histories and fetches each one's sessions. Feeds the row Edit button's +chooser (`StartEditingFlow.vue`), which lists managed datastreams with their +in-progress/committed sessions. + +- `deleteManaged(historyId, managedId)`: removes the QC history, then the + managed datastream. +- `deleteSession(historyId, sessionId)`: removes one session and throws the + server's message when it refuses (the session has dependents). Callers only + offer the newest session, or an empty in-progress one. + +### `useDatastreamMetadata()` + +```ts +const { sensors, statuses, load } = useDatastreamMetadata() +await load() +``` + +Reference data for the create-datastream form: the active workspace's sensors +(`hs.sensors.listAllItems`, every page, including the system-level ones) and the datastream status +vocabulary (`hs.datastreams.getStatuses`). Loaded on demand when the form +opens rather than with the workspace catalog in `App.vue`, since nothing else +needs it. Neither request throws: a list that fails to load stays empty and +the form falls back to the source datastream's own value. + +### `useWorkspacePermissions()` + +Synchronous, reactive role/permission checks for gating UI. The role +travels with the `Workspace` object (`collaboratorRole.permissions`; owners +have a null role; `accountType === 'admin'` overrides), so no separate +endpoint is needed. + +```ts +const { canEdit, canCommit, canCreateDatastream, roleName } = + useWorkspacePermissions() +canEdit() // selected workspace: can work in QC sessions? +canCommit() // ...and commit them? +canCreateDatastream(ws) // can create a managed datastream here? +roleName(ws) // 'Owner' | | 'Admin' | 'Read-only' +``` + +Each check is what the API enforces for that step: + +| Check | Needs | Gates | +|---|---|---| +| `canEdit` | edit Datastream (QC sessions and operations) | row Edit, New session, Save | +| `canCommit` | `canEdit` and create Observation (the push to the managed datastream) | Commit | +| `canCreateDatastream` | `canEdit` and create Datastream (its QC history needs edit) | the create-datastream form | + +`roleName` labels each workspace's role on the picker. ### `useResizable()` @@ -146,7 +412,7 @@ and the plot's table-vs-chart splitter. ### `useBufferedNumber()` -Debounced numeric input wrapper for filter panels — avoids dispatching +Debounced numeric input wrapper for filter panels. Avoids dispatching on every keystroke when a user is typing a threshold. ## Internal: Pinia stores @@ -163,13 +429,13 @@ Columns in the per-store tables: `T` inside `Ref`; actions list the call signature. The "Persistence" line at the top of each store cites the -`pinia-plugin-persistedstate` config — the storage key and the +`pinia-plugin-persistedstate` config: the storage key and the specific slice picked. Stores with no Persistence line are session-only. -### `useDataVisStore()` — `src/store/dataVisualization.ts` +### `useDataVisStore()` (`src/store/dataVisualization.ts`) Catalog data (sites, datastreams, taxonomy), sidebar filters, plotted -set + QC target, time-range window. The orchestrator for everything in +set + edit target, time-range window. The orchestrator for everything in the Select drawer and the rebuild pipeline that owns `rebuildPlot()`. Persistence: `selectedDateBtnId` only (so the user's preset choice @@ -180,47 +446,97 @@ on boot. |-------------------------------------|----------|---------------------------------------------------|-------| | `things` | state | `Thing[]` | Sites in the active workspace; fetched once on workspace mount. | | `datastreams` | state | `(Datastream & DatastreamExtended)[]` | All visible datastreams (with `expand_related` nested objects). | +| `qcHistories` | state | `QualityControlHistory[]` | Workspace QC histories (each links a managed datastream to its source); loaded with the catalog. | +| `managedDatastreamIds` | computed | `Set` | Ids of every managed (QC) datastream; hidden from the catalog (reached via the row Edit chooser). | +| `historiesBySource` | computed | `Map` | `sourceDatastreamId` -> its QC histories; drives the row Edit chooser. | +| `addQcHistory` | action | `(history: QualityControlHistory) => void` | Register a newly-created history so its managed datastream hides from the catalog and shows in the chooser without a reload. | +| `removeManagedDatastream` | action | `(historyId: string, managedId: string) => void` | Drop a deleted managed datastream + its history from local state (chooser/catalog) after deleting it server-side. | +| `replaceDatastream` | action | `(ds: Datastream & DatastreamExtended) => void` | Swap a fresh copy into the catalog and plotted set (used after a commit moves a managed datastream's phenomenon times). | | `observedProperties` | state | `ObservedProperty[]` | Taxonomy for the filter chips. | | `processingLevels` | state | `ProcessingLevel[]` | Taxonomy for the filter chips. | | `selectedThings` | state | `Thing[]` | Site filter selection (sidebar). | | `selectedObservedPropertyNames` | state | `string[]` | Observed-property filter selection. | | `selectedProcessingLevelNames` | state | `string[]` | Processing-level filter selection. | -| `filteredDatastreams` | computed | `(Datastream & DatastreamExtended)[]` | `datastreams` narrowed by the three filter selections. | -| `plottedDatastreams` | state | `Datastream[]` | Up to 5 streams currently on the chart. | -| `qcDatastreamId` | state | `string \| null` | Storage form of the QC target; survives plotted-list mutations. | -| `qcDatastream` | computed | `Datastream \| null` | Live lookup of `qcDatastreamId` in `plottedDatastreams`. | -| `qualifierSet` | state | `Set` | Qualifier codes seen on the QC target's loaded points. | +| `filteredDatastreams` | computed | `(Datastream & DatastreamExtended)[]` | `datastreams` narrowed by the three filter selections, with managed (QC) datastreams excluded. | +| `plottedDatastreams` | state | `Datastream[]` | Up to 4 streams the user chose to plot. Editing never adds to or removes from it (snapshots are the exception, dropped on leave); the 4-stream cap doesn't count the edit target or its source, so the plot holds at most 6 series while editing. | +| `qcDatastreamId` | state | `string \| null` | The edit target's id. Set only by the edit flow (`setEditTarget` / `clearEditTarget`); null in the Select view. | +| `qcDatastream` | computed | `Datastream \| null` | Live catalog lookup of `qcDatastreamId` in `datastreams`, not `plottedDatastreams`, since the edit target isn't a plotted entry. | +| `sourceContextDatastream` | computed | `Datastream \| null` | The source drawn around the edit target as context (light grey, cut around the session window), with a `ctx:` id so the user can also plot the source as an ordinary series beside it. Null without an edit target or with context switched off. | +| `seriesDatastreams` | computed | `Datastream[]` | What the plot actually draws, in order: `[edit target, its source, ...plotted minus the source]` while editing, otherwise `plottedDatastreams` unchanged. A plotted edit target is drawn under its `ctx:` id, from its committed data, beside the edit record. `toggleDatastream` takes either id. Refresh, colour assignment, series ordering, working-copy invalidation, `PlottedDatastreams`, the share watcher, and snapshots all iterate this instead of `plottedDatastreams`. | +| `qualifierSet` | state | `Set` | Qualifier codes seen on the edit target's loaded points. | | `selectedQualifier` | state | `string` | Active qualifier in the picker. | | `selectedData` | state | `number[] \| null` | Index list of the active selection (lasso, box, click). | | `hasSelectionShape` | state | `boolean` | True while a box/lasso shape exists, even when it captured zero points. | -| `loadingStates` | state | `Map` | Per-datastream in-flight observation fetches. | -| `beginDate` / `endDate` | state | `Date` | Active loaded time-range window (the date pickers' source of truth). | -| `dateOptions` | state | `Array<{ id, icon, label, title, calculateBeginDate }>` | Preset definitions (1w, 1m, 6m, 1y, YTD, All). | -| `selectedDateBtnId` | state | `number` | Active preset id; `-1` when the user picked dates manually. | +| `loadingStates` | state | `Map` | Per-datastream in-flight observation fetches. Only the latest request for a datastream clears its flag. | +| `isEditorReady` | computed | `boolean` | True while editing once the session window is known, the edit record is on the plot, no session is opening (`isSwitchingSession`), and no plot work is pending: no queued or running plot load (including the context re-anchor around the session window) and no tracked draw. Bound to `data-editor-ready` on the edit view's `edit-plot-column`; e2e `waitForEditorReady` waits on it. | +| `trackPlotWork` | action | `(work: () => Promise) => Promise` | Run `work` counted as pending plot work for `isEditorReady`. Plot loads and `setEditRecord` use it, and `Plot.vue` counts its first draw from the moment the plot element appears. | +| `beginDate` / `endDate` | state | `Date` | Active loaded window. A preset re-resolves it on every plot rebuild: around the edit session's window while one is set (`presetAroundWindow`), otherwise back from the context data's (`seriesDatastreams` minus the edit target) end (`presetWindow`). It also re-resolves when the edit session's window loads or changes. A custom range stays fixed. | +| `selectedDateBtnId` | state | `number` | The Select view's Time range preset id (default `1`, 1m); `-1` (`CUSTOM_PRESET_ID`) for a manual range. Presets are defined in `utils/timeRangePresets.ts`. | +| `contextPresetId` | state | `number` | The editor's Context range preset id, remembered apart from `selectedDateBtnId` so neither moves the other. Persisted. | +| `activePresetId` | computed | `number` (writable) | The preset the loaded range follows: `contextPresetId` while an edit target is set, else `selectedDateBtnId`. `onDateBtnClick` and a custom `setDateRange` write it. | +| `editSessionWindow` | computed | `TimeWindow \| null` | The viewed session's window, else the in-progress one's; null without an edit target. | +| `editSourceDatastream` | computed | `Datastream \| null` | The edit target's source datastream, however it is drawn. | +| `showSourceContext` | state | `boolean` | Whether the edit target's source is drawn around it as context (default `true`). Persisted with `selectedDateBtnId`. | +| `setShowSourceContext` | action | `(show: boolean) => Promise` | Turn the source context on or off from the Context menu; rebuilds the plot while editing. | | `matchesSelectedThing` | action | `(ds) => boolean` | Filter predicate; exposed so the table can reuse it on row updates. | | `matchesSelectedObservedProperty` | action | `(ds) => boolean` | Same shape as above. | | `matchesSelectedProcessingLevel` | action | `(ds) => boolean` | Same shape as above. | -| `setDateRange` | action | `({ begin?, end?, update?, custom? }) => Promise` | No-ops when neither bound moves; clears zoom history when it does. | -| `onDateBtnClick` | action | `(id: number) => void` | Anchors `endDate` to today, recomputes `beginDate`, applies the preset. | -| `syncRangeToPreset` | action | `() => void` | Re-derives `beginDate`/`endDate` from `selectedDateBtnId`. Run on `afterHydrate` so the restored preset's window applies to the first load. | -| `refreshGraphSeriesArray` | action | `() => Promise` | Reconciles `graphSeriesArray` against `plottedDatastreams` (fetch deltas + reorder + recolor). | -| `resetState` | action | `() => void` | Clears filters + plotted set on a workspace swap; preserves the preset preference. | +| `setDateRange` | action | `({ begin?, end?, update?, custom? }) => Promise` | No-ops when neither bound moves. Otherwise sets the bounds at once and queues a range reload as a plot load (see `rebuildPlot`), which joins a queued rebuild instead of loading on its own and is skipped when an earlier load already caught up with the range. While editing it reloads context only and preserves the zoom (`redraw(false, true)`), since the edit target's data isn't fetched here. Otherwise it clears zoom history and applies the new window. | +| `onDateBtnClick` | action | `(id: number) => Promise` | Selects the preset and applies the window the internal `resolvePresetWindow` gives it: around the edit session's window while editing one, otherwise back from the context data's end. With nothing to anchor to, only the selection changes. | +| `refreshGraphSeriesArray` | action | `() => Promise` | Reconciles `graphSeriesArray` against `seriesDatastreams` (fetch deltas + reorder + recolor), skipping the edit target (its data is owned by the edit session, not this refresh). A managed datastream with a loaded working copy (`useWorkingCopiesStore`) uses it instead of fetching. Invalidates the working copy of any managed datastream no longer in `seriesDatastreams`, except the edit target. | +| `resetState` | action | `() => void` | Clears filters, the plotted set, and the edit target, and drops every working copy, on a workspace swap; preserves the preset preference. | | `toggleDatastream` | action | `(ds: Datastream) => Promise` | Plot if absent, unplot if present. | -| `plotDatastream` | action | `(ds: Datastream) => Promise` | Add to plot; promotes to QC when nothing's there yet. | -| `unplotDatastream` | action | `(id: string) => Promise` | Remove; promotes the previous plotted entry to QC if removing the QC target. | +| `plotDatastream` | action | `(ds: Datastream) => Promise` | Add to plot. Plotting never picks or changes the edit target. | +| `unplotDatastream` | action | `(id: string) => Promise` | Remove from the plotted set. Never touches the edit target. | | `clearPlottedDatastreams` | action | `() => Promise` | Drop the entire plotted set. | -| `setPlottedDatastreams` | action | `(items: Datastream[], qcId?: string \| null) => Promise` | Wholesale replace; used by URL hydration. | -| `setQcDatastream` | action | `(id: string \| null) => Promise` | Change QC target; preserves the current zoom. | -| `rebuildPlot` | action | `() => Promise` | Serialized rebuild (drop zoom history, refresh series, regenerate options, render). Coalesces concurrent callers. | - -### `usePlotlyStore()` — `src/store/plotly.ts` +| `sourceGroupIds` | action | `(sourceId: string) => string[]` | The source datastream plus every managed (QC) datastream derived from it. | +| `plotSourceSelection` | action | `(sourceId: string, ids: string[]) => Promise` | Apply a whole "what to plot for this source" choice at once: `ids` is the complete set wanted from that source's group. Group members absent from `ids` are unplotted, additions are appended in `ids` order. One rebuild for the whole selection; never touches the edit target. | +| `addSnapshotSeries` | action | `(id: string, record: ObservationRecord, meta: SnapshotMeta) => Promise` | Add a frozen history snapshot as an extra comparison line under the synthetic id `snap::`. Never touches the edit target; `refreshGraphSeriesArray` skips its fetch. | +| `removeSnapshotSeries` | action | `(id: string) => Promise` | Drop one snapshot line. Leaves the edit target alone. | +| `setPlottedDatastreams` | action | `(items: Datastream[]) => Promise` | Replace the plotted set wholesale (used by URL hydration). Doesn't touch the edit target. Hydrate that separately with `setEditTarget`. | +| `setEditTarget` | action | `(managedId: string) => Promise` | Begin editing `managedId`: drops snapshots, resets the `qcSession` store (not `resumeDatastreamId`) and clears zoom history when the target changes, sets `qcDatastreamId`, rebuilds the plot. Its data arrives later via `setEditRecord`. This action doesn't fetch it. | +| `setEditRecord` | action | `(record: ObservationRecord) => Promise` | Put the edit target's record on the plot: updates its graph series in place if one exists, otherwise adds it (`buildGraphSeries`), then re-plots keeping the x view and refitting the target's y axis (`handleNewPlot` with `refitSeriesIds`). The only path by which the edit target's data reaches the plot. Counted as plot work until drawn. | +| `clearEditTarget` | action | `() => Promise` | Stop editing: drops snapshots, invalidates the edit target's working copy, clears `qcDatastreamId`, removes its graph series, and rebuilds. Plotted datastreams are left exactly as they were. | +| `rebuildPlot` | action | `() => Promise` | Rebuild (refresh series, regenerate options, render) as a plot load. Plot loads (rebuilds and `setDateRange` range reloads) run one at a time; requests made while one runs share one queued follow-up, which is a rebuild if any of them was, and every caller resolves only after a load that started after its change. A load whose range moves while it loads drops those responses and loads again before drawing. Drops zoom history in the Select view; keeps the zoom while an edit target is set. | + +### `useWorkingCopiesStore()` (`src/store/workingCopies.ts`) + +One working copy per managed datastream, keyed by its in-progress +session: the Select-view plot and the editor share it so the preview +shows exactly what editing opens. Not persisted, not reactive (records +hold large typed arrays that must not be proxied). + +Concurrency: a per-managed-id generation counter, bumped by `invalidate`, +`set`, `clear`, and every new build, guards each build's cache write: a +build whose generation is no longer current is discarded rather than +resurrecting a stale copy. Concurrent `load()` calls for the same managed +id share one in-flight build and resolve to the same record, and a `load()` +that meets an in-flight `rebuild()` joins it instead of starting its own +build. A superseded `rebuild()` or `load()` never returns the record it +discarded: it resolves to the newer in-flight `rebuild()`'s result +(whichever fetch finishes first), otherwise to whatever is now cached +(`null` after `invalidate` or `clear`). `invalidate`, `set`, and `clear` +also drop the in-flight load they supersede, so the next `load()` starts +fresh. + +| Name | Kind | Type / signature | Notes | +|--------------|--------|-------------------------------------------------------------------------------------------------------|-------| +| `get` | action | `(managedId: string) => WorkingCopy \| undefined` | Current cached copy for a managed datastream, if any. | +| `load` | action | `(managed: Datastream, source: Datastream, historyId: string) => Promise` | Returns the cached copy when it matches the history's in-progress session; rebuilds and caches otherwise; `null` (and evicts any stale cache entry) when the history has no in-progress session. Concurrent calls for the same managed id dedupe onto one build, and a call made while a `rebuild()` is in flight joins it. If superseded mid-build, resolves to the superseding `rebuild()`'s result or the now-current cache entry (or `null`) instead of the discarded build. | +| `rebuild` | action | `(managed: Datastream, source: Datastream, historyId: string, session: SessionWindow) => Promise` | Always reconstructs from the session window and replays its operations, even when a cached copy already matches; supersedes any in-flight `load()` build or earlier `rebuild()` for this managed id and overwrites the cache. If superseded itself while awaiting, never returns the discarded build: resolves to a later in-flight `rebuild()`'s result, or reads the cache after a synchronous `invalidate`/`set`/`clear`, which is `null` when nothing ended up cached. | +| `set` | action | `(managedId: string, sessionId: string, record: ObservationRecord, begin: Date, end: Date) => void` | Insert or replace a cache entry directly (used by `startSession` to store a new session's fresh base); supersedes any in-flight build or load for this managed id. | +| `invalidate` | action | `(managedId: string) => void` | Drop a managed datastream's cached copy; supersedes any in-flight build or load for this managed id. | +| `clear` | action | `() => void` | Drop every cached copy and supersede every in-flight build and load; called by `useDataVisStore().resetState()` on a workspace reset. | +| `extents` | action | `(managedIds: string[]) => { phenomenonBeginTime: string; phenomenonEndTime: string }[]` | The cached copies' windows for the given managed ids, in ISO form; ids with no cached copy are omitted. | + +### `usePlotlyStore()` (`src/store/plotly.ts`) Owns the Plotly DOM ref, the per-series array driving the chart, viewport state (tooltips, crosshair, hover, zoom history), and the redraw / restyle plumbing. Persistence: `tooltipsMaxDataPoints`, `tooltipsMode`, and -`tooltipsManualEnabled` (key `qc.plot.tooltipsMaxDataPoints`) — the +`tooltipsManualEnabled` (key `qc.plot.tooltipsMaxDataPoints`): the user's data-points-mode preference. Everything else is ephemeral (DOM handles, live chart caches). @@ -229,13 +545,13 @@ handles, live chart caches). | `graphSeriesArray` | state | `GraphSeries[]` | Per-series state driving traces, colors, and axis chips. | | `plotlyOptions` | state | `PlotlyChartOptions` | Cached `createPlotlyOption` output; seeded empty so consumers can read without null-guards. | | `plotlyRef` | state | `AppPlotlyHTMLElement \| null` | Live Plotly DOM element; populated by `handleNewPlot`. | -| `mainPlotEpoch` | state | `number` | Monotonic counter — bumped per `handleNewPlot` so listeners can re-attach. | -| `selectedSeriesIndex` | computed | `number` | Index of the QC target in `graphSeriesArray` (`-1` when none). | +| `mainPlotEpoch` | state | `number` | Monotonic counter, bumped per `handleNewPlot` so listeners can re-attach. | +| `selectedSeriesIndex` | computed | `number` | Index of the edit target in `graphSeriesArray` (`-1` when none). | | `selectedSeries` | computed | `GraphSeries` | Convenience for `graphSeriesArray[selectedSeriesIndex]`. | -| `editHistory` | state | `HistoryItem[]` | Mirrors `selectedSeries.data.history` (mutated in place — never reassign). | +| `editHistory` | state | `HistoryItem[]` | Mirrors `selectedSeries.data.history` (mutated in place; never reassign). | +| `previewIndex` | computed | `number \| null` | The earlier history step the edit target shows (`-1` for the starting state), or null for the whole history. While set, edits wait: operations, plot selections and table saves are held. | | `suppressedEchoSelection` | state | `number[] \| null` | Sentinel armed by programmatic Plotly writes to suppress the echo SELECTION dispatch. | | `isUpdating` | state | `boolean` | Surfaced in the nav rail while a redraw runs. | -| `isSubmitting` | state | `boolean` | True during a QC submit POST. | | `showLegend` | state | `boolean` | Drives Plotly's legend visibility. | | `showTooltip` | state | `boolean` | Legacy flag; tooltip control routes through the auto/manual mode below. | | `tooltipsMaxDataPoints` | state | `number` | Auto-mode cutoff (default 10 000); user-tunable from the data-points menu. | @@ -249,28 +565,29 @@ handles, live chart caches). | `hiddenAxisIds` | state | `Set` | Datastream ids whose right-side y-axis chrome is hidden. | | `hiddenTraceIds` | state | `Set` | Datastream ids whose trace is fully hidden (eye toggle). | | `activeTab` | state | `'plot' \| 'table'` | Center-column tab; captured by the share URL. | -| `tableScrollRequest` | state | `{ time: number; seq: number } \| null` | Signal from the "zoom to range" presets; `DataTable` scrolls to the first row at/after `time`. `seq` re-triggers on repeats. | +| `tableScrollRequest` | state | `{ time: number; seq: number } \| null` | Set when the plot zooms to the session window; `DataTable` scrolls to the first row at/after `time`. `seq` re-triggers on repeats. | | `requestTableScroll` | action | `(time: number) => void` | Publish a `tableScrollRequest` for the given epoch-ms range start (bumps `seq`). | | `axisChips` | state | `AxisChip[]` | Horizontal axis title chips (replaces Plotly's rotated titles). | -| `previewMode` | state | `boolean` | Strips select/lasso/etc when the chart is rendered in the Select view's preview slot. | | `zoomUndoStack` | state | `ZoomState[]` | Captured viewports for the modebar's Undo zoom button. | | `zoomRedoStack` | state | `ZoomState[]` | Cleared on every new user-initiated zoom. | | `suppressZoomHistory` | state | `boolean` | Flipped on during programmatic restores so the recorder doesn't double-capture. | | `pendingShareZoom` | state | `ZoomState \| null` | URL-hydrated zoom; applied once on mount then cleared. | +| `shareZoomEditTarget` | state | `string \| null` | The `ed` target of that link, when it carried one: the one session window the share zoom outranks. | | `canUndoZoom` | computed | `boolean` | `zoomUndoStack.length > 1`. | | `canRedoZoom` | computed | `boolean` | `zoomRedoStack.length > 0`. | | `currentZoom` | computed | `ZoomState \| null` | Top of the undo stack; what the share URL writer subscribes to. | | `updateOptions` | action | `() => void` | Rebuild `plotlyOptions` from `graphSeriesArray`. | -| `redraw` | action | `(recomputeXaxisRange?: boolean, preserveZoom?: boolean) => Promise` | Push typed-array updates + restyle; preserves the user's zoom by default. | +| `redraw` | action | `(recomputeXaxisRange?: boolean, preserveZoom?: boolean) => Promise` | Push typed-array updates + restyle; preserves the user's zoom by default, except on a y axis that had no points drawn. Leaves `layout.shapes` (the staging band) alone. | | `clearChartState` | action | `() => void` | Drop all series + zoom history (used on workspace swap). | -| `fetchGraphSeries` | action | `(ds, start: Date, end: Date) => Promise` | Build a fresh `GraphSeries` from observations; colour is filled later by `assignSeriesColors`. | +| `fetchGraphSeries` | action | `(ds, start: Date, end: Date) => Promise` | Fetch observations for `ds` over `[start, end]` and build a `GraphSeries` via `buildGraphSeries`. | +| `buildGraphSeries` | action | `(ds: Datastream, data: ObservationRecord) => GraphSeries` | Build a `GraphSeries` from an already-loaded record, no fetch. Used directly for a managed datastream's working copy. | | `assignSeriesColors` | action | `(orderedIds: string[]) => void` | Stable per-id colour assignment over the legend order. | -| `colorForDatastream` | action | `(id?: string) => string` | Resolve the line colour for a datastream (QC is always black). | +| `colorForDatastream` | action | `(id?: string) => string` | Resolve the line colour for a datastream (QC is always dark grey, `#3f3f3f`). | | `labelColorForDatastream` | action | `(id?: string) => string` | Darker companion for legend text. | | `clearZoomHistory` | action | `() => void` | Empty both stacks. | | `pushZoomState` | action | `(state: ZoomState) => void` | Called by the debounced recorder in `utils/plotting/zoom.ts`. | -### `useObservationStore()` — `src/store/observations.ts` +### `useObservationStore()` (`src/store/observations.ts`) Fetches + caches observation windows and inflates them into `ObservationRecord` instances. @@ -279,11 +596,13 @@ Fetches + caches observation windows and inflates them into |----------------------------|----------|---------------------------------------------------|-------| | `observations` | state | `Record` | Per-datastream record; reused across rebuilds. | | `observationsRaw` | state | `Record` | Typed-array cache (`Float64Array` datetimes + `Float32Array` values). | -| `fetchObservationsInRange` | action | `(ds: Datastream, b: Date, e: Date) => Promise` | Extends the cached range minimally; only fetches segments outside the existing window. | +| `fetchObservationsInRange` | action | `(ds: Datastream, b: Date, e: Date, exclude?: { begin: Date; end: Date }) => Promise` | Fetches only the parts of `[b, e]` never asked for before, skipping `exclude`, and returns the shared record windowed to `[b, e]`. Requests for one datastream run in order, so the record ends on the latest requested window; a request matching the last queued one shares its promise. | +| `fetchDetachedRecord` | action | `(ds: Datastream, b: Date, e: Date) => Promise` | Fills the same cache through the same per-datastream queue, but returns a record of its own windowed to `[b, e]`. For working copies and snapshots: never re-windows the shared record the plot draws. | +| `forget` | action | `(datastreamId: string) => void` | Drops a datastream's cached observations so the next load asks the server again. Called by `commit()` after its upload; a load already in flight discards its result and retries. | -### `useWorkspaceStore()` — `src/store/workspaces.ts` +### `useWorkspaceStore()` (`src/store/workspaces.ts`) -Workspace selection + role-derived edit permission. +Workspace selection. Permissions come from `useWorkspacePermissions()`. Persistence: `selectedWorkspace` only (key `qc:selectedWorkspace:v1`) so the router's `hasWorkspaceGuard` sees a @@ -296,16 +615,15 @@ restored selection on the first navigation. | `isLoading` | state | `boolean` | True while `loadWorkspaces` is in flight. | | `selectedWorkspaceId` | computed | `string \| null` | Shortcut for `selectedWorkspace?.id`. | | `hasSelection` | computed | `boolean` | True iff `selectedWorkspace` is non-null. | -| `canEditSelected` | computed | `boolean` | True for workspace owners; for collaborators, true when their role includes an Observation create/edit permission. | | `loadWorkspaces` | action | `() => Promise` | Refetch + reconcile against the stored selection (drops the selection if the user lost access). | | `selectWorkspace` | action | `(id: string \| null) => Workspace \| null` | Pick by id from `availableWorkspaces`. | -| `applyWorkspaceById` | action | `(id: string) => Workspace \| null` | Falls back to a placeholder `{ id }` when the list isn't loaded yet — used by URL hydration. | +| `applyWorkspaceById` | action | `(id: string) => Workspace \| null` | Falls back to a placeholder `{ id }` when the list isn't loaded yet. Used by URL hydration. | | `clearSelection` | action | `() => void` | Drop the selection. | -### `useUIStore()` — `src/store/userInterface.ts` +### `useUIStore()` (`src/store/userInterface.ts`) Drawer / view chrome state plus the per-operation form fields read -by every filter / edit panel. Mostly a flat bag — the panel +by every filter / edit panel. Mostly a flat bag: the panel components own the validation; this store just keeps the values reactive between mounts. @@ -315,12 +633,13 @@ defaults are reseeded from the datastream on each mount. | Name | Kind | Type / signature | Notes | |-----------------------------------|--------|---------------------------------------------------|-------| -| `selectedDrawer` | state | `DrawerType` | `Edit`, `Select`, or `None` — which left drawer is active. | +| `selectedDrawer` | state | `DrawerType` | `Edit`, `Select`, or `None`: which left drawer is active. | | `isDrawerOpen` | state | `boolean` | Drawer open/collapsed. | | `currentView` | state | `'Edit' \| 'Select'` | Current main view (drives the nav rail's active state). | +| `isPlotPreview` | computed | `boolean` | True only in the Select view with nothing being edited; an open edit target keeps the full plot chrome in both views. | | `selectedOperation` | state | `string \| null` | Open operation panel id; `null` when nothing is open. | | `cardHeight` / `tableHeight` | state | `number` | Select-view top/bottom split. | -| `operators` | state | `string[]` | `Object.keys(Operator)` — Change-values operator choices. | +| `operators` | state | `string[]` | `Object.keys(Operator)`: Change-values operator choices. | | `selectedOperator` | state | `number` | Index into `operators`. | | `operationValue` | state | `number` | Change-values numeric operand. | | `interpolateValues` | state | `boolean` | Fill-gaps: interpolate vs constant. | @@ -337,13 +656,14 @@ defaults are reseeded from the datastream on each mount. | `filterRangeActive` | state | `boolean` | Toggles the shared filter-window UX; the only persisted field. | | `filterRangeFromTs` / `filterRangeToTs` | state | `number \| null` | Filter-window epoch bounds; reseed on each panel mount. | | `onRailItemClicked` | action | `(title: DrawerType) => void` | Nav-rail click handler: toggles open/closed on repeat, switches view on first click. | +| `showView` | action | `(view: View) => void` | Switch layouts (view + drawer). Layout only: an open edit session is untouched. | -### `useQualifierStore()` — `src/store/qualifiers.ts` +### `useQualifierStore()` (`src/store/qualifiers.ts`) Workspace-scoped qualifier dictionary plus the per-observation applications added via the Qualifying Comments panel. -Persistence: `applied` only — the dictionary is reloaded on every +Persistence: `applied` only; the dictionary is reloaded on every workspace change. | Name | Kind | Type / signature | Notes | @@ -354,12 +674,12 @@ workspace change. | `qualifierById` | computed | `Record` | Lookup map for the chips. | | `loadQualifiers` | action | `() => Promise` | Fetch dictionary for the active workspace; triggers a plot refresh so the qualifier band materialises. | | `createQualifier` | action | `(code: string, description: string) => Promise` | Server-side create with a local-only fallback when no workspace is active. | -| `applyQualifiers` | action | `(datastreamId, indices, qualifierIds, appliedBy) => void` | Idempotent merge — already-applied (qualifier, index) pairs are skipped. | +| `applyQualifiers` | action | `(datastreamId, indices, qualifierIds, appliedBy) => void` | Idempotent merge: already-applied (qualifier, index) pairs are skipped. | | `removeQualifier` | action | `(datastreamId, index, qualifierId) => void` | Drops a single (qualifier, index) application. | | `getApplicationsForDatastream` | action | `(datastreamId) => Array<{ index, qualifierId, appliedAt, appliedBy }>` | Flat list suitable for plotting. | | `getApplicationsAtIndex` | action | `(datastreamId, index) => QualifierApplication[]` | Per-point lookup. | -### `useUiLayoutStore()` — `src/store/uiLayout.ts` +### `useUiLayoutStore()` (`src/store/uiLayout.ts`) Persisted drawer / splitter geometry. A bag of values keyed by strings the calling composable supplies, so new resizable components @@ -376,7 +696,7 @@ Persistence: both `sizes` and `flags` (key `qc:uiLayout:v1`). | `getFlag` | action | `(key: string) => boolean \| null` | `null` lets callers distinguish "unset" from "explicit false". | | `setFlag` | action | `(key: string, value: boolean) => void` | Same fresh-object pattern. | -### `useOperationParamsStore()` — `src/store/operationParams.ts` +### `useOperationParamsStore()` (`src/store/operationParams.ts`) Per-datastream remembered slots for Find Gaps / Fill Gaps parameters. `useUIStore` reads these to seed defaults; otherwise @@ -390,7 +710,7 @@ Persistence: `byDatastream` (key `qc:opParams:v1`). | `load` | action | `(id?: string \| null) => PersistedOpParams \| null` | `null` when nothing's stored. | | `save` | action | `(id?: string \| null, patch: PersistedOpParams) => void` | Merges; partial patches don't clobber unrelated fields. | -### `useUserStore()` — `src/store/user.ts` +### `useUserStore()` (`src/store/user.ts`) The signed-in user. Persisted in full. @@ -399,10 +719,10 @@ The signed-in user. Persisted in full. | `user` | state | `User` | Defaults to a fresh `new User()` until auth resolves. | | `setUser` | action | `(u: User) => void` | Replace wholesale (called by the session resolver in `main.ts`). | -### `useHydroServer()` — `src/store/hydroserver.ts` +### `useHydroServer()` (`src/store/hydroserver.ts`) Holds the `@hydroserver/client` instance. Initialized in `main.ts` -after settings load — every other store reaches `hs.value` through +after settings load; every other store reaches `hs.value` through `storeToRefs(useHydroServer())`. Not persisted (the client carries ephemeral connection state). @@ -410,29 +730,167 @@ ephemeral connection state). |------|-------|--------------------|-------| | `hs` | state | `Ref` | Non-null after `main.ts` finishes settings load; type-asserted as non-null for ergonomic consumer code. | +### `useQcSessionStore()` (`src/store/qcSession.ts`) + +View-mode state for QC sessions: which session is editable (the single +in-progress one) and which is being viewed. Viewing a committed session +puts the editor in read-only mode. + +| Name | Kind | Type / signature | Notes | +|---------------------|----------|-----------------------------------------|-------| +| `historyId` | state | `string \| null` | The managed datastream's QC history being navigated. | +| `resumeDatastreamId`| state | `string \| null` | Managed datastream the editor was last open on. The only persisted field: a page reload replots it and resumes its session from the last save. Set on entering the editor, cleared on exit. | +| `sessions` | state | `QualityControlSession[]` | Committed + in-progress sessions for the history. | +| `currentSessionId` | state | `string \| null` | The single in-progress (editable) session. | +| `viewedSessionId` | state | `string \| null` | The session currently being viewed. | +| `isSwitchingSession`| state | `boolean` | True while another session's data and operations load. The operations panel renders a loading state instead of the outgoing session's entries, which would otherwise linger and read as the incoming session's. | +| `savedEdits` | state | `HistoryItem[]` | Edit history entries (by reference) at the last load or save. `useEditSession` compares the working copy against it for `hasUnsavedChanges`; kept in the store so the editor footer and the leave flow agree. | +| `savedComments` | state | `string[]` | Comment text of `savedEdits`, since comments are edited in place. | +| `isReadOnly` | computed | `boolean` | True when sessions exist and the viewed one isn't the in-progress session. Guarded on `sessions.length` so plain editing outside the session workflow isn't treated as read-only. | +| `inProgressSession` | computed | `QualityControlSession \| null` | The editable session, if any. | +| `viewedSession` | computed | `QualityControlSession \| null` | The session for `viewedSessionId`. | +| `hasSessionOperations` | computed | `boolean` | True when the in-progress session holds any work: the operations the server returned with it, plus anything saved since (a save leaves them in `savedEdits` before the sessions are re-fetched). The leave flow tells an untouched session from one worth keeping with it. | +| `fetchSessions` | action | `(historyId: string) => Promise` | Fetch a history's sessions with their operations without writing any state, so a caller can drop a result that went stale (see `useEditSession`). | +| `applySessions` | action | `(historyId: string, sessions: QualityControlSession[]) => void` | Adopt fetched sessions; default the view to the in-progress one, else the one committed last (by `committedAt`), so a commit leaves the session it just made on screen. | +| `viewSession` | action | `(sessionId: string) => void` | View a session read-only (no-op for an unknown id). | +| `returnToCurrent` | action | `() => void` | Return to the editable in-progress session. | +| `reset` | action | `() => void` | Clear all state. | + +### `useQcPreferencesStore()` (`src/store/qcPreferences.ts`) + +Persisted QC preferences. Persistence: key `qc:preferences:v1`, +`pick: ['processingLevelId', 'displayZone']`. `main.ts` creates the store +at startup so the zone is restored before anything is formatted. + +| Name | Kind | Type / signature | Notes | +|---------------------|-------|------------------|-------| +| `processingLevelId` | state | `string \| null` | Last-used processing level for the Create-Datastream-for-Editing form; null on first use (no assumed default). | +| `displayZone` | state | `DisplayZone` | The time zone dates are shown in; the same ref `utils/timeZone.ts` exports. Defaults to the browser's IANA zone. | + ## Internal: utilities ### `src/utils/plotting/plotly.ts` (barrel) -Re-exports: +Re-exports `options`, `zoom`, `relayout`, `selected`, `events`, +`interaction` and `operations`. `internal.ts` stays private. The ones most +callers reach for: + +| Function | From | Purpose | +|--------------------------|-----------------|------------------------------------------------------------------| +| `handleNewPlot(...)` | `events.ts` | First draw: build traces, wire events, set the initial range. | +| `createPlotlyOption(...)`| `options.ts` | Build the traces and layout for the plotted series. | +| `setSelectedPoints(...)` | `operations.ts` | Programmatic selection update via `Plotly.restyle`. | +| `clearSelection(...)` | `operations.ts` | Drop the selection shape + restyle. | -| Function | Purpose | -|--------------------------|------------------------------------------------------------------| -| `handleNewPlot(...)` | First-mount: build traces, wire events, set initial range. | -| `setSelectedPoints(...)` | Programmatic selection update via `Plotly.restyle`. | -| `clearSelection(...)` | Drop the selection shape + restyle. | -| `redrawTraces(...)` | Push new typed-array x/y into all plotted traces. | -| `updateOptions(...)` | Apply axis label / range / tick-formatting changes. | +`updateOptions()` is on the plotly store (`store/plotly.ts`), not here. ### `src/utils/observations.ts` -`fetchObservationsSync(datastream, startTime?, endTime?)` — paged +`fetchObservationsSync(datastream, startTime?, endTime?)`: paged columnar fetch, returns `{ datetimes: number[]; dataValues: number[] }`. +### `src/utils/timeZone.ts` + +The time zone dates are shown and typed in. `displayZone` holds a +`DisplayZone`, `{ mode: 'utc' | 'fixedOffset' | 'iana', zone }`, where +`zone` is a fixed offset like `-0700` or an IANA name, the same choice a +data connection's timestamps offer (the lists are qc-utils' +`FIXED_OFFSET_TIMEZONES` and `DST_AWARE_TIMEZONES`). `browserZone()` is the +default. + +Instants stay epoch ms everywhere. A **wall** value is an instant moved by +the zone's offset, so its UTC fields read as the zone's clock: + +- `offsetMs(ms)`, `toWall(ms)`, `fromWall(wall)`, `toWallArray(xs)`: the + conversion, on qc-utils' zone math (see its Time zones section) with the + chosen zone filled in. +- `zoneId()`: the chosen zone as qc-utils takes it (`UTC`, `-0700`, or an + IANA name). The shift dialog passes it to `SHIFT_DATETIMES`, which saves + it with the step. +- `wallParts(ms)` / `fromWallParts(y, m, d, h, mi, s)`: the zone's clock + fields, for pickers and calendar arithmetic. +- `zoneAbbreviation(ms)`, `zoneDescription(ms)`, `zoneName()`: labels. + +`utils/time.ts` formats through it, and `dateMath.ts`, the YTD presets, +`DatePickerField` and the table's datetime input all work on the zone's +clock. Change the zone with `useDisplayZone().setZone(zone)`: it reads the +plot's view as real instants, switches the zone, rebuilds the plot, puts +the view back and redraws the stage band. + +### `src/utils/plotting/plotTime.ts` + +Plotly has no time zones and reads epoch ms as UTC, so trace x values go in +as wall values (`toPlotX`), which makes its axis, ticks and `%{x}` hover +read in the chosen zone. Ranges, shapes and tick values are written as +Plotly date strings (`toPlotDate`, `plotCoordToDate`): Plotly reads a bare +number there as browser-local time, which shifted every programmatic zoom +by the browser's UTC offset. `plotCoord` reads a range or shape value in +Plotly's own frame, for comparing with trace values, and `fromPlot` turns +any Plotly x back into an instant. Values the app keeps (zoom history, +share-link zoom, the stage band, hover readout) are real instants. + ### `src/utils/dateMath.ts` `subtractDays`, `subtractMonths`, `subtractYears` for the time-range -preset buttons. +preset buttons, on the chosen zone's clock. + +### `src/utils/timeRangePresets.ts` + +Preset definitions (`TIME_RANGE_PRESETS`, ids stable for the share URL) +and their pure resolution: + +- `dataExtent(datastreams)`: earliest begin to latest end over the given + phenomenon times; `null` when none has observations. +- `presetWindow(id, extent)`: the preset counted back from `extent.end` + (All is the whole extent, YTD starts Jan 1 of the end year). Used in the + Select view and while editing without a session window. +- `presetAroundWindow(id, window, extent)`: the editor's rule while a + session window is known. 1w / 1m / 6m / 1y return + `[window.begin - span, window.end + span]`, not clipped to the data. + All returns `extent` widened to cover `window` (or `window` when `extent` + is `null`); YTD resolves the same as All. `null` for an unknown id. +- `EDITOR_PRESETS`: the presets the editor's Context menu offers, without + YTD and titled for the session window. +- `shownPresetId(selectedId, presets)`: the chip that shows the selection + among `presets`. YTD shows as All where it is not offered; `null` for + Custom or an unknown id. Display only: `DataVisTimeFilters` highlights it + and `selectedDateBtnId` is unchanged. + +### `src/utils/sessionWindow.ts` + +Pure validation for the session-window step (`SessionWindowDialog.vue`, +opened from the row Edit chooser or the editor footer's **New session**): + +- `defaultSessionWindow(source, sessions)`. The step's default window, + where the last session left off: from the committed history's end to the + source's end (the **Since commit** preset). The source's full extent when + nothing is committed, when no data arrived after the history, or when the + history ends outside the source. `null` when the source has no + observations. The rules below accept it whenever the committed history + lies inside the source's extent; the dialog reports the rare case where it + does not. +- `sessionWindowIssue(window, source, sessions)`: `null` when `window` is + valid, otherwise `{ kind, message, fix }` for the first broken rule. The + window must lie inside the source's extent, and it can't leave a gap + before or after the committed history. + Touching an edge or overlapping is fine. `fix` carries only the + endpoints at fault, set to the nearest valid value (source start or end, + committed end for a gap after, committed start for a gap before), plus + button copy naming the date. It is `null` when the start is not before + the end, or when the correction would itself break a rule. The dialog + shows it as a button on the warning. +- `NO_SOURCE_DATA_ISSUE`: the issue for a source without observations. +- `sessionWindowPresets(source, sessions)`: the dialog's preset chips. + `All` (the whole record), then `1y`, `6m`, `1m` counting back from the + source's last observation through `presetWindow`, and, when something is + committed, `Since commit` (committed end to source end). The default + window is `defaultSessionWindow` above: `Since commit` when there is + committed work after which data arrived, else `All`. + Each carries its `window` and a short `disabledReason` when that window + breaks a rule; the dialog disables it and shows the reason as its + tooltip. Empty when the source has no observations. +- `committedExtent(sessions)`: the earliest committed start to the latest + committed end, or `null` with nothing committed. ### `src/utils/rules.ts` @@ -441,7 +899,7 @@ Vuetify validation rules used across forms (required, numeric, range). ## Test hooks When `VITE_APP_E2E_HOOKS=1` (Playwright sets this), `src/testHooks.ts` -attaches `window.__vbwTestHooks` with the handles E2E specs need — +attaches `window.__vbwTestHooks` with the handles E2E specs need: selecting a datastream programmatically, reading the current edit history, asserting the plot is ready. These are e2e plumbing, not a public surface; treat the names as unstable. @@ -468,8 +926,8 @@ Wire shape: ## Integrating from outside -If you want to reuse the QC engine in a non-Vue context — a Jupyter -notebook driven by Pyodide, a Node CLI, another web app — depend on +If you want to reuse the QC engine in a non-Vue context (a Jupyter +notebook driven by Pyodide, a Node CLI, another web app), depend on `@uwrl/qc-utils` directly and skip the QC App entirely. The QC App is a UI shell; the engine is independent. diff --git a/apps/qc-app/docs/ARCHITECTURE.md b/apps/qc-app/docs/ARCHITECTURE.md index a61a8d21..b91e8fbc 100644 --- a/apps/qc-app/docs/ARCHITECTURE.md +++ b/apps/qc-app/docs/ARCHITECTURE.md @@ -12,34 +12,36 @@ the end-user perspective, see [USER_GUIDE.md](./USER_GUIDE.md). | UI framework | Vue 3 (Composition API, ` diff --git a/apps/qc-app/src/components/EditData/DriftCorrection.vue b/apps/qc-app/src/components/EditData/DriftCorrection.vue index 643f6898..357d775a 100644 --- a/apps/qc-app/src/components/EditData/DriftCorrection.vue +++ b/apps/qc-app/src/components/EditData/DriftCorrection.vue @@ -99,13 +99,12 @@ import { storeToRefs } from 'pinia' import { useDataVisStore } from '@/store/dataVisualization' import { EnumEditOperations } from '@uwrl/qc-utils' import { computed } from 'vue' -import { formatDate } from '@uwrl/qc-utils' +import { formatDateTime } from '@/utils/time' import { usePlotlyStore } from '@/store/plotly' -import type { PlotData } from 'plotly.js-dist' import { useFilterDispatch } from '@/composables/useFilterDispatch' import { useUIStore, DriftCorrectionMethods } from '@/store/userInterface' const { recordPostActionSelection } = useFilterDispatch() -const { selectedSeries, plotlyRef, isUpdating } = storeToRefs(usePlotlyStore()) +const { selectedSeries, isUpdating } = storeToRefs(usePlotlyStore()) const { driftGapWidth, selectedDriftCorrectionMethod } = storeToRefs(useUIStore()) const { redraw } = usePlotlyStore() @@ -152,12 +151,12 @@ const onDriftCorrection = async () => { }) } +// Group indices are the edit record's, so its own times say where they start. const getGroupStart = (group: number[]) => { - const trace = plotlyRef.value?.data[0] as Partial | undefined - const xData = trace?.x as number[] | undefined const firstIdx = group[0] - if (!xData || firstIdx === undefined) return '' - return formatDate(new Date(xData[firstIdx] as number)) + const x = + firstIdx === undefined ? undefined : selectedSeries.value?.data.dataX[firstIdx] + return x === undefined ? '' : formatDateTime(x) } diff --git a/apps/qc-app/src/components/EditData/EditHistory.vue b/apps/qc-app/src/components/EditData/EditHistory.vue index 84802f5a..ae518085 100644 --- a/apps/qc-app/src/components/EditData/EditHistory.vue +++ b/apps/qc-app/src/components/EditData/EditHistory.vue @@ -38,7 +38,7 @@ variant="text" density="comfortable" icon="mdi-undo-variant" - :disabled="isUpdating || !canUndo" + :disabled="isUpdating || isReadOnly || !canUndo" @click.stop="onUndo" /> @@ -54,7 +54,7 @@ variant="text" density="comfortable" icon="mdi-redo-variant" - :disabled="isUpdating || !canRedo" + :disabled="isUpdating || isReadOnly || !canRedo" @click.stop="onRedo" /> @@ -76,30 +76,6 @@ - - - - - @@ -342,8 +482,15 @@ import { formatDuration } from '@uwrl/qc-utils' import { useDataVisStore } from '@/store/dataVisualization' import { useUIStore } from '@/store/userInterface' import { iconForMethod, colorForMethod } from '@/components/EditData/operations' +import SessionList from '@/components/EditData/SessionList.vue' +import EditHistoryStepDetails from '@/components/EditData/EditHistoryStepDetails.vue' import { useQcHistory } from '@/composables/useQcHistory' +import { useQcSessionStore } from '@/store/qcSession' +import { useHistorySnapshots } from '@/composables/useHistorySnapshots' +import { SNAPSHOT_BASELINE_INDEX } from '@/utils/snapshotId' import { Snackbar } from '@uwrl/qc-utils' +import type { HistoryItem } from '@uwrl/qc-utils' +import { formatDateRange, formatStamp } from '@/utils/time' const props = withDefaults( defineProps<{ @@ -358,9 +505,15 @@ const props = withDefaults( } ) +defineSlots<{ + /** Session actions pinned below the history, shown collapsed or not. */ + footer?: () => any +}>() + const emit = defineEmits<{ (e: 'update:collapsed', value: boolean): void (e: 'pop-out'): void + (e: 'view-session', sessionId: string): void }>() const isCollapsed = computed(() => props.collapsible && !!props.collapsed) @@ -379,17 +532,38 @@ const onHeaderClick = (e: MouseEvent) => { toggleCollapsed() } -const { editHistory, selectedSeries, isUpdating } = +const { editHistory, selectedSeries, isUpdating, previewIndex } = storeToRefs(usePlotlyStore()) const { selectedOperation } = storeToRefs(useUIStore()) const { redraw } = usePlotlyStore() const { clearSelected, setPlotSelection } = useDataSelection() -const { exportHistory, importHistory } = useQcHistory() -const fileInputRef = ref(null) +const { exportHistory } = useQcHistory() const openIndex = ref(null) -const isDev = import.meta.env.DEV +/** The step the plot reflects: the one previewed, else the last entry. */ +const shownStepIndex = computed(() => { + const last = editHistory.value.length - 1 + if (last < 0) return null + return previewIndex.value ?? last +}) + +// Committed sessions are immutable server-side, so their comments are shown +// but not editable. +const { isReadOnly, isSwitchingSession, viewedSessionId } = + storeToRefs(useQcSessionStore()) + +const { toggleSnapshot, isSnapshotPlotted, isBuilding } = useHistorySnapshots() + +// Not gated on isReadOnly: plotting a comparison line is a read action. +const onToggleSnapshot = async (opIndex: number) => { + const sessionId = viewedSessionId.value + if (!sessionId) return + await toggleSnapshot(sessionId, opIndex) +} + +const snapshotShown = (opIndex: number) => + !!viewedSessionId.value && isSnapshotPlotted(viewedSessionId.value, opIndex) const editCount = computed(() => editHistory.value?.length ?? 0) @@ -400,10 +574,45 @@ const canRedo = computed( () => (selectedSeries.value?.data.redoStack?.length ?? 0) > 0 ) +/** Stepping needs something to replay and a settled dispatch. */ +const canStepTo = computed(() => !isUpdating.value && editCount.value > 0) + +/** Row click / Enter / Space: preview that step. */ +const onRowReload = (index: number) => { + if (!canStepTo.value) return + if (index >= 0 && editHistory.value[index]?.execution?.inFlight) return + onReloadHistory(index) +} + function toggle(index: number) { openIndex.value = openIndex.value === index ? null : index } +/** Steps past the one on screen were not replayed, so their execution + * record describes a run that no longer holds in this view. */ +const isApplied = (index: number) => + shownStepIndex.value === null || index <= shownStepIndex.value + +const stepTitle = (index: number) => + isApplied(index) + ? 'Preview this step' + : 'Not applied in the step currently shown. Click to preview it.' + +/** The period a step touched and how many points, from the datetimes qc-utils + * read when it ran. Indices would drift with later edits. */ +function stepExtent(entry: HistoryItem): string { + const extent = entry.execution?.extent + if (!extent) return '' + const begin = new Date(extent.begin) + const end = new Date(extent.end) + const period = + extent.begin === extent.end + ? formatStamp(begin) + : formatDateRange(begin.toISOString(), end.toISOString()) + const n = entry.execution?.selectionSize + return n ? `${period} · ${n.toLocaleString()} pt${n === 1 ? '' : 's'}` : period +} + function formatMethod(method: string) { if (!method) return '' return method @@ -413,27 +622,8 @@ function formatMethod(method: string) { .join(' ') } -function formatArg(arg: unknown): string { - if (Array.isArray(arg)) { - const len = arg.length - if (!len) return '[]' - const preview = arg - .slice(0, 5) - .map((v) => (typeof v === 'number' ? v : JSON.stringify(v))) - .join(', ') - return len <= 5 ? `[${preview}]` : `[${preview}, … (${len} items)]` - } - if (arg && typeof arg === 'object') { - try { - return JSON.stringify(arg) - } catch { - return String(arg) - } - } - return String(arg) -} - const onReload = async () => { + if (isReadOnly.value || isUpdating.value) return isUpdating.value = true closeStaleStagingPanel() @@ -446,6 +636,8 @@ const onReload = async () => { selectedSeries.value.data.redoStack.length = 0 } await refreshGraphSeriesArray() + // Restores the raw values when the refetch above failed and left the + // edited record in place. await selectedSeries.value?.data.reload() // reload() already wiped history; don't push an empty SELECTION. await clearSelected({ recordHistory: false }) @@ -454,20 +646,26 @@ const onReload = async () => { }) } +// Previewing only shows a step: every step stays in the history, and only +// undo and redo change it. The last step is the whole history again. const onReloadHistory = async (index: number) => { - if (index < editHistory.value.length) { - isUpdating.value = true - closeStaleStagingPanel() - setTimeout(async () => { - const newSelection = await selectedSeries.value?.data.reloadHistory(index) - + if (index >= editHistory.value.length) return + const record = selectedSeries.value?.data + if (!record) return + isUpdating.value = true + closeStaleStagingPanel() + setTimeout(async () => { + try { + await applyReplayedSelection(await record.previewHistory(index)) + } finally { isUpdating.value = false - await redraw() - if (newSelection) { - setPlotSelection(newSelection) - } - }) - } + } + }) +} + +const onBackToLatest = () => { + const last = editHistory.value.length - 1 + if (last >= 0) void onReloadHistory(last) } const onSaveHistory = async () => { @@ -480,39 +678,6 @@ const onSaveHistory = async () => { } } -const onLoadHistoryClick = () => { - fileInputRef.value?.click() -} - -const onLoadHistoryFile = async (e: Event) => { - const input = e.target as HTMLInputElement - const file = input.files?.[0] - // Reset early so re-picking the same file fires change again. - input.value = '' - if (!file) return - - isUpdating.value = true - try { - const report = await importHistory(file) - if (report.failed.length === 0) { - Snackbar.success( - `Loaded ${report.applied} operation${report.applied === 1 ? '' : 's'}.` - ) - } else { - Snackbar.warn( - `Loaded ${report.applied} operation${report.applied === 1 ? '' : 's'}; ` + - `${report.failed.length} failed (see history badges).` - ) - } - await redraw() - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - Snackbar.error(`Couldn't load QC history: ${msg}`) - } finally { - isUpdating.value = false - } -} - // Pass recordHistory: false on the clear path because the replay is // authoritative; dispatching an empty SELECTION could pop a filter the // replay just restored. @@ -535,7 +700,8 @@ const closeStaleStagingPanel = () => { } const onUndo = async () => { - if (!canUndo.value || isUpdating.value) return + // Also guards the Ctrl+Z shortcut, which bypasses the disabled button. + if (isReadOnly.value || !canUndo.value || isUpdating.value) return isUpdating.value = true closeStaleStagingPanel() setTimeout(async () => { @@ -549,7 +715,7 @@ const onUndo = async () => { } const onRedo = async () => { - if (!canRedo.value || isUpdating.value) return + if (isReadOnly.value || !canRedo.value || isUpdating.value) return isUpdating.value = true closeStaleStagingPanel() setTimeout(async () => { @@ -616,6 +782,32 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) background-color: rgba(var(--v-theme-primary), 0.04); } +.edit-history__row--clickable { + cursor: pointer; +} + +/* Size and weight come from the typography utility classes. */ +.edit-history__step { + min-width: 0; + padding: 0; + border: 0; + background: none; + color: inherit; + font-family: inherit; + text-align: start; + cursor: inherit; +} + +.edit-history__step:focus-visible { + outline: 2px solid rgb(var(--v-theme-primary)); + outline-offset: 2px; +} + +.edit-history__row--loaded { + background-color: rgba(var(--v-theme-primary), 0.1); + box-shadow: inset 3px 0 0 0 rgb(var(--v-theme-primary)); +} + .edit-history__row--open { background-color: rgba(var(--v-theme-primary), 0.06); } @@ -628,6 +820,16 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) opacity: 0.75; } +/* Steps past the one on screen were not replayed, so they are dimmed to + separate what the plot reflects from what is merely recorded. */ +.edit-history__preview { + background-color: rgba(var(--v-theme-primary), 0.06); +} + +.edit-history__row--unapplied { + opacity: 0.45; +} + .edit-history__expand { width: 20px; height: 20px; @@ -641,26 +843,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) background-color: rgba(0, 0, 0, 0.05); } -.edit-history__method { - font-size: 0.8125rem; +.edit-history__title { min-width: 0; + line-height: inherit; } -.edit-history__args { - background-color: rgba(var(--v-theme-primary), 0.03); - border-left: 2px solid rgb(var(--v-theme-primary)); -} - -.edit-history__args-list { - list-style: none; - max-height: 12rem; +.edit-history__method { + font-size: 0.8125rem; + min-width: 0; } -.edit-history__mode-chip { - font-size: 0.625rem; - letter-spacing: 0.05em; - text-transform: uppercase; - height: 16px; - padding-inline: 6px; +.edit-history__extent { + font-size: 0.6875rem; + line-height: 1.2; } diff --git a/apps/qc-app/src/components/EditData/EditHistoryStepDetails.vue b/apps/qc-app/src/components/EditData/EditHistoryStepDetails.vue new file mode 100644 index 00000000..a5a9d64f --- /dev/null +++ b/apps/qc-app/src/components/EditData/EditHistoryStepDetails.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/EditTargetPanel.vue b/apps/qc-app/src/components/EditData/EditTargetPanel.vue new file mode 100644 index 00000000..50ce5c32 --- /dev/null +++ b/apps/qc-app/src/components/EditData/EditTargetPanel.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/FillGaps.vue b/apps/qc-app/src/components/EditData/FillGaps.vue index c7672c81..4865c2a6 100644 --- a/apps/qc-app/src/components/EditData/FillGaps.vue +++ b/apps/qc-app/src/components/EditData/FillGaps.vue @@ -358,7 +358,7 @@ const fillSnapChips = computed(() => { return [0.5, 1, 2].map((m) => { const amount = i.amount * m return { - label: `${m}× intended (${amount} ${i.unit.toLowerCase()})`, + label: `${m}× intended (${amount} ${i.unit.toLowerCase()})`, amount, unit: i.unit, active: diff --git a/apps/qc-app/src/components/EditData/LeaveSessionDialog.vue b/apps/qc-app/src/components/EditData/LeaveSessionDialog.vue new file mode 100644 index 00000000..9a413409 --- /dev/null +++ b/apps/qc-app/src/components/EditData/LeaveSessionDialog.vue @@ -0,0 +1,155 @@ + + + diff --git a/apps/qc-app/src/components/EditData/OperationPanel.vue b/apps/qc-app/src/components/EditData/OperationPanel.vue index fafa87f9..e003cfd4 100644 --- a/apps/qc-app/src/components/EditData/OperationPanel.vue +++ b/apps/qc-app/src/components/EditData/OperationPanel.vue @@ -37,7 +37,31 @@
+ +
+ This session is committed and read-only. Return to the current + session, or start a new one, to keep editing. +
+
+ +
+ +
+ An earlier history step is being previewed. Go back to the latest + step in the edit history to continue editing. +
+
+ +
selectedOperation.value ? operationsById[selectedOperation.value] : null diff --git a/apps/qc-app/src/components/EditData/SessionList.vue b/apps/qc-app/src/components/EditData/SessionList.vue new file mode 100644 index 00000000..929d0450 --- /dev/null +++ b/apps/qc-app/src/components/EditData/SessionList.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/SessionTimelineItem.vue b/apps/qc-app/src/components/EditData/SessionTimelineItem.vue new file mode 100644 index 00000000..c4696d90 --- /dev/null +++ b/apps/qc-app/src/components/EditData/SessionTimelineItem.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/SessionWindowDialog.vue b/apps/qc-app/src/components/EditData/SessionWindowDialog.vue new file mode 100644 index 00000000..345d4cb8 --- /dev/null +++ b/apps/qc-app/src/components/EditData/SessionWindowDialog.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/apps/qc-app/src/components/EditData/ShiftDatetimes.vue b/apps/qc-app/src/components/EditData/ShiftDatetimes.vue index cbfafff6..2ae66499 100644 --- a/apps/qc-app/src/components/EditData/ShiftDatetimes.vue +++ b/apps/qc-app/src/components/EditData/ShiftDatetimes.vue @@ -24,10 +24,10 @@ v-model.number="shiftAmount" density="comfortable" variant="outlined" + :error="!amountValid" hide-details - @keyup.enter=" - !isUpdating && selectedData?.length && onShiftDatetimes() - " + data-testid="shift-amount" + @keyup.enter="canShift && onShiftDatetimes()" />
+
+ {{ + amountValid + ? `Months and years follow the calendar in ${zoneName()}, keeping the clock time. A day past the end of a month moves to its last day.` + : 'Months and years shift by whole numbers.' + }} +
+
Shift @@ -78,6 +92,7 @@ import { EnumEditOperations, TimeUnit } from '@uwrl/qc-utils' import { usePlotlyStore } from '@/store/plotly' import { useUIStore, timeSpacingUnitToTimeUnitKey } from '@/store/userInterface' import { useFilterDispatch } from '@/composables/useFilterDispatch' +import { zoneId, zoneName } from '@/utils/timeZone' const { selectedData, qcDatastream } = storeToRefs(useDataVisStore()) const { selectedSeries, isUpdating } = storeToRefs(usePlotlyStore()) @@ -104,7 +119,7 @@ const snapChips = computed(() => { return [0.5, 1, 2].map((m) => { const amount = n * m return { - label: `${m}× intended (${amount} ${unitKey.toLowerCase()})`, + label: `${m}× intended (${amount} ${unitKey.toLowerCase()})`, amount, unit: unitKey, active: @@ -119,12 +134,21 @@ const applySnap = (chip: SnapChip) => { selectedShiftUnit.value = chip.unit } +const isCalendarUnit = computed( + () => selectedShiftUnit.value === 'MONTH' || selectedShiftUnit.value === 'YEAR' +) +const amountValid = computed(() => { + const n = Number(shiftAmount.value) + return Number.isFinite(n) && (!isCalendarUnit.value || Number.isInteger(n)) +}) +const canShift = computed( + () => !isUpdating.value && !!selectedData.value?.length && amountValid.value +) + const emit = defineEmits(['close']) const onShiftDatetimes = async () => { - if (!selectedData.value?.length) { - return - } + if (!canShift.value) return isUpdating.value = true @@ -134,7 +158,8 @@ const onShiftDatetimes = async () => { EnumEditOperations.SHIFT_DATETIMES, +shiftAmount.value, // @ts-ignore - TimeUnit[selectedShiftUnit.value] + TimeUnit[selectedShiftUnit.value], + zoneId() )) as number[] | undefined) ?? [] isUpdating.value = false diff --git a/apps/qc-app/src/components/EditData/StartEditingDialog.vue b/apps/qc-app/src/components/EditData/StartEditingDialog.vue new file mode 100644 index 00000000..461b4628 --- /dev/null +++ b/apps/qc-app/src/components/EditData/StartEditingDialog.vue @@ -0,0 +1,303 @@ + + + diff --git a/apps/qc-app/src/components/EditData/StartEditingFlow.vue b/apps/qc-app/src/components/EditData/StartEditingFlow.vue new file mode 100644 index 00000000..ea45ae14 --- /dev/null +++ b/apps/qc-app/src/components/EditData/StartEditingFlow.vue @@ -0,0 +1,386 @@ + + + diff --git a/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts b/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts new file mode 100644 index 00000000..a471c063 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/CreateDatastreamForm.spec.ts @@ -0,0 +1,204 @@ +import { mount, flushPromises, type VueWrapper } from '@vue/test-utils' +import { describe, it, expect, vi } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import CreateDatastreamForm from '@/components/EditData/CreateDatastreamForm.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const source = { + id: 's-1', + name: 'Raw Temp', + description: 'Raw temperature readings', + status: 'ongoing', + sensorId: 'sn-1', + processingLevelId: 'pl-raw', +} as any + +const processingLevels = [ + { id: 'pl-raw', definition: 'Raw' }, + { id: 'pl-qc', definition: 'Quality Controlled' }, +] + +const sensors = [ + { id: 'sn-1', name: 'Thermistor' }, + { id: 'sn-2', name: 'Weather station' }, +] + +const statuses = ['ongoing', 'complete'] + +const mountForm = (props: Record = {}) => + mount(CreateDatastreamForm, { + props: { source, processingLevels, sensors, statuses, ...props }, + global: { plugins: [createTestVuetify()] }, + }) + +// Every field below is a Vuetify component carrying the test id. +const field = (w: ReturnType, testId: string) => + w.findComponent(`[data-testid="${testId}"]`) as VueWrapper + +const levelSelect = (w: ReturnType) => + field(w, 'create-processing-level') + +describe('CreateDatastreamForm', () => { + it('disables create until a different processing level is chosen', async () => { + const w = mountForm() + const confirm = () => w.find('[data-testid="create-confirm"]') + expect(confirm().attributes('disabled')).toBeDefined() + + await levelSelect(w).vm.$emit('update:modelValue', 'pl-raw') // same as source + expect(confirm().attributes('disabled')).toBeDefined() + + await levelSelect(w).vm.$emit('update:modelValue', 'pl-qc') + expect(confirm().attributes('disabled')).toBeUndefined() + }) + + it('emits the create spec on confirm, defaulting the fields from the source', async () => { + const w = mountForm() + await levelSelect(w).vm.$emit('update:modelValue', 'pl-qc') + await w.find('[data-testid="create-confirm"]').trigger('click') + + const spec = w.emitted('confirm')![0][0] as { + source: { id: string } + processingLevelId: string + name?: string + description: string + status?: string + sensorId: string + } + expect(spec.source.id).toBe('s-1') + expect(spec.processingLevelId).toBe('pl-qc') + expect(spec.name).toBe('Raw Temp (QC)') + expect(spec.description).toBe('Raw temperature readings') + expect(spec.status).toBe('ongoing') + expect(spec.sensorId).toBe('sn-1') + }) + + it('emits the edited description, status and method', async () => { + const w = mountForm({ defaultProcessingLevelId: 'pl-qc' }) + await field(w, 'create-description').vm.$emit('update:modelValue', 'QC pass') + await field(w, 'create-status').vm.$emit('update:modelValue', 'complete') + await field(w, 'create-sensor').vm.$emit('update:modelValue', 'sn-2') + await w.find('[data-testid="create-confirm"]').trigger('click') + + const spec = w.emitted('confirm')![0][0] as { + description: string + status?: string + sensorId: string + } + expect(spec.description).toBe('QC pass') + expect(spec.status).toBe('complete') + expect(spec.sensorId).toBe('sn-2') + }) + + it('requires a description, and allows an empty status', async () => { + const w = mountForm({ defaultProcessingLevelId: 'pl-qc' }) + const confirm = () => w.find('[data-testid="create-confirm"]') + + await field(w, 'create-status').vm.$emit('update:modelValue', null) + expect(confirm().attributes('disabled')).toBeUndefined() + + await field(w, 'create-description').vm.$emit('update:modelValue', ' ') + expect(confirm().attributes('disabled')).toBeDefined() + + await field(w, 'create-description').vm.$emit('update:modelValue', 'QC pass') + expect(confirm().attributes('disabled')).toBeUndefined() + await confirm().trigger('click') + const spec = w.emitted('confirm')![0][0] as { status?: string } + expect(spec.status).toBeUndefined() + }) + + it('keeps the source values when the lists could not be loaded', async () => { + const w = mountForm({ + defaultProcessingLevelId: 'pl-qc', + sensors: [], + statuses: [], + }) + await w.find('[data-testid="create-confirm"]').trigger('click') + const spec = w.emitted('confirm')![0][0] as { + status?: string + sensorId: string + } + expect(spec.status).toBe('ongoing') + expect(spec.sensorId).toBe('sn-1') + }) + + it('uses the provided default processing level', async () => { + const w = mountForm({ defaultProcessingLevelId: 'pl-qc' }) + // Valid immediately since the default differs from the source's level. + expect(w.find('[data-testid="create-confirm"]').attributes('disabled')).toBeUndefined() + await w.find('[data-testid="create-confirm"]').trigger('click') + const spec = w.emitted('confirm')![0][0] as { processingLevelId: string } + expect(spec.processingLevelId).toBe('pl-qc') + }) + + it('emits cancel', async () => { + const w = mountForm() + await w.find('[data-testid="create-cancel"]').trigger('click') + expect(w.emitted('cancel')).toHaveLength(1) + }) + + it('ignores a remembered default not in this workspace, and stays invalid until a real level is picked', async () => { + // A level id persisted from another workspace/backend. + const w = mountForm({ defaultProcessingLevelId: 'pl-from-elsewhere' }) + // Stale default dropped -> confirm disabled (not submitted as-is). + expect( + w.find('[data-testid="create-confirm"]').attributes('disabled') + ).toBeDefined() + await levelSelect(w).vm.$emit('update:modelValue', 'pl-qc') + expect( + w.find('[data-testid="create-confirm"]').attributes('disabled') + ).toBeUndefined() + }) + + it('adds a processing level inline and selects the new one', async () => { + const created = { id: 'pl-new', code: 'Quality Controlled' } + const onCreateProcessingLevel = vi.fn().mockResolvedValue(created) + const w = mountForm({ onCreateProcessingLevel }) + + // Open the inline add panel and submit a new level. + await w.find('[data-testid="add-level-toggle"]').trigger('click') + await w.find('[data-testid="new-level-code"] input').setValue('Quality Controlled') + await w.find('[data-testid="new-level-save"]').trigger('click') + await flushPromises() + + expect(onCreateProcessingLevel).toHaveBeenCalledWith( + expect.objectContaining({ code: 'Quality Controlled' }) + ) + // Parent appends the created level to the catalog. + await w.setProps({ processingLevels: [...processingLevels, created] }) + + // Panel collapsed, and the new level is selected -> confirm carries it. + expect(w.find('[data-testid="new-level-code"]').exists()).toBe(false) + await w.find('[data-testid="create-confirm"]').trigger('click') + const spec = w.emitted('confirm')![0][0] as { processingLevelId: string } + expect(spec.processingLevelId).toBe('pl-new') + }) + + it('shows progress and blocks both buttons while creating', async () => { + const w = mountForm({ defaultProcessingLevelId: 'pl-qc', loading: true }) + const confirm = w.find('[data-testid="create-confirm"]') + expect(confirm.classes()).toContain('v-btn--loading') + expect( + w.find('[data-testid="create-cancel"]').attributes('disabled') + ).toBeDefined() + + await field(w, 'create-confirm').vm.$emit('click') + expect(w.emitted('confirm')).toBeUndefined() + }) + + it('blocks create and shows a warning when permissionError is set', async () => { + const w = mountForm({ + defaultProcessingLevelId: 'pl-qc', // otherwise valid + permissionError: 'You cannot create datastreams here.', + }) + expect(w.find('[data-testid="create-permission-error"]').exists()).toBe(true) + expect(w.text()).toContain('You cannot create datastreams here.') + // Confirm stays disabled despite a valid processing level. + expect( + w.find('[data-testid="create-confirm"]').attributes('disabled') + ).toBeDefined() + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts b/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts index 375be285..cff9f763 100644 --- a/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts +++ b/apps/qc-app/src/components/EditData/__tests__/EditHistory.spec.ts @@ -1,19 +1,50 @@ -import { mount, flushPromises } from '@vue/test-utils' -import { ref } from 'vue' -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils' +import { computed, reactive, ref } from 'vue' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { createTestPinia } from '@/utils/test/pinia' import { createTestVuetify } from '@/utils/test/vuetify' +// EditHistory listens for Ctrl+Z/Y on window. A wrapper left mounted keeps +// handling shortcuts dispatched by later tests. +enableAutoUnmount(afterEach) +// The per-operation comment textarea (Vuetify auto-grow) observes resizes. +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + const editHistory = ref([]) const selectedSeries = ref(null) const isUpdating = ref(false) const redraw = vi.fn().mockResolvedValue(undefined) const refreshGraphSeriesArray = vi.fn().mockResolvedValue(undefined) +const previewIndex = computed(() => selectedSeries.value?.data?.previewIndex ?? null) + +// Reactive like a real store, so `storeToRefs` unwraps the computed. vi.mock('@/store/plotly', () => ({ - usePlotlyStore: () => ({ editHistory, selectedSeries, isUpdating, redraw }), + usePlotlyStore: () => + reactive({ + editHistory, + selectedSeries, + isUpdating, + redraw, + previewIndex, + }), })) +/** + * Stands in for `ObservationRecord.previewHistory`: shows step `index` + * without dropping the later ones, and previewing the last step returns to + * the whole history. Called on the record, so `this` is the series data. + */ +const previewing = (selection: number[] = []) => + vi.fn(async function (this: any, index: number) { + this.previewIndex = index >= this.history.length - 1 ? null : index + return selection + }) + const qcDatastream = ref(null) vi.mock('@/store/dataVisualization', () => ({ useDataVisStore: () => ({ refreshGraphSeriesArray, qcDatastream }), @@ -25,57 +56,78 @@ vi.mock('@/composables/useDataSelection', () => ({ useDataSelection: () => ({ clearSelected, setPlotSelection }), })) -vi.mock('@uwrl/qc-utils', () => ({ - formatDuration: (ms: number) => String(ms) + 'ms', - // operations.ts (transitively imported via EditHistory.vue's - // `iconForMethod` lookup) reads enum values to build its method → - // operation-id map. Stub the keys it actually consults; the test - // never inspects the icon output, only that the entry renders. - EnumEditOperations: { - ADD_POINTS: 'ADD_POINTS', - CHANGE_VALUES: 'CHANGE_VALUES', - ASSIGN_VALUES_BULK: 'ASSIGN_VALUES_BULK', - ASSIGN_DATETIMES_BULK: 'ASSIGN_DATETIMES_BULK', - DELETE_POINTS: 'DELETE_POINTS', - DRIFT_CORRECTION: 'DRIFT_CORRECTION', - INTERPOLATE: 'INTERPOLATE', - SHIFT_DATETIMES: 'SHIFT_DATETIMES', - FILL_GAPS: 'FILL_GAPS', - }, - EnumFilterOperations: { - FIND_GAPS: 'FIND_GAPS', - PERSISTENCE: 'PERSISTENCE', - CHANGE: 'CHANGE', - RATE_OF_CHANGE: 'RATE_OF_CHANGE', - VALUE_THRESHOLD: 'VALUE_THRESHOLD', - DATETIME_RANGE: 'DATETIME_RANGE', - SELECTION: 'SELECTION', - }, - Operator: { - ADD: 'ADD', - SUB: 'SUB', - MULT: 'MULT', - DIV: 'DIV', - ASSIGN: 'ASSIGN', - }, - TimeUnit: { - SECOND: 's', - MINUTE: 'm', - HOUR: 'h', - DAY: 'D', - WEEK: 'W', - MONTH: 'M', - YEAR: 'Y', - }, - LogicalOperation: { - LT: 'Less than', - LTE: 'Less than or equal to', - GT: 'Greater than', - GTE: 'Greater than or equal to', - E: 'Equal', - }, +const toggleSnapshot = vi.fn().mockResolvedValue(undefined) +const plottedSnapshots = ref([]) +const isBuilding = ref(false) +vi.mock('@/composables/useHistorySnapshots', () => ({ + useHistorySnapshots: () => ({ + toggleSnapshot, + isSnapshotPlotted: (sessionId: string, opIndex: number) => + plottedSnapshots.value.includes(`${sessionId}:${opIndex}`), + isBuilding, + }), })) +vi.mock('@uwrl/qc-utils', async (importOriginal) => { + // The real time zone math, which the app's date helpers use. + const { offsetMs, toWall, fromWall, toWallArray } = + await importOriginal() + return { + offsetMs, + toWall, + fromWall, + toWallArray, + formatDuration: (ms: number) => String(ms) + 'ms', + // operations.ts (transitively imported via EditHistory.vue's + // `iconForMethod` lookup) reads enum values to build its method → + // operation-id map. Stub the keys it actually consults; the test + // never inspects the icon output, only that the entry renders. + EnumEditOperations: { + ADD_POINTS: 'ADD_POINTS', + CHANGE_VALUES: 'CHANGE_VALUES', + ASSIGN_VALUES_BULK: 'ASSIGN_VALUES_BULK', + ASSIGN_DATETIMES_BULK: 'ASSIGN_DATETIMES_BULK', + DELETE_POINTS: 'DELETE_POINTS', + DRIFT_CORRECTION: 'DRIFT_CORRECTION', + INTERPOLATE: 'INTERPOLATE', + SHIFT_DATETIMES: 'SHIFT_DATETIMES', + FILL_GAPS: 'FILL_GAPS', + }, + EnumFilterOperations: { + FIND_GAPS: 'FIND_GAPS', + PERSISTENCE: 'PERSISTENCE', + CHANGE: 'CHANGE', + RATE_OF_CHANGE: 'RATE_OF_CHANGE', + VALUE_THRESHOLD: 'VALUE_THRESHOLD', + DATETIME_RANGE: 'DATETIME_RANGE', + SELECTION: 'SELECTION', + }, + Operator: { + ADD: 'ADD', + SUB: 'SUB', + MULT: 'MULT', + DIV: 'DIV', + ASSIGN: 'ASSIGN', + }, + TimeUnit: { + SECOND: 's', + MINUTE: 'm', + HOUR: 'h', + DAY: 'D', + WEEK: 'W', + MONTH: 'M', + YEAR: 'Y', + }, + LogicalOperation: { + LT: 'Less than', + LTE: 'Less than or equal to', + GT: 'Greater than', + GTE: 'Greater than or equal to', + E: 'Equal', + }, + } +}) + import EditHistory from '@/components/EditData/EditHistory.vue' function makeSeries(overrides: Partial = {}) { @@ -88,7 +140,7 @@ function makeSeries(overrides: Partial = {}) { undo: vi.fn().mockResolvedValue([1, 2]), redo: vi.fn().mockResolvedValue([]), reload: vi.fn().mockResolvedValue(undefined), - reloadHistory: vi.fn().mockResolvedValue([3]), + previewHistory: previewing([3]), ...overrides, }, } @@ -113,6 +165,7 @@ function makeEntry( mode: 'worker' | 'inline' datasetSize: number selectionSize: number + extent: { begin: number; end: number } }> = {}, ) { return { @@ -126,9 +179,13 @@ function makeEntry( } } -function createWrapper(props: Record = {}) { +function createWrapper( + props: Record = {}, + slots: Record = {} +) { return mount(EditHistory, { props, + slots, global: { plugins: [createTestPinia(), createTestVuetify()] }, }) } @@ -141,6 +198,30 @@ describe('EditHistory.vue', () => { vi.clearAllMocks() }) + it('shows the period and point count a step touched', async () => { + const begin = new Date(2026, 2, 3, 12, 5) + const end = new Date(2026, 3, 1, 8, 0) + editHistory.value = [ + makeEntry('INTERPOLATE', [], { + extent: { begin: begin.getTime(), end: end.getTime() }, + selectionSize: 1200, + }), + makeEntry('ADD_POINTS', [], { + extent: { begin: end.getTime(), end: end.getTime() }, + }), + makeEntry('FIND_GAPS'), + ] + const wrapper = createWrapper() + await flushPromises() + expect(wrapper.find('[data-testid="history-extent-0"]').text()).toBe( + 'Mar 3, 12:05 PM – Apr 1, 2026, 8:00 AM · 1,200 pts' + ) + expect(wrapper.find('[data-testid="history-extent-1"]').text()).toBe( + 'Apr 1, 2026, 8:00 AM' + ) + expect(wrapper.find('[data-testid="history-extent-2"]').exists()).toBe(false) + }) + it('disables undo/redo when history is empty', () => { const wrapper = createWrapper() expect(wrapper.find('[data-testid="history-undo-btn"]').attributes('disabled')).toBeDefined() @@ -218,6 +299,26 @@ describe('EditHistory.vue', () => { expect(wrapper.emitted('update:collapsed')).toBeFalsy() }) + it('renders no footer when the slot is not provided', () => { + const wrapper = createWrapper() + expect(wrapper.find('.edit-history__footer').exists()).toBe(false) + }) + + it('keeps the footer slot visible while collapsed', () => { + const wrapper = createWrapper( + { collapsible: true, collapsed: true }, + { footer: '' } + ) + const footer = wrapper.find('.edit-history__footer') + expect(footer.exists()).toBe(true) + expect(footer.isVisible()).toBe(true) + expect(wrapper.find('[data-testid="session-actions"]').isVisible()).toBe( + true + ) + // The body is what collapses (v-show), not the footer. + expect(wrapper.find('.edit-history__row--baseline').isVisible()).toBe(false) + }) + it('emits pop-out when pop-out button is clicked', async () => { const wrapper = createWrapper({ popOutEnabled: true }) const popBtn = wrapper.find('[aria-label="Open history in a modal window"]') @@ -275,27 +376,27 @@ describe('EditHistory.vue actions', () => { vi.useFakeTimers() const wrapper = createWrapper() await flushPromises() - const reloadBtn = wrapper.findAll('button').find((b) => b.html().includes('mdi-reload')) - expect(reloadBtn).toBeTruthy() - await reloadBtn!.trigger('click') + const reloadBtn = wrapper.find('[data-testid="history-reload-btn"]') + expect(reloadBtn.exists()).toBe(true) + await reloadBtn.trigger('click') await vi.runAllTimersAsync() expect(selectedSeries.value.data.reload).toHaveBeenCalled() expect(refreshGraphSeriesArray).toHaveBeenCalled() vi.useRealTimers() }) - it('per-step reload button calls reloadHistory with entry index', async () => { + it('clicking a step row previews it', async () => { vi.useFakeTimers() editHistory.value = [makeEntry('ADD_POINTS')] - selectedSeries.value.data.reloadHistory = vi.fn().mockResolvedValue([9]) + selectedSeries.value.data.previewHistory = vi.fn().mockResolvedValue([9]) const wrapper = createWrapper() await flushPromises() const entry = wrapper.find('[data-testid="history-item-0"]') - const reloadBtn = entry.findAll('button').find((b) => b.html().includes('mdi-reload')) + const reloadBtn = entry.find('.edit-history__row') expect(reloadBtn).toBeTruthy() await reloadBtn!.trigger('click') await vi.runAllTimersAsync() - expect(selectedSeries.value.data.reloadHistory).toHaveBeenCalledWith(0) + expect(selectedSeries.value.data.previewHistory).toHaveBeenCalledWith(0) expect(setPlotSelection).toHaveBeenCalledWith([9]) vi.useRealTimers() }) @@ -370,4 +471,730 @@ describe('EditHistory.vue actions', () => { await wrapper.find('.edit-history__header').trigger('keydown.enter') expect(wrapper.emitted('update:collapsed')).toBeTruthy() }) + + // A committed session's operations are a record of what happened, not a + // draft: nothing in the panel may rewrite them. + describe('read-only session', () => { + const readOnly = async () => { + const { useQcSessionStore } = await import('@/store/qcSession') + const store = useQcSessionStore() + // `createdAt` is required: SessionList orders on it. + store.sessions = [ + { + id: 'a', + status: 'committed', + createdAt: '2025-01-01T00:00:00Z', + phenomenonTimeStart: '2025-01-01T00:00:00Z', + phenomenonTimeEnd: '2025-02-01T00:00:00Z', + }, + { + id: 'b', + status: 'in_progress', + createdAt: '2025-02-01T00:00:00Z', + phenomenonTimeStart: '2025-02-01T00:00:00Z', + phenomenonTimeEnd: '2025-03-01T00:00:00Z', + }, + ] as any + store.currentSessionId = 'b' + store.viewedSessionId = 'a' + return store + } + + /** + * Stands in for the real `previewHistory`: replace the steps up to + * `index` with freshly dispatched entries carrying the timings the replay + * just measured, and keep the later ones. Splices through the ref's + * proxy; mutating the raw array wouldn't trigger reactivity. + */ + const replayingPreview = () => + vi.fn(async function (this: any, index: number) { + const fresh = editHistory.value.slice(0, index + 1).map((h, i) => ({ + method: h.method, + args: h.args, + execution: { inFlight: false, status: 'success', durationMs: 900 + i }, + })) + editHistory.value.splice(0, index + 1, ...fresh) + this.previewIndex = index + return [] + }) + + // A preview keeps the later steps listed; they must not show the timings + // of a run that no longer holds in this view. + it('shows the timings the replay produced, not the originals', async () => { + const history = [ + makeEntry('SELECTION', [], { durationMs: 10 }), + makeEntry('DELETE_POINTS', [], { durationMs: 20 }), + makeEntry('INTERPOLATE', [], { durationMs: 30 }), + ] + editHistory.value = history + selectedSeries.value = { + data: { + history: editHistory.value, + redoStack: [], + previewHistory: replayingPreview(), + }, + } + const w = createWrapper() + await readOnly() + await flushPromises() + + await w + .find('[data-testid="history-item-1"] .edit-history__row') + .trigger('click') + // Wait for the preview itself to settle. + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await flushPromises() + + // Replayed entries report the new measurement. + expect(w.find('[data-testid="history-duration-0"]').text()).toContain('900') + expect(w.find('[data-testid="history-duration-1"]').text()).toContain('901') + // The un-replayed tail survives on screen but did not run. + expect(w.findAll('[data-testid^="history-item-"]').length).toBe(3) + expect(w.find('[data-testid="history-duration-2"]').exists()).toBe(false) + }) + + it('hides the per-entry undo and disables the toolbar undo/redo', async () => { + editHistory.value = [{ method: 'ADD_POINTS', args: [], execution: {} }] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [{}] } } + const w = createWrapper() + await readOnly() + await flushPromises() + + expect(w.find('[data-testid="history-undo-0"]').exists()).toBe(false) + expect( + w.find('[data-testid="history-undo-btn"]').attributes('disabled') + ).toBeDefined() + expect( + w.find('[data-testid="history-redo-btn"]').attributes('disabled') + ).toBeDefined() + }) + + it('hides reload-from-server, which would wipe the record', async () => { + editHistory.value = [{ method: 'ADD_POINTS', args: [], execution: {} }] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [] } } + const w = createWrapper() + await readOnly() + await flushPromises() + + expect(w.find('[data-testid="history-reload-btn"]').exists()).toBe(false) + }) + + it('shows reload-from-server on an editable session', async () => { + editHistory.value = [{ method: 'ADD_POINTS', args: [], execution: {} }] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [] } } + const w = createWrapper() + await flushPromises() + + const btn = w.find('[data-testid="history-reload-btn"]') + expect(btn.exists()).toBe(true) + // Never gated on whether history exists, unlike the step reload. + expect(btn.attributes('disabled')).toBeUndefined() + }) + + }) + + describe('previewing a step', () => { + const threeSteps = () => [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + { method: 'INTERPOLATE', args: [], execution: {} }, + ] + + it('keeps every step listed and offers the way back', async () => { + const history = threeSteps() + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid="history-preview-banner"]').exists()).toBe(false) + + await w.find('[data-testid="history-item-0"] .edit-history__row').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-preview-banner"]').exists()).toBe(true) + ) + expect(previewHistory).toHaveBeenCalledWith(0) + expect(w.findAll('[data-testid^="history-item-"]').length).toBe(3) + expect(w.find('[data-testid="history-preview-banner"]').text()).toContain( + 'step 1 of 3' + ) + + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await w.find('[data-testid="history-back-to-latest-btn"]').trigger('click') + await vi.waitFor(() => expect(previewHistory).toHaveBeenLastCalledWith(2)) + await vi.waitFor(() => + expect(w.find('[data-testid="history-preview-banner"]').exists()).toBe(false) + ) + }) + + it('names the starting state when previewing it', async () => { + const history = threeSteps() + editHistory.value = history + selectedSeries.value = { + data: { history, redoStack: [], previewHistory: previewing() }, + } + const w = createWrapper() + await flushPromises() + + await w.find('[data-testid="history-reload-step-baseline"]').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-preview-banner"]').text()).toContain( + 'the starting state' + ) + ) + }) + }) + + describe('loaded step', () => { + it('marks the step the plot reflects, and clears it on undo', async () => { + const history = [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + ] + editHistory.value = history + const previewHistory = previewing() + const undo = vi.fn(async function (this: any) { + this.previewIndex = null + return [] + }) + selectedSeries.value = { data: { history, redoStack: [], previewHistory, undo } } + + const w = createWrapper() + await flushPromises() + // With no step singled out the plot reflects the whole history, so the + // last entry carries the marker. + expect(w.find('[data-testid="history-loaded-1"]').exists()).toBe(true) + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(false) + + await w.find('[data-testid="history-item-0"] .edit-history__row').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(true) + ) + + // Undoing changes the history, so "showing step 0" no longer holds and + // the marker returns to the end of the list. + // Wait for the step reload to settle: the button is disabled while + // `isUpdating`, and a click on a disabled button never reaches the handler. + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await w.find('[data-testid="history-undo-btn"]').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(false) + ) + expect(w.find('[data-testid="history-loaded-1"]').exists()).toBe(true) + // Drain undo's deferred replay here or it fires in the next test + // against a series with no `undo`. + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + expect(undo).toHaveBeenCalled() + }) + + // The baseline row is step -1: the state the session started from, + // before any of its operations. + it('reloads to the session baseline from the Data loaded row', async () => { + const history = [ + makeEntry('SELECTION', [], { durationMs: 10 }), + makeEntry('DELETE_POINTS', [], { durationMs: 20 }), + ] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + + const w = createWrapper() + await flushPromises() + await w + .find('[data-testid="history-reload-step-baseline"]') + .trigger('click') + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await flushPromises() + + expect(previewHistory).toHaveBeenCalledWith(-1) + // Nothing is applied at the baseline, so no row reports a run. + expect(w.find('[data-testid="history-duration-0"]').exists()).toBe(false) + expect(w.find('[data-testid="history-duration-1"]').exists()).toBe(false) + }) + + it('marks the Data loaded row as the one being shown', async () => { + const history = [makeEntry('SELECTION', [], { durationMs: 10 })] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid="history-loaded-baseline"]').exists()).toBe(false) + + await w + .find('[data-testid="history-reload-step-baseline"]') + .trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-baseline"]').exists()).toBe(true) + ) + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(false) + }) + + it('makes no row clickable when there is nothing to step back from', async () => { + editHistory.value = [] + const previewHistory = previewing() + selectedSeries.value = makeSeries({ previewHistory }) + + const w = createWrapper() + await flushPromises() + + const baseline = w.find('[data-testid="history-reload-step-baseline"]') + expect(baseline.classes()).not.toContain('edit-history__row--clickable') + expect( + w.find('[data-testid="history-step-btn-baseline"]').attributes('disabled') + ).toBeDefined() + await baseline.trigger('click') + expect(previewHistory).not.toHaveBeenCalled() + }) + + // Reloading from a step un-applies everything below it. A committed + // session keeps those rows on screen, so their telemetry would otherwise + // still advertise a run that no longer holds in this view. + it('drops execution info for steps after the one being shown', async () => { + const history = [ + makeEntry('SELECTION', [], { durationMs: 10 }), + makeEntry('DELETE_POINTS', [], { durationMs: 20, status: 'failed' }), + makeEntry('INTERPOLATE', [], { durationMs: 30 }), + ] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid="history-duration-2"]').exists()).toBe(true) + expect(w.find('[data-testid="history-failed-1"]').exists()).toBe(true) + + await w + .find('[data-testid="history-item-0"] .edit-history__row') + .trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(true) + ) + + // The step you reloaded from did run, so it keeps its label. + expect(w.find('[data-testid="history-duration-0"]').exists()).toBe(true) + expect(w.find('[data-testid="history-duration-1"]').exists()).toBe(false) + expect(w.find('[data-testid="history-duration-2"]').exists()).toBe(false) + // The failure badge is execution state too, so it goes as well. + expect(w.find('[data-testid="history-failed-1"]').exists()).toBe(false) + }) + }) + + describe('execution telemetry placement', () => { + // Duration stays on the row: it is the answer to "did that step run?", + // which you want without expanding. Only the dev-only mode chip moved. + it('keeps the duration on the row and the mode chip off it', async () => { + editHistory.value = [ + makeEntry('SELECTION', [], { durationMs: 1234, mode: 'inline' }), + ] + const w = createWrapper() + await flushPromises() + + const row = w.find('[data-testid="history-item-0"] .edit-history__row') + expect(row.find('[data-testid="history-duration-0"]').exists()).toBe(true) + expect(row.text()).not.toContain('inline') + }) + + it('shows the mode chip in the expanded drawer', async () => { + editHistory.value = [ + makeEntry('SELECTION', [], { durationMs: 1234, mode: 'inline' }), + ] + const w = createWrapper() + await flushPromises() + + await w + .find('[data-testid="history-item-0"] .edit-history__expand') + .trigger('click') + await flushPromises() + + const detail = w.find('[data-testid="history-execution-0"]') + expect(detail.exists()).toBe(true) + expect(detail.text()).toContain('inline') + }) + + // A replayed step can measure well under a millisecond, which rounds to + // "0ms". That is still a real run and must not read as "never ran". + it('renders sub-millisecond and zero durations rather than hiding them', async () => { + editHistory.value = [ + makeEntry('SELECTION', [], { durationMs: 0.0054 }), + makeEntry('DELETE_POINTS', [], { durationMs: 0 }), + ] + const w = createWrapper() + await flushPromises() + + expect(w.find('[data-testid="history-duration-0"]').exists()).toBe(true) + expect(w.find('[data-testid="history-duration-1"]').exists()).toBe(true) + }) + }) + + describe('session switch', () => { + it('shows a loading state instead of the outgoing session operations', async () => { + editHistory.value = [{ method: 'ADD_POINTS', args: [], execution: {} }] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [] } } + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid="history-item-0"]').exists()).toBe(true) + + const { useQcSessionStore } = await import('@/store/qcSession') + useQcSessionStore().isSwitchingSession = true + await flushPromises() + + expect(w.find('[data-testid="history-loading"]').exists()).toBe(true) + expect(w.find('[data-testid="history-item-0"]').exists()).toBe(false) + }) + }) + + describe('shown step fallback', () => { + it('marks nothing when the history is empty', async () => { + editHistory.value = [] + selectedSeries.value = { data: { history: [], redoStack: [] } } + const w = createWrapper() + await flushPromises() + expect(w.find('[data-testid^="history-loaded-"]').exists()).toBe(false) + }) + + it('falls back to the last entry when the chosen step is out of range', async () => { + const history = [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + { method: 'INTERPOLATE', args: [], execution: {} }, + ] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + + await w.find('[data-testid="history-item-2"] .edit-history__row').trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-2"]').exists()).toBe(true) + ) + + // The history shrinks under the chosen step. Splice through the ref's + // proxy: mutating the raw array wouldn't trigger reactivity. + editHistory.value.splice(1) + await flushPromises() + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(true) + }) + }) + + // A button can't contain other controls, so the row stays a plain click + // target and the step name is the control keyboards and screen readers use. + describe('step button', () => { + it('keeps the row out of the button role', async () => { + editHistory.value = [makeEntry('SELECTION')] + const w = createWrapper() + await flushPromises() + + const row = w.find('[data-testid="history-item-0"] .edit-history__row') + expect(row.attributes('role')).toBeUndefined() + expect(row.attributes('tabindex')).toBeUndefined() + const baseline = w.find('[data-testid="history-reload-step-baseline"]') + expect(baseline.attributes('role')).toBeUndefined() + + const step = w.find('[data-testid="history-step-btn-0"]') + expect(step.element.tagName).toBe('BUTTON') + expect(step.find('button').exists()).toBe(false) + expect(step.text()).toBe('Selection') + }) + + it('replays to its step once', async () => { + const history = [makeEntry('SELECTION'), makeEntry('DELETE_POINTS')] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + + await w.find('[data-testid="history-step-btn-0"]').trigger('click') + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await flushPromises() + expect(previewHistory).toHaveBeenCalledTimes(1) + expect(previewHistory).toHaveBeenCalledWith(0) + }) + + it('is disabled while a dispatch is running', async () => { + editHistory.value = [makeEntry('SELECTION')] + isUpdating.value = true + const w = createWrapper() + await flushPromises() + + expect( + w.find('[data-testid="history-step-btn-0"]').attributes('disabled') + ).toBeDefined() + expect( + w.find('[data-testid="history-step-btn-baseline"]').attributes('disabled') + ).toBeDefined() + }) + }) + + describe('row click does not swallow its buttons', () => { + it('expanding the args drawer does not step back', async () => { + const history = [makeEntry('SELECTION'), makeEntry('DELETE_POINTS')] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + + await w + .find('[data-testid="history-item-0"] .edit-history__expand') + .trigger('click') + expect(w.text()).toContain('Arguments') + expect(previewHistory).not.toHaveBeenCalled() + }) + + it('plotting a comparison line does not step back', async () => { + const history = [makeEntry('SELECTION'), makeEntry('DELETE_POINTS')] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + + await w.find('[data-testid="history-snapshot-0"]').trigger('click') + expect(previewHistory).not.toHaveBeenCalled() + }) + + it('reload-from-server does not step back', async () => { + const history = [makeEntry('SELECTION')] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { + data: { + history, + redoStack: [], + previewHistory, + reload: vi.fn().mockResolvedValue(undefined), + }, + } + const w = createWrapper() + await flushPromises() + + // onReload defers its work; drain it here or the history wipe lands + // in the next test. + vi.useFakeTimers() + await w.find('[data-testid="history-reload-btn"]').trigger('click') + await vi.runAllTimersAsync() + vi.useRealTimers() + expect(previewHistory).not.toHaveBeenCalled() + }) + }) + + describe('unapplied steps', () => { + const threeSteps = () => { + const history = [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + { method: 'INTERPOLATE', args: [], execution: {} }, + ] + editHistory.value = history + const previewHistory = previewing() + selectedSeries.value = { + data: { history, redoStack: [], previewHistory }, + } + return history + } + + const rowOf = (w: any, index: number) => + w.find(`[data-testid="history-item-${index}"] .edit-history__row`) + + it('dims nothing while the plot reflects the whole history', async () => { + threeSteps() + const w = createWrapper() + await flushPromises() + for (const i of [0, 1, 2]) { + expect(rowOf(w, i).classes()).not.toContain( + 'edit-history__row--unapplied' + ) + } + }) + + it('dims the steps after the one being shown', async () => { + threeSteps() + const w = createWrapper() + await flushPromises() + + // Reload from step 0: steps 1 and 2 were not replayed. + await w + .find('[data-testid="history-item-0"] .edit-history__row') + .trigger('click') + await vi.waitFor(() => + expect(w.find('[data-testid="history-loaded-0"]').exists()).toBe(true) + ) + + expect(rowOf(w, 0).classes()).not.toContain( + 'edit-history__row--unapplied' + ) + expect(rowOf(w, 1).classes()).toContain('edit-history__row--unapplied') + expect(rowOf(w, 2).classes()).toContain('edit-history__row--unapplied') + expect(rowOf(w, 2).attributes('title')).toContain( + 'Not applied in the step currently shown' + ) + }) + }) + + describe('attribution', () => { + it('shows who applied an operation in the expanded detail only', async () => { + editHistory.value = [ + { method: 'ADD_POINTS', args: [], execution: {}, performedBy: 'Ada Lovelace' }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + ] + selectedSeries.value = { data: { history: editHistory.value, redoStack: [] } } + const w = createWrapper() + await flushPromises() + + // The collapsed row stays terse; attribution lives in the detail. + expect(w.find('[data-testid="history-item-0"]').text()).not.toContain( + 'Ada Lovelace' + ) + + await w.find('[data-testid="history-item-0"]').find('button').trigger('click') + await flushPromises() + expect(w.find('[data-testid="history-author-detail-0"]').text()).toContain( + 'Applied by Ada Lovelace' + ) + + // Unsaved operations have no server attribution yet. + await w.find('[data-testid="history-item-1"]').find('button').trigger('click') + await flushPromises() + expect(w.find('[data-testid="history-author-detail-1"]').exists()).toBe(false) + }) + }) + + describe('selection after loading a step', () => { + const stepBackTo = async (returned: number[] | undefined) => { + const history = [ + { method: 'SELECTION', args: [], execution: {} }, + { method: 'DELETE_POINTS', args: [], execution: {} }, + ] + editHistory.value = history + const previewHistory = vi.fn(async () => returned as any) + selectedSeries.value = { data: { history, redoStack: [], previewHistory } } + const w = createWrapper() + await flushPromises() + // Item 0 isn't the trailing entry, so its buttons are expand + reload. + await w.find('[data-testid="history-item-0"] .edit-history__row').trigger('click') + await vi.waitFor(() => expect(previewHistory).toHaveBeenCalledWith(0)) + await vi.waitFor(() => expect(isUpdating.value).toBe(false)) + await flushPromises() + } + + it('applies the selection the replay produced', async () => { + await stepBackTo([3, 4, 5]) + expect(setPlotSelection).toHaveBeenCalledWith([3, 4, 5]) + expect(clearSelected).not.toHaveBeenCalled() + }) + + // Without this the previous selection stayed painted on the plot. + it('clears the selection when the replay produced none', async () => { + await stepBackTo(undefined) + expect(setPlotSelection).not.toHaveBeenCalled() + expect(clearSelected).toHaveBeenCalledWith({ recordHistory: false }) + }) + + it('clears the selection when the replay produced an empty one', async () => { + await stepBackTo([]) + expect(setPlotSelection).not.toHaveBeenCalled() + expect(clearSelected).toHaveBeenCalledWith({ recordHistory: false }) + }) + }) +}) + +describe('EditHistory.vue snapshot buttons', () => { + beforeEach(() => { + editHistory.value = [] + isUpdating.value = false + isBuilding.value = false + plottedSnapshots.value = [] + selectedSeries.value = makeSeries() + vi.clearAllMocks() + }) + + const makeSession = (id: string, createdAt = '2026-03-01T00:00:00Z') => ({ + id, + createdAt, + status: 'in_progress', + phenomenonTimeStart: '2026-03-01T00:00:00Z', + phenomenonTimeEnd: '2026-04-01T00:00:00Z', + }) + + /** + * SessionList only renders the operations slot for a session it actually + * lists, or in its no-session fallback. Seed `sessions` accordingly or the + * history rows never mount. + */ + const mountWithSession = async ( + sessionId: string | null, + sessions: unknown[] = sessionId ? [makeSession(sessionId)] : [] + ) => { + const pinia = createTestPinia() + const { useQcSessionStore } = await import('@/store/qcSession') + const store = useQcSessionStore() + store.sessions = sessions as any + store.viewedSessionId = sessionId + return mount(EditHistory, { + props: {}, + global: { plugins: [pinia, createTestVuetify()] }, + }) + } + + it('renders an add-to-plot button on each operation row and the baseline', async () => { + editHistory.value = [makeEntry('FILL_GAPS'), makeEntry('DELETE_POINTS')] + + const w = await mountWithSession('sess-1') + + expect(w.find('[data-testid="history-snapshot-baseline"]').exists()).toBe(true) + expect(w.find('[data-testid="history-snapshot-0"]').exists()).toBe(true) + expect(w.find('[data-testid="history-snapshot-1"]').exists()).toBe(true) + }) + + it('toggles the snapshot for the clicked operation', async () => { + editHistory.value = [makeEntry('FILL_GAPS')] + + const w = await mountWithSession('sess-1') + await w.find('[data-testid="history-snapshot-0"]').trigger('click') + + expect(toggleSnapshot).toHaveBeenCalledWith('sess-1', 0) + }) + + it('toggles the baseline snapshot at index -1', async () => { + editHistory.value = [makeEntry('FILL_GAPS')] + + const w = await mountWithSession('sess-1') + await w.find('[data-testid="history-snapshot-baseline"]').trigger('click') + + expect(toggleSnapshot).toHaveBeenCalledWith('sess-1', -1) + }) + + // Plotting a comparison line is a read action, so it stays available on a + // committed session, which is exactly when comparing matters most. + it('stays enabled while the viewed session is read-only', async () => { + editHistory.value = [makeEntry('FILL_GAPS')] + + const w = await mountWithSession('sess-1', [ + makeSession('sess-1'), + makeSession('sess-2', '2026-04-01T00:00:00Z'), + ]) + const { useQcSessionStore } = await import('@/store/qcSession') + const store = useQcSessionStore() + store.currentSessionId = 'sess-2' + await flushPromises() + + expect(store.isReadOnly).toBe(true) + expect( + w.find('[data-testid="history-snapshot-0"]').attributes('disabled') + ).toBeUndefined() + }) + + it('does nothing when no session is being viewed', async () => { + editHistory.value = [makeEntry('FILL_GAPS')] + + const w = await mountWithSession(null) + await w.find('[data-testid="history-snapshot-0"]').trigger('click') + + expect(toggleSnapshot).not.toHaveBeenCalled() + }) }) diff --git a/apps/qc-app/src/components/EditData/__tests__/EditTargetPanel.spec.ts b/apps/qc-app/src/components/EditData/__tests__/EditTargetPanel.spec.ts new file mode 100644 index 00000000..fc754d52 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/EditTargetPanel.spec.ts @@ -0,0 +1,92 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' + +const { qcDatastream, inProgressSession, viewedSession, unsavedEditCount, openEditor } = + vi.hoisted(() => { + const { ref: r } = require('vue') as typeof import('vue') + return { + qcDatastream: r<{ id: string; name: string } | null>(null), + inProgressSession: r | null>(null), + viewedSession: r | null>(null), + unsavedEditCount: r(0), + openEditor: vi.fn(), + } + }) + +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ qcDatastream }), +})) + +vi.mock('@/store/qcSession', () => ({ + useQcSessionStore: () => ({ inProgressSession, viewedSession }), +})) + +vi.mock('@/composables/useEditSession', () => ({ + useEditSession: () => ({ unsavedEditCount }), +})) + +vi.mock('@/composables/useEditEntry', () => ({ + useEditEntry: () => ({ openEditor }), +})) + +import EditTargetPanel from '@/components/EditData/EditTargetPanel.vue' +import { formatDateRange } from '@/utils/time' + +const mountPanel = () => + mount(EditTargetPanel, { + global: { plugins: [createTestPinia(), createTestVuetify()] }, + }) + +beforeEach(() => { + vi.clearAllMocks() + qcDatastream.value = { id: 'mgd-1', name: 'Managed Temperature' } + inProgressSession.value = { + phenomenonTimeStart: '2025-01-05T00:00:00Z', + phenomenonTimeEnd: '2025-02-01T00:00:00Z', + } + viewedSession.value = null + unsavedEditCount.value = 0 +}) + +describe('EditTargetPanel', () => { + it('names what is being edited and the session window', () => { + const text = mountPanel().text() + expect(text).toContain('Managed Temperature') + expect(text).toContain( + formatDateRange('2025-01-05T00:00:00Z', '2025-02-01T00:00:00Z') + ) + }) + + it('reports unsaved edits, and says so when there are none', async () => { + expect(mountPanel().text()).toContain('All edits saved') + unsavedEditCount.value = 3 + expect(mountPanel().text()).toContain('3 unsaved edits') + unsavedEditCount.value = 1 + expect(mountPanel().text()).toContain('1 unsaved edit') + }) + + it('shows the window of the session being viewed over the live one', () => { + viewedSession.value = { + phenomenonTimeStart: '2024-03-01T00:00:00Z', + phenomenonTimeEnd: '2024-04-01T00:00:00Z', + } + expect(mountPanel().text()).toContain( + formatDateRange('2024-03-01T00:00:00Z', '2024-04-01T00:00:00Z') + ) + }) + + it('goes back to the editor without touching the session', async () => { + const wrapper = mountPanel() + await wrapper.find('[data-testid="open-editor-btn"]').trigger('click') + expect(openEditor).toHaveBeenCalledTimes(1) + }) + + it('renders nothing without an edit target', () => { + qcDatastream.value = null + expect(mountPanel().find('[data-testid="edit-target-panel"]').exists()).toBe( + false + ) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/LeaveSessionDialog.spec.ts b/apps/qc-app/src/components/EditData/__tests__/LeaveSessionDialog.spec.ts new file mode 100644 index 00000000..842058f4 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/LeaveSessionDialog.spec.ts @@ -0,0 +1,173 @@ +import { mount } from '@vue/test-utils' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +// jsdom has no visualViewport; Vuetify's overlay positioning reads it. +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768, + scale: 1, +} + +const { + leavePrompt, + leaveWork, + cancelLeave, + keepSession, + saveAndLeave, + discardEditsAndLeave, + discardSessionAndLeave, +} = vi.hoisted(() => { + const { ref: r } = require('vue') as typeof import('vue') + return { + leavePrompt: r | null>(null), + leaveWork: r(null), + cancelLeave: vi.fn(), + keepSession: vi.fn(), + saveAndLeave: vi.fn(), + discardEditsAndLeave: vi.fn(), + discardSessionAndLeave: vi.fn(), + } +}) + +vi.mock('@/composables/useLeaveSession', () => ({ + useLeaveSession: () => ({ + leavePrompt, + leaveWork, + cancelLeave, + keepSession, + saveAndLeave, + discardEditsAndLeave, + discardSessionAndLeave, + }), +})) + +import LeaveSessionDialog from '@/components/EditData/LeaveSessionDialog.vue' + +const mountDialog = () => + mount(LeaveSessionDialog, { + attachTo: document.body, + global: { plugins: [createTestPinia(), createTestVuetify()] }, + }) + +const dialogText = () => + document.querySelector('[data-testid="leave-session-dialog"]')?.textContent ?? + '' + +const button = (testId: string) => + document.querySelector(`[data-testid="${testId}"]`) + +beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + leavePrompt.value = null + leaveWork.value = null +}) + +describe('LeaveSessionDialog', () => { + it('shows nothing while no exit is pending', () => { + mountDialog() + expect(button('leave-session-dialog')).toBeNull() + }) + + it('offers save, discard and cancel for unsaved edits', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'unsaved', + unsavedCount: 2, + canSave: true, + } + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + const text = dialogText() + expect(text).toContain('2 edits') + expect(text).toContain('Resume it') + expect(button('leave-save-btn')).not.toBeNull() + expect(button('leave-save-btn')?.getAttribute('disabled')).toBeNull() + expect(button('leave-discard-edits-btn')).not.toBeNull() + + button('leave-discard-edits-btn')?.click() + expect(discardEditsAndLeave).toHaveBeenCalled() + button('leave-save-btn')?.click() + expect(saveAndLeave).toHaveBeenCalled() + button('leave-cancel-btn')?.click() + expect(cancelLeave).toHaveBeenCalled() + }) + + it('disables saving when no session is open', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'unsaved', + unsavedCount: 1, + canSave: false, + } + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + expect(button('leave-save-btn')?.getAttribute('disabled')).not.toBeNull() + expect(dialogText()).toContain('No session is open') + }) + + it('offers keep or discard for a session with no edits', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'empty', + unsavedCount: 0, + canSave: true, + } + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + expect(dialogText()).toContain('Your session on Temp (QC) has no edits') + expect(button('leave-save-btn')).toBeNull() + + button('leave-keep-btn')?.click() + expect(keepSession).toHaveBeenCalled() + button('leave-discard-session-btn')?.click() + expect(discardSessionAndLeave).toHaveBeenCalled() + }) + + it('says the session stays in progress when everything is saved', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'saved', + unsavedCount: 0, + canSave: true, + } + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + expect(dialogText()).toContain('stays in progress') + expect(button('leave-discard-session-btn')).toBeNull() + + button('leave-close-btn')?.click() + expect(keepSession).toHaveBeenCalled() + }) + + it('locks the choices while one is running', async () => { + leavePrompt.value = { + datastreamName: 'Temp (QC)', + kind: 'unsaved', + unsavedCount: 1, + canSave: true, + } + leaveWork.value = 'save' + const wrapper = mountDialog() + await wrapper.vm.$nextTick() + + expect(button('leave-cancel-btn')?.getAttribute('disabled')).not.toBeNull() + expect( + button('leave-discard-edits-btn')?.getAttribute('disabled') + ).not.toBeNull() + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/OperationPanel.spec.ts b/apps/qc-app/src/components/EditData/__tests__/OperationPanel.spec.ts new file mode 100644 index 00000000..488c23a8 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/OperationPanel.spec.ts @@ -0,0 +1,70 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { ref } from 'vue' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' + +const editLock = ref<'readOnly' | 'preview' | null>(null) +vi.mock('@/composables/useEditLock', () => ({ + useEditLock: () => ({ editLock }), +})) + +const selectedOperation = ref('changeValues') +const filterRangeActive = ref(false) +vi.mock('@/store/userInterface', () => ({ + useUIStore: () => ({ selectedOperation, filterRangeActive }), +})) + +const selectedData = ref([1, 2]) +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ selectedData }), +})) + +vi.mock('../operations', () => ({ + operationsById: { + changeValues: { + id: 'changeValues', + title: 'Change values', + description: '', + icon: 'mdi-pencil', + group: 'edit', + requiresSelection: true, + component: { template: '
' }, + }, + }, +})) + +import OperationPanel from '@/components/EditData/OperationPanel.vue' + +const mountPanel = () => + mount(OperationPanel, { + global: { plugins: [createTestPinia(), createTestVuetify()] }, + }) + +beforeEach(() => { + editLock.value = null +}) + +describe('OperationPanel edit lock', () => { + it('renders the operation when unlocked', async () => { + const w = mountPanel() + await flushPromises() + expect(w.find('[data-testid="op-body"]').exists()).toBe(true) + }) + + it('blocks the operation on a committed session', async () => { + editLock.value = 'readOnly' + const w = mountPanel() + await flushPromises() + expect(w.find('[data-testid="operation-read-only-blocked"]').exists()).toBe(true) + expect(w.find('[data-testid="op-body"]').exists()).toBe(false) + }) + + it('blocks the operation while previewing', async () => { + editLock.value = 'preview' + const w = mountPanel() + await flushPromises() + expect(w.find('[data-testid="operation-preview-blocked"]').exists()).toBe(true) + expect(w.find('[data-testid="op-body"]').exists()).toBe(false) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts b/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts new file mode 100644 index 00000000..b870cdec --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/SessionList.spec.ts @@ -0,0 +1,222 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, it, expect, beforeEach } from 'vitest' +import type { Pinia } from 'pinia' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +import { useHydroServer } from '@/store/hydroserver' +import { makeQcFake } from '@/services/qualityControl/__tests__/qcServiceFake' +import { useQcSessionStore } from '@/store/qcSession' +import { unwrap } from '@/services/qualityControl/unwrap' +import SessionList from '@/components/EditData/SessionList.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const win = (start: string, end: string) => ({ + phenomenonTimeStart: start, + phenomenonTimeEnd: end, +}) + +let pinia: Pinia +let qc: ReturnType + +beforeEach(() => { + pinia = createTestPinia() + qc = makeQcFake() + useHydroServer().hs = { + qualityControlHistories: qc.histories, + qualityControlSessions: qc.sessions, + qualityControlOperations: qc.operations, + } as any +}) + +const mountList = () => + mount(SessionList, { + global: { plugins: [pinia, createTestVuetify()] }, + slots: { operations: '
ops
' }, + }) + +/** Seed a committed + an in-progress session and load them into the store. */ +async function seedAndLoad() { + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + const committed = unwrap( + await qc.sessions.create(h.id, win('2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z')) + ) + await qc.sessions.commit(h.id, committed.id) + const inProgress = unwrap( + await qc.sessions.create(h.id, win('2025-02-01T00:00:00Z', '2025-03-01T00:00:00Z')) + ) + const store = useQcSessionStore() + store.applySessions(h.id, await store.fetchSessions(h.id)) + return { store, committedId: committed.id, inProgressId: inProgress.id } +} + +describe('SessionList', () => { + it('renders an empty state when there are no sessions', () => { + expect(mountList().text()).toContain('No sessions yet') + }) + + it('renders committed and in-progress sessions, marking the editable one', async () => { + const { committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find(`[data-testid="session-${committedId}"]`).exists()).toBe(true) + expect(wrapper.find(`[data-testid="session-${inProgressId}"]`).exists()).toBe(true) + expect(wrapper.text()).toContain('Editing') + }) + + it('marks each session with its status, not just the editable one', async () => { + const { committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + const marker = (id: string) => wrapper.find(`[data-testid="session-${id}"]`) + expect(marker(committedId).classes()).toContain('qc-timeline__item--done') + expect(marker(committedId).find('.mdi-check').exists()).toBe(true) + expect(marker(inProgressId).classes()).toContain( + 'qc-timeline__item--active' + ) + expect(marker(inProgressId).find('.mdi-pencil').exists()).toBe(true) + }) + + it('lists sessions oldest first, so the most recent is at the bottom', async () => { + const { committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + const ids = wrapper + .findAll('.v-timeline-item[data-testid^="session-"]') + .map((item) => item.attributes('data-testid')) + expect(ids).toEqual([`session-${committedId}`, `session-${inProgressId}`]) + }) + + it('orders by creation even when sessions share a phenomenon-time window', async () => { + // Continuing work after a commit reuses the same window, so the windows + // tie and only the creation time distinguishes the sessions. + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + const sameWindow = win('2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z') + const first = unwrap(await qc.sessions.create(h.id, sameWindow)) + await qc.sessions.commit(h.id, first.id) + const second = unwrap(await qc.sessions.create(h.id, sameWindow)) + useQcSessionStore().applySessions(h.id, await useQcSessionStore().fetchSessions(h.id)) + + const wrapper = mountList() + await flushPromises() + const ids = wrapper + .findAll('.v-timeline-item[data-testid^="session-"]') + .map((item) => item.attributes('data-testid')) + expect(ids).toEqual([`session-${first.id}`, `session-${second.id}`]) + }) + + // Selecting a session reloads observations and replays operations, so the + // list only reports the intent; the view owns the work. + it('emits view for a committed session', async () => { + const { committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + await wrapper.find(`[data-testid="session-header-${committedId}"]`).trigger('click') + expect(wrapper.emitted('view')![0]).toEqual([committedId]) + }) + + it('makes each session row a button, so the keyboard can reach it', async () => { + const { committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + const header = wrapper.find(`[data-testid="session-header-${committedId}"]`) + expect(header.element.tagName).toBe('BUTTON') + expect(header.attributes('type')).toBe('button') + }) + + it('emits view for the in-progress session too', async () => { + const { inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + await wrapper.find(`[data-testid="session-header-${inProgressId}"]`).trigger('click') + expect(wrapper.emitted('view')![0]).toEqual([inProgressId]) + }) + + it('shows "Return to current" while read-only, and it emits the current session', async () => { + const { store, committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find('[data-testid="session-return-current"]').exists()).toBe(false) + + // The view flips this after loading the committed session. + store.viewSession(committedId) + await flushPromises() + + const control = wrapper.find('[data-testid="session-return-current"]') + expect(control.exists()).toBe(true) + await control.trigger('click') + expect(wrapper.emitted('view')!.at(-1)).toEqual([inProgressId]) + }) + + // The operations panel nests under the session it belongs to, so the list + // reads hierarchically instead of as two sibling panels. + it('renders the operations slot under the viewed session only', async () => { + const { store, committedId, inProgressId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + + const inProgressItem = wrapper.find(`[data-testid="session-${inProgressId}"]`) + expect(inProgressItem.find('[data-testid="ops-panel"]').exists()).toBe(true) + const committedItem = wrapper.find(`[data-testid="session-${committedId}"]`) + expect(committedItem.find('[data-testid="ops-panel"]').exists()).toBe(false) + + store.viewSession(committedId) + await flushPromises() + + expect( + wrapper.find(`[data-testid="session-${committedId}"]`).find('[data-testid="ops-panel"]').exists() + ).toBe(true) + expect(wrapper.findAll('[data-testid="ops-panel"]')).toHaveLength(1) + }) + + it('previews the operation count of sessions that are not in view', async () => { + const h = unwrap( + await qc.histories.create({ + managedDatastreamId: 'm-1', + sourceDatastreamId: 's-1', + }) + ) + const range = win('2025-01-01T00:00:00Z', '2025-02-01T00:00:00Z') + const committed = unwrap(await qc.sessions.create(h.id, range)) + await qc.operations.create(h.id, committed.id, [ + { operationType: 'SELECTION' as any, order: 0 }, + { operationType: 'DELETE_POINTS' as any, order: 1 }, + ]) + await qc.sessions.commit(h.id, committed.id) + await qc.sessions.create(h.id, range) + useQcSessionStore().applySessions(h.id, await useQcSessionStore().fetchSessions(h.id)) + + const wrapper = mountList() + await flushPromises() + expect( + wrapper.find(`[data-testid="session-preview-${committed.id}"]`).text() + ).toContain('2 operations') + }) + + it('says so when a session that is not in view has no operations', async () => { + const { committedId } = await seedAndLoad() + const wrapper = mountList() + await flushPromises() + expect(wrapper.find(`[data-testid="session-preview-${committedId}"]`).text()).toContain( + 'No operations' + ) + }) + + it('still renders the operations slot when there are no sessions', () => { + const wrapper = mountList() + expect(wrapper.find('[data-testid="ops-panel"]').exists()).toBe(true) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/SessionWindowDialog.spec.ts b/apps/qc-app/src/components/EditData/__tests__/SessionWindowDialog.spec.ts new file mode 100644 index 00000000..919cf01d --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/SessionWindowDialog.spec.ts @@ -0,0 +1,221 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, it, expect, afterEach } from 'vitest' +import { defineComponent, h } from 'vue' +import { createTestVuetify } from '@/utils/test/vuetify' +import SessionWindowDialog from '@/components/EditData/SessionWindowDialog.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768, + scale: 1, +} + +const DatePickerStub = defineComponent({ + props: { modelValue: { type: Date, required: true } }, + emits: ['update:modelValue'], + setup(props, { attrs }) { + return () => + h('input', { + ...attrs, + value: props.modelValue.toISOString(), + }) + }, +}) + +const source = { + phenomenonBeginTime: '2025-01-01T00:00:00Z', + phenomenonEndTime: '2025-12-31T00:00:00Z', +} +const sessions = [ + { + status: 'committed', + phenomenonTimeStart: '2025-01-01T00:00:00Z', + phenomenonTimeEnd: '2025-05-01T00:00:00Z', + }, +] + +let wrapper: ReturnType | null = null +afterEach(() => { + wrapper?.unmount() + wrapper = null +}) + +const mountDialog = (props: Record = {}) => { + wrapper = mount(SessionWindowDialog, { + props: { managedName: 'Temp (QC)', source, sessions, ...props }, + global: { + plugins: [createTestVuetify()], + stubs: { DatePickerField: DatePickerStub }, + }, + attachTo: document.body, + }) + return wrapper +} + +const pickers = (w: ReturnType) => + w.findAllComponents(DatePickerStub) + +describe('SessionWindowDialog', () => { + it('prefills the window from where the committed history ends', () => { + const w = mountDialog() + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(false) + }) + + it('prefills the whole source extent when nothing is committed', () => { + const w = mountDialog({ sessions: [] }) + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-01-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + }) + + it('shows the committed history range', () => { + const w = mountDialog() + expect(w.find('[data-testid="session-window-committed"]').text()).not.toMatch(/Nothing committed/) + }) + + it('says when nothing is committed yet', () => { + const w = mountDialog({ sessions: [] }) + expect(w.find('[data-testid="session-window-committed"]').text()).toMatch(/Nothing committed yet/) + }) + + it('emits the window on Start', async () => { + const w = mountDialog() + await w.find('[data-testid="session-window-start"]').trigger('click') + const [window] = w.emitted('confirm')![0] as [{ begin: Date; end: Date }] + expect(window.begin.toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect(window.end.toISOString()).toBe('2025-12-31T00:00:00.000Z') + }) + + it('blocks Start when the committed history sits outside the source', () => { + const w = mountDialog({ + sessions: [ + { + status: 'committed', + phenomenonTimeStart: '2026-03-01T00:00:00Z', + phenomenonTimeEnd: '2026-04-01T00:00:00Z', + }, + ], + }) + expect(w.find('[data-testid="session-window-error"]').text()).toMatch(/gap before/) + expect(w.find('[data-testid="session-window-start"]').attributes('disabled')).toBeDefined() + }) + + it('blocks Start and explains a gap after the history', async () => { + const w = mountDialog() + pickers(w)[0]!.vm.$emit('update:modelValue', new Date('2025-07-01T00:00:00Z')) + await flushPromises() + expect(w.find('[data-testid="session-window-error"]').text()).toMatch(/gap after/) + expect(w.find('[data-testid="session-window-start"]').attributes('disabled')).toBeDefined() + }) + + it('emits cancel', async () => { + const w = mountDialog() + await w.find('[data-testid="session-window-cancel"]').trigger('click') + expect(w.emitted('cancel')).toHaveLength(1) + }) +}) + +describe('SessionWindowDialog presets', () => { + const preset = (w: ReturnType, id: string) => + w.find(`[data-testid="session-window-preset-${id}"]`) + + it('offers the record spans, plus one starting at the committed end', () => { + const w = mountDialog() + for (const id of ['all', '1y', '6m', '1m', 'since']) { + expect(preset(w, id).exists()).toBe(true) + } + }) + + it('leaves out the committed preset when nothing is committed', () => { + const w = mountDialog({ sessions: [] }) + expect(preset(w, 'since').exists()).toBe(false) + expect(preset(w, 'all').exists()).toBe(true) + }) + + it('sets both fields from a preset', async () => { + const w = mountDialog() + await preset(w, 'since').trigger('click') + await flushPromises() + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(false) + }) + + it('disables a preset that would break a rule and says why', () => { + const w = mountDialog() + expect(preset(w, '1m').attributes('disabled')).toBeDefined() + expect(w.find('[data-testid="session-window-preset-slot-1m"]').attributes('title')).toBe( + 'Leaves a gap after the committed work' + ) + expect(preset(w, 'all').attributes('disabled')).toBeUndefined() + }) + + it('marks the preset matching the current window', async () => { + const w = mountDialog() + expect(preset(w, 'since').attributes('aria-pressed')).toBe('true') + await preset(w, 'all').trigger('click') + await flushPromises() + expect(preset(w, 'all').attributes('aria-pressed')).toBe('true') + expect(preset(w, 'since').attributes('aria-pressed')).toBe('false') + }) +}) + +describe('SessionWindowDialog one-click fix', () => { + const fix = (w: ReturnType) => + w.find('[data-testid="session-window-fix"]') + + it('offers the nearest valid start and leaves the end alone', async () => { + const w = mountDialog() + pickers(w)[0]!.vm.$emit('update:modelValue', new Date('2025-07-01T00:00:00Z')) + await flushPromises() + expect(fix(w).exists()).toBe(true) + + await fix(w).trigger('click') + await flushPromises() + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(false) + expect(w.find('[data-testid="session-window-start"]').attributes('disabled')).toBeUndefined() + }) + + it('offers the nearest valid end and leaves the start alone', async () => { + const w = mountDialog() + pickers(w)[1]!.vm.$emit('update:modelValue', new Date('2026-06-01T00:00:00Z')) + await flushPromises() + expect(fix(w).text()).toMatch(/^End at/) + + await fix(w).trigger('click') + await flushPromises() + const [from, to] = pickers(w) + expect((from!.props('modelValue') as Date).toISOString()).toBe('2025-05-01T00:00:00.000Z') + expect((to!.props('modelValue') as Date).toISOString()).toBe('2025-12-31T00:00:00.000Z') + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(false) + }) + + it('offers no fix when no single correction would be valid', () => { + const w = mountDialog({ + sessions: [ + { + status: 'committed', + phenomenonTimeStart: '2026-03-01T00:00:00Z', + phenomenonTimeEnd: '2026-04-01T00:00:00Z', + }, + ], + }) + expect(w.find('[data-testid="session-window-error"]').exists()).toBe(true) + expect(fix(w).exists()).toBe(false) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/StartEditingDialog.spec.ts b/apps/qc-app/src/components/EditData/__tests__/StartEditingDialog.spec.ts new file mode 100644 index 00000000..9905447c --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/StartEditingDialog.spec.ts @@ -0,0 +1,332 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { describe, it, expect, afterEach } from 'vitest' +import { createTestVuetify } from '@/utils/test/vuetify' +import StartEditingDialog from '@/components/EditData/StartEditingDialog.vue' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +// jsdom has no visualViewport; Vuetify's overlay positioning reads it. +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768, + scale: 1, +} + +const source = { id: 'src-1', name: 'Raw Temp' } as any + +const options = [ + { + historyId: 'h-1', + managed: { + id: 'mgd-1', + name: 'Temp (QC)', + valueCount: 1234, + processingLevel: { definition: 'Quality Controlled' }, + }, + sessions: [ + { + id: 's-1', + status: 'in_progress', + createdAt: '2025-01-05T09:00:00Z', + phenomenonTimeStart: '2025-01-05T00:00:00Z', + phenomenonTimeEnd: '2025-01-15T00:00:00Z', + description: '', + }, + { + id: 's-0', + status: 'committed', + createdAt: '2024-12-01T09:00:00Z', + description: 'Removed spikes', + phenomenonTimeStart: '2024-12-01T00:00:00', + phenomenonTimeEnd: '2024-12-31T00:00:00', + }, + ], + }, + { historyId: 'h-2', managed: { id: 'mgd-2', name: 'Temp (QC v2)' }, sessions: [] }, +] as any + +// The delete confirmation is a v-dialog, whose content Vuetify teleports +// out of the wrapper, so it is queried through the document instead. +const mountDialog = (props: Record = {}) => + mount(StartEditingDialog, { + props: { source, options, ...props }, + global: { plugins: [createTestVuetify()] }, + attachTo: document.body, + }) + +const inDialog = (testId: string) => + document.querySelector(`[data-testid="${testId}"]`) + +const clickInDialog = async (testId: string) => { + const el = inDialog(testId) + if (!el) throw new Error(`${testId} is not in the dialog`) + ;(el as HTMLElement).click() + await flushPromises() +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('StartEditingDialog', () => { + it('lists managed datastreams and labels in-progress vs committed sessions', () => { + const w = mountDialog() + expect(w.text()).toContain('Raw Temp') + expect(w.text()).toContain('Temp (QC)') + expect(w.text()).toContain('In progress') + expect(w.text()).toContain('Committed') + }) + + it('offers Continue on the in-progress session row, not in the header', () => { + const w = mountDialog() + // mgd-1 has an in-progress session: continuing hangs off that row. + expect(w.find('[data-testid="edit-managed-mgd-1"]').exists()).toBe(false) + expect(w.find('[data-testid="continue-session-s-1"]').text()).toContain( + 'Continue' + ) + // mgd-2 has none, so the header offers starting one. + expect(w.find('[data-testid="edit-managed-mgd-2"]').text()).toContain( + 'Start new' + ) + }) + + it('heads the timeline with the start-a-session node, above the sessions', () => { + const w = mountDialog() + const ids = w + .findAll('.v-timeline-item') + .map((el) => el.attributes('data-testid') ?? 'new-session-node') + // mgd-1 has an in-progress session, so only mgd-2 offers the node. + expect(w.find('[data-testid="edit-managed-mgd-1"]').exists()).toBe(false) + expect(w.find('[data-testid="edit-managed-mgd-2"]').text()).toContain( + 'Start new session' + ) + // mgd-1's sessions run oldest first; mgd-2's action node follows them. + expect(ids).toEqual([ + 'chooser-session-s-0', + 'chooser-session-s-1', + 'new-session-node', + ]) + }) + + it('renders sessions as a timeline, marked by status', () => { + const w = mountDialog() + const item = (id: string) => w.find(`[data-testid="chooser-session-${id}"]`) + + expect(w.findAll('.v-timeline-item[data-testid^="chooser-session-"]')) + .toHaveLength(2) + expect(item('s-1').classes()).toContain('qc-timeline__item--active') + expect(item('s-1').find('.mdi-pencil').exists()).toBe(true) + expect(item('s-0').classes()).toContain('qc-timeline__item--done') + expect(item('s-0').find('.mdi-check').exists()).toBe(true) + }) + + it('offers delete only on the most recent session', () => { + const w = mountDialog() + // s-1 is the newest, so nothing can be built on it. + expect(w.find('[data-testid="delete-session-s-1"]').exists()).toBe(true) + expect(w.find('[data-testid="delete-session-s-0"]').exists()).toBe(false) + expect(w.find('[data-testid="continue-session-s-0"]').exists()).toBe(false) + }) + + it('drops the In progress chip but keeps the state readable', () => { + const w = mountDialog() + const active = w.find('[data-testid="chooser-session-s-1"]') + // The Continue button already says the row is in progress. + expect(active.find('.v-chip').exists()).toBe(false) + expect(active.find('.d-sr-only').text()).toBe('In progress') + // A committed row has no button, so its chip is the only cue. + const done = w.find('[data-testid="chooser-session-s-0"]') + expect(done.find('.v-chip').text()).toBe('Committed') + expect(done.find('.d-sr-only').exists()).toBe(false) + }) + + it('asks for confirmation, then emits deleteSession for that session', async () => { + const w = mountDialog() + await w.find('[data-testid="delete-session-s-1"]').trigger('click') + expect(inDialog('confirm-delete-session-s-1')).not.toBeNull() + await clickInDialog('confirm-delete-session-s-1') + const [option, sessionId] = w.emitted('deleteSession')![0] + expect(option).toMatchObject({ historyId: 'h-1' }) + expect(sessionId).toBe('s-1') + }) + + it('cancelling the confirmation emits nothing', async () => { + const w = mountDialog() + await w.find('[data-testid="delete-session-s-1"]').trigger('click') + await clickInDialog('cancel-delete') + expect(w.emitted('deleteSession')).toBeUndefined() + }) + + it('warns that the delete cannot be undone', async () => { + const w = mountDialog() + await w.find('[data-testid="delete-session-s-1"]').trigger('click') + const dialog = inDialog('delete-session-dialog')! + expect(dialog.textContent).toContain('Delete this session?') + expect(dialog.textContent).toContain('cannot be undone') + // Only the newest session is deletable, so nothing cascades. + expect(inDialog('delete-session-chain')).toBeNull() + expect(inDialog('delete-session-dependents-note')).toBeNull() + expect(inDialog('delete-session-acknowledge')).toBeNull() + }) + + it('formats session date ranges readably', () => { + const w = mountDialog() + expect(w.text()).toContain('Dec 1 – Dec 31, 2024') + }) + + it('shows the period alongside the description on a described session', () => { + const w = mountDialog() + // s-0 is committed and described, so the description takes the title and + // the window has to surface underneath it. + expect(w.text()).toContain('Removed spikes') + expect(w.text()).toContain('Dec 1 – Dec 31, 2024') + }) + + it('shows a recap line with processing level, observations, and sessions', () => { + const w = mountDialog() + expect(w.text()).toContain('Quality Controlled') + expect(w.text()).toContain('1,234 obs') + expect(w.text()).toContain('2 sessions, 1 in progress') + }) + + it('asks for confirmation, then emits delete with the chosen option', async () => { + const w = mountDialog() + await w.find('[data-testid="delete-managed-mgd-1"]').trigger('click') + await flushPromises() + expect(inDialog('delete-managed-dialog')?.textContent).toContain('Temp (QC)') + expect(w.emitted('delete')).toBeUndefined() + await clickInDialog('confirm-delete-mgd-1') + expect(w.emitted('delete')![0][0]).toMatchObject({ historyId: 'h-1' }) + }) + + it('emits edit from the in-progress row and from the header', async () => { + const w = mountDialog() + await w.find('[data-testid="continue-session-s-1"]').trigger('click') + expect(w.emitted('edit')![0][0]).toMatchObject({ historyId: 'h-1' }) + await w.find('[data-testid="edit-managed-mgd-2"]').trigger('click') + expect(w.emitted('edit')![1][0]).toMatchObject({ historyId: 'h-2' }) + }) + + it('emits create and cancel', async () => { + const w = mountDialog() + await w.find('[data-testid="chooser-create-managed"]').trigger('click') + expect(w.emitted('create')).toHaveLength(1) + await w.find('[data-testid="chooser-cancel"]').trigger('click') + expect(w.emitted('cancel')).toHaveLength(1) + }) + + it('shows an empty hint with only the create option when there are no managed datastreams', () => { + const w = mountDialog({ options: [] }) + expect(w.text()).toContain('No QC datastreams') + expect(w.find('[data-testid="chooser-create-managed"]').exists()).toBe(true) + }) +}) + +describe('StartEditingDialog session deletion', () => { + // s-0 committed, s-1 built on it, s-2 built on s-1. + const chained = [ + { + historyId: 'h-1', + managed: { id: 'mgd-1', name: 'Temp (QC)' }, + sessions: [ + { + id: 's-0', + status: 'committed', + createdAt: '2024-12-01T09:00:00Z', + description: 'First pass', + phenomenonTimeStart: '2024-12-01T00:00:00Z', + phenomenonTimeEnd: '2024-12-31T00:00:00Z', + dependencyIds: [], + }, + { + id: 's-1', + status: 'committed', + createdAt: '2025-01-05T09:00:00Z', + description: 'Second pass', + phenomenonTimeStart: '2025-01-05T00:00:00Z', + phenomenonTimeEnd: '2025-01-15T00:00:00Z', + dependencyIds: ['s-0'], + }, + { + id: 's-2', + status: 'in_progress', + createdAt: '2025-02-01T09:00:00Z', + description: 'Third pass', + phenomenonTimeStart: '2025-02-01T00:00:00Z', + phenomenonTimeEnd: '2025-02-10T00:00:00Z', + dependencyIds: ['s-1'], + }, + ], + }, + ] as any + + const mountChained = () => + mount(StartEditingDialog, { + props: { source, options: chained }, + global: { plugins: [createTestVuetify()] }, + attachTo: document.body, + }) + + const openDeleteFor = async (id: string) => { + const w = mountChained() + await w.find(`[data-testid="delete-session-${id}"]`).trigger('click') + return w + } + + it('offers delete on the newest session only, whatever came before', () => { + const w = mountChained() + expect(w.find('[data-testid="delete-session-s-2"]').exists()).toBe(true) + expect(w.find('[data-testid="delete-session-s-1"]').exists()).toBe(false) + expect(w.find('[data-testid="delete-session-s-0"]').exists()).toBe(false) + }) + + it('names the session and deletes it without an acknowledgement', async () => { + const w = await openDeleteFor('s-2') + const dialog = inDialog('delete-session-dialog')! + expect(dialog.textContent).toContain('Delete this session?') + expect(dialog.textContent).toContain('Third pass') + expect(inDialog('delete-session-acknowledge')).toBeNull() + + const confirm = inDialog('confirm-delete-session-s-2') as HTMLButtonElement + expect(confirm.disabled).toBe(false) + await clickInDialog('confirm-delete-session-s-2') + expect(w.emitted('deleteSession')![0][1]).toBe('s-2') + }) + + it('labels the trash button for the one session it removes', () => { + const w = mountChained() + expect( + w.find('[data-testid="delete-session-s-2"]').attributes('title') + ).toBe('Delete this session') + }) + + it('offers delete on the only session of a managed datastream', () => { + const lone = [ + { + historyId: 'h-9', + managed: { id: 'mgd-9', name: 'Temp (QC solo)' }, + sessions: [ + { + id: 's-solo', + status: 'committed', + createdAt: '2025-03-01T09:00:00Z', + description: 'Only pass', + phenomenonTimeStart: '2025-03-01T00:00:00Z', + phenomenonTimeEnd: '2025-03-05T00:00:00Z', + dependencyIds: [], + }, + ], + }, + ] as any + const w = mountDialog({ options: lone }) + expect(w.find('[data-testid="delete-session-s-solo"]').exists()).toBe(true) + // The managed datastream keeps its own delete, separate from the session. + expect(w.find('[data-testid="delete-managed-mgd-9"]').exists()).toBe(true) + }) +}) diff --git a/apps/qc-app/src/components/EditData/__tests__/StartEditingFlow.spec.ts b/apps/qc-app/src/components/EditData/__tests__/StartEditingFlow.spec.ts new file mode 100644 index 00000000..62026720 --- /dev/null +++ b/apps/qc-app/src/components/EditData/__tests__/StartEditingFlow.spec.ts @@ -0,0 +1,658 @@ +import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { VDialog } from 'vuetify/components' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} +// jsdom has no visualViewport; Vuetify's overlay positioning reads it. +;(globalThis as any).visualViewport ||= { + addEventListener() {}, + removeEventListener() {}, + offsetLeft: 0, + offsetTop: 0, + width: 1024, + height: 768, + scale: 1, +} + +const { + loadForSource, + deleteManaged, + deleteSession, + enterEdit, + startSessionOver, + leaveEdit, + closeEditor, + createManaged, + addQcHistory, + removeManagedDatastream, + invalidate, + qcDatastream, + datastreams, + processingLevels, + sourceDatastream, + sessions, + success, + error, +} = vi.hoisted(() => { + const { ref: r } = require('vue') as typeof import('vue') + return { + loadForSource: vi.fn(), + deleteManaged: vi.fn(), + deleteSession: vi.fn(), + enterEdit: vi.fn(), + startSessionOver: vi.fn(), + leaveEdit: vi.fn(), + closeEditor: vi.fn(), + createManaged: vi.fn(), + addQcHistory: vi.fn(), + removeManagedDatastream: vi.fn(), + invalidate: vi.fn(), + qcDatastream: r(null), + datastreams: r([]), + processingLevels: r([]), + sourceDatastream: r(null), + sessions: r([]), + success: vi.fn(), + error: vi.fn(), + } +}) + +vi.mock('@/store/dataVisualization', async () => { + const { defineStore } = await import('pinia') + return { + useDataVisStore: defineStore('dataVisualization', () => ({ + qcDatastream, + datastreams, + processingLevels, + addQcHistory, + removeManagedDatastream, + })), + } +}) + +vi.mock('@/store/qcSession', async () => { + const { defineStore } = await import('pinia') + const { computed } = await import('vue') + return { + useQcSessionStore: defineStore('qcSession', () => ({ + sourceDatastream, + sessions, + inProgressSession: computed( + () => sessions.value.find((s) => s.status === 'in_progress') ?? null + ), + })), + } +}) + +vi.mock('@/store/workingCopies', () => ({ + useWorkingCopiesStore: () => ({ invalidate }), +})) + +vi.mock('@/composables/useManagedDatastreams', () => ({ + useManagedDatastreams: () => ({ + loadForSource, + deleteManaged, + deleteSession, + }), +})) + +vi.mock('@/composables/useEditEntry', () => ({ + useEditEntry: () => ({ enterEdit, startSessionOver, leaveEdit, closeEditor }), +})) + +vi.mock('@/composables/useCreateManagedDatastream', () => ({ + useCreateManagedDatastream: () => ({ create: createManaged }), +})) + +vi.mock('@/composables/useProcessingLevels', () => ({ + useProcessingLevels: () => ({ createProcessingLevel: vi.fn() }), +})) + +vi.mock('@/composables/useWorkspacePermissions', () => ({ + useWorkspacePermissions: () => ({ + canCreateDatastream: () => true, + roleName: () => 'Owner', + }), +})) + +vi.mock('@uwrl/qc-utils', () => ({ Snackbar: { success, error } })) + +import StartEditingFlow from '@/components/EditData/StartEditingFlow.vue' + +const stub = (name: string, testId: string, emits: string[]) => + defineComponent({ + name, + emits, + setup: () => () => h('div', { 'data-testid': testId }), + }) + +const ChooserStub = stub('StartEditingDialog', 'chooser-stub', [ + 'edit', + 'delete', + 'deleteSession', + 'create', + 'cancel', +]) +const CreateStub = defineComponent({ + name: 'CreateDatastreamForm', + emits: ['cancel', 'confirm'], + props: { loading: Boolean }, + setup: () => () => h('div', { 'data-testid': 'create-stub' }), +}) +const WindowStub = stub('SessionWindowDialog', 'window-stub', [ + 'confirm', + 'cancel', +]) + +interface FlowApi { + openFor(source: unknown): Promise + openNewSession(): void + resume(managedId: string): Promise +} + +const source = { + id: 'src', + name: 'Raw Temp', + phenomenonBeginTime: '2025-01-01T00:00:00Z', + phenomenonEndTime: '2025-12-31T00:00:00Z', +} +const committed = { + id: 's-0', + status: 'committed', + phenomenonTimeStart: '2025-01-01T00:00:00Z', + phenomenonTimeEnd: '2025-02-01T00:00:00Z', +} +const inProgress = { ...committed, id: 's-1', status: 'in_progress' } +const optionWith = (list: unknown[]) => ({ + historyId: 'h', + managed: { id: 'mgd', name: 'Temp (QC)' }, + sessions: list, +}) +const window = { + begin: new Date('2025-02-01T00:00:00Z'), + end: new Date('2025-03-01T00:00:00Z'), +} + +const mountFlow = () => { + const wrapper = mount(StartEditingFlow, { + global: { + plugins: [createTestPinia(), createTestVuetify()], + stubs: { + StartEditingDialog: ChooserStub, + CreateDatastreamForm: CreateStub, + SessionWindowDialog: WindowStub, + }, + }, + attachTo: document.body, + }) + return { wrapper, flow: wrapper.vm as unknown as FlowApi } +} + +// v-dialog teleports its content out of the wrapper. +const present = (testId: string) => + !!document.querySelector(`[data-testid="${testId}"]`) + +// Dialog order: chooser, create form, session window. +const dialogOpen = ( + wrapper: ReturnType['wrapper'], + i: number +) => wrapper.findAllComponents(VDialog)[i].props('modelValue') + +enableAutoUnmount(afterEach) +afterEach(() => { + document.body.innerHTML = '' +}) + +beforeEach(() => { + vi.clearAllMocks() + qcDatastream.value = null + datastreams.value = [] + processingLevels.value = [] + sourceDatastream.value = null + sessions.value = [] + enterEdit.mockResolvedValue('editing') + startSessionOver.mockResolvedValue(true) + leaveEdit.mockResolvedValue(undefined) + closeEditor.mockResolvedValue(true) +}) + +describe('StartEditingFlow', () => { + it('opens the chooser when the source has managed datastreams', async () => { + loadForSource.mockResolvedValue([optionWith([committed])]) + const { flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(loadForSource).toHaveBeenCalledWith('src') + expect(present('chooser-stub')).toBe(true) + expect(present('create-stub')).toBe(false) + }) + + it('goes straight to the create form when the source has none', async () => { + loadForSource.mockResolvedValue([]) + const { flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(present('create-stub')).toBe(true) + expect(present('chooser-stub')).toBe(false) + }) + + it('continues an in-progress session without asking for a window', async () => { + const option = optionWith([committed, inProgress]) + loadForSource.mockResolvedValue([option]) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(enterEdit).toHaveBeenCalledTimes(1) + // Picked from a row, so it opens in the Select view's preview. + expect(enterEdit.mock.calls[0]).toEqual(['mgd', undefined, 'Select']) + expect(present('window-stub')).toBe(false) + }) + + it('asks for a window before starting a new session', async () => { + const option = optionWith([committed]) + loadForSource.mockResolvedValue([option]) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(present('window-stub')).toBe(true) + expect(enterEdit).not.toHaveBeenCalled() + + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(enterEdit).toHaveBeenCalledWith('mgd', window, 'Select') + expect(present('window-stub')).toBe(false) + }) + + it('opens the window step when a resume finds no session', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + expect(enterEdit.mock.calls[0]).toEqual(['mgd', undefined, undefined]) + expect(present('window-stub')).toBe(true) + }) + + it('leaves the editor when the window is cancelled with no sessions at all', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('cancel') + await flushPromises() + expect(leaveEdit).toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('leaves the editor when the window is closed with Esc or the scrim after an empty resume', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + wrapper.findAllComponents(VDialog)[2].vm.$emit('update:modelValue', false) + await flushPromises() + expect(leaveEdit).toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('leaves the editor when the window is cancelled after a resume over committed-only sessions', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + sessions.value = [committed] + const { wrapper, flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('cancel') + await flushPromises() + expect(leaveEdit).toHaveBeenCalled() + }) + + it('stays in the editor when the footer window is cancelled', async () => { + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + flow.openNewSession() + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('cancel') + await flushPromises() + expect(leaveEdit).not.toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('starts the session in the open editor when a resume window is confirmed', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(startSessionOver).toHaveBeenCalledWith(window) + expect(enterEdit).toHaveBeenCalledTimes(1) + }) + + it('keeps the window open to pick again when starting the session fails', async () => { + startSessionOver.mockResolvedValue(false) + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + flow.openNewSession() + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(present('window-stub')).toBe(true) + }) + + it('remounts the window step for a new target, so its defaults are fresh', async () => { + const option = optionWith([committed]) + loadForSource.mockResolvedValue([option]) + // The entering start fails and reopens the step on the open editor. + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + const first = wrapper.findComponent(WindowStub).vm + + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + + expect(present('window-stub')).toBe(true) + expect(wrapper.findComponent(WindowStub).vm).not.toBe(first) + }) + + it('closes the window when the start left the editor', async () => { + startSessionOver.mockImplementation(async () => { + qcDatastream.value = null + return false + }) + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + const { wrapper, flow } = mountFlow() + flow.openNewSession() + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(present('window-stub')).toBe(false) + }) + + it('leaves the editor when a resume needs a window but the source is missing', async () => { + enterEdit.mockResolvedValue('needs-window') + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = null + const { flow } = mountFlow() + await flow.resume('mgd') + await flushPromises() + expect(leaveEdit).toHaveBeenCalled() + expect(error).toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('starts a new session in the open editor from the footer', async () => { + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + sourceDatastream.value = source + sessions.value = [committed] + const { wrapper, flow } = mountFlow() + flow.openNewSession() + await flushPromises() + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(startSessionOver).toHaveBeenCalledWith(window) + expect(enterEdit).not.toHaveBeenCalled() + expect(present('window-stub')).toBe(false) + }) + + it('leaves the open session before the create form, not after the create', async () => { + loadForSource.mockResolvedValue([]) + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + const { flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(closeEditor).toHaveBeenCalled() + expect(present('create-stub')).toBe(true) + }) + + it('creates nothing when the open session is kept', async () => { + loadForSource.mockResolvedValue([]) + closeEditor.mockResolvedValue(false) + qcDatastream.value = { id: 'mgd', name: 'Temp (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(dialogOpen(wrapper, 1)).toBe(false) + expect(createManaged).not.toHaveBeenCalled() + }) + + it('asks before the create form opened from the chooser', async () => { + const option = optionWith([inProgress]) + loadForSource.mockResolvedValue([option]) + qcDatastream.value = { id: 'other', name: 'Other (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('create') + await flushPromises() + expect(closeEditor).toHaveBeenCalled() + expect(present('create-stub')).toBe(true) + }) + + it('returns to the chooser when the open session is kept', async () => { + const option = optionWith([inProgress]) + loadForSource.mockResolvedValue([option]) + closeEditor.mockResolvedValue(false) + qcDatastream.value = { id: 'other', name: 'Other (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('create') + await flushPromises() + expect(dialogOpen(wrapper, 1)).toBe(false) + expect(dialogOpen(wrapper, 0)).toBe(true) + }) + + it('asks about the open session when another datastream is picked, before the window step', async () => { + const option = optionWith([committed]) + loadForSource.mockResolvedValue([option]) + qcDatastream.value = { id: 'other', name: 'Other (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(closeEditor).toHaveBeenCalled() + expect(present('window-stub')).toBe(true) + }) + + it('returns to the chooser when a pick keeps the open session', async () => { + const option = optionWith([committed]) + loadForSource.mockResolvedValue([option]) + closeEditor.mockResolvedValue(false) + qcDatastream.value = { id: 'other', name: 'Other (QC)' } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(present('window-stub')).toBe(false) + expect(enterEdit).not.toHaveBeenCalled() + expect(dialogOpen(wrapper, 0)).toBe(true) + }) + + it('does not ask when the open datastream is picked again', async () => { + const option = optionWith([inProgress]) + loadForSource.mockResolvedValue([option]) + qcDatastream.value = { id: option.managed.id, name: option.managed.name } + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(ChooserStub).vm.$emit('edit', option) + await flushPromises() + expect(closeEditor).not.toHaveBeenCalled() + expect(enterEdit).toHaveBeenCalled() + }) + + it('does not ask when nothing is being edited', async () => { + loadForSource.mockResolvedValue([]) + const { flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + expect(closeEditor).not.toHaveBeenCalled() + expect(present('create-stub')).toBe(true) + }) + + it('asks for a window for a newly created managed datastream', async () => { + loadForSource.mockResolvedValue([]) + const managed = { id: 'mgd-new', name: 'Temp (QC new)' } + createManaged.mockResolvedValue({ + managedDatastream: managed, + history: { id: 'h-new' }, + }) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + wrapper.findComponent(CreateStub).vm.$emit('confirm', { + source, + processingLevelId: 'pl', + description: 'Cleaned series', + sensorId: 'sn-1', + }) + await flushPromises() + + expect(addQcHistory).toHaveBeenCalledWith({ id: 'h-new' }) + expect(datastreams.value).toContainEqual(managed) + expect(success).toHaveBeenCalledWith('Managed datastream created.') + // Closed dialogs keep their content mounted, so check the dialog itself. + expect(dialogOpen(wrapper, 1)).toBe(false) + expect(present('window-stub')).toBe(true) + + wrapper.findComponent(WindowStub).vm.$emit('confirm', window) + await flushPromises() + expect(enterEdit).toHaveBeenCalledWith('mgd-new', window, 'Select') + }) + + it('keeps the create form open and busy until the create finishes', async () => { + loadForSource.mockResolvedValue([]) + let finish!: (value: unknown) => void + createManaged.mockReturnValue(new Promise((resolve) => (finish = resolve))) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + const form = wrapper.findComponent(CreateStub) + form.vm.$emit('confirm', { source, processingLevelId: 'pl' }) + await flushPromises() + + expect(dialogOpen(wrapper, 1)).toBe(true) + expect(form.props('loading')).toBe(true) + + finish({ managedDatastream: { id: 'mgd-new', name: 'New' }, history: {} }) + await flushPromises() + expect(dialogOpen(wrapper, 1)).toBe(false) + expect(form.props('loading')).toBe(false) + }) + + it('keeps the create form open when the create fails', async () => { + loadForSource.mockResolvedValue([]) + createManaged.mockRejectedValue(new Error('Name already taken')) + const { wrapper, flow } = mountFlow() + await flow.openFor(source) + await flushPromises() + const form = wrapper.findComponent(CreateStub) + form.vm.$emit('confirm', { source, processingLevelId: 'pl' }) + await flushPromises() + + expect(error).toHaveBeenCalledWith('Name already taken') + expect(dialogOpen(wrapper, 1)).toBe(true) + expect(form.props('loading')).toBe(false) + expect(present('window-stub')).toBe(false) + }) +}) + +describe('StartEditingFlow deletes', () => { + const openChooser = async (option: unknown) => { + loadForSource.mockResolvedValue([option]) + const mounted = mountFlow() + await mounted.flow.openFor(source) + await flushPromises() + return mounted.wrapper.findComponent(ChooserStub) + } + + it('leaves the editor after deleting the managed datastream it has open', async () => { + qcDatastream.value = { id: 'mgd' } + deleteManaged.mockResolvedValue(undefined) + const chooser = await openChooser(optionWith([committed])) + + chooser.vm.$emit('delete', optionWith([committed])) + await flushPromises() + + expect(removeManagedDatastream).toHaveBeenCalledWith('h', 'mgd') + expect(leaveEdit).toHaveBeenCalledTimes(1) + }) + + it('keeps the editor when another managed datastream is deleted', async () => { + qcDatastream.value = { id: 'other' } + deleteManaged.mockResolvedValue(undefined) + const chooser = await openChooser(optionWith([committed])) + + chooser.vm.$emit('delete', optionWith([committed])) + await flushPromises() + + expect(leaveEdit).not.toHaveBeenCalled() + }) + + it('leaves the editor after deleting a session of the datastream it has open', async () => { + qcDatastream.value = { id: 'mgd' } + deleteSession.mockResolvedValue(undefined) + const option = optionWith([committed, inProgress]) + const chooser = await openChooser(option) + + chooser.vm.$emit('deleteSession', option, 's-1') + await flushPromises() + + expect(deleteSession).toHaveBeenCalledWith('h', 's-1') + expect(leaveEdit).toHaveBeenCalledTimes(1) + }) + + it('keeps the editor when the delete fails', async () => { + qcDatastream.value = { id: 'mgd' } + deleteManaged.mockRejectedValue(new Error('Forbidden')) + const chooser = await openChooser(optionWith([committed])) + + chooser.vm.$emit('delete', optionWith([committed])) + await flushPromises() + + expect(error).toHaveBeenCalledWith('Forbidden') + expect(leaveEdit).not.toHaveBeenCalled() + }) + + it('shows a failed session delete even when reloading the chooser fails too', async () => { + deleteSession.mockRejectedValue(new Error('Has dependents')) + const option = optionWith([committed, inProgress]) + const chooser = await openChooser(option) + loadForSource.mockRejectedValue(new Error('Offline')) + + chooser.vm.$emit('deleteSession', option, 's-1') + await flushPromises() + + expect(error).toHaveBeenCalledWith('Has dependents') + }) +}) diff --git a/apps/qc-app/src/components/FilterPoints/GapFinder.vue b/apps/qc-app/src/components/FilterPoints/GapFinder.vue index 6ff539be..ba35bea7 100644 --- a/apps/qc-app/src/components/FilterPoints/GapFinder.vue +++ b/apps/qc-app/src/components/FilterPoints/GapFinder.vue @@ -256,7 +256,7 @@ const snapChips = computed(() => { return multipliers.map((m) => { const amount = n * m return { - label: `${m}× intended (${amount} ${unitKey.toLowerCase()})`, + label: `${m}× intended (${amount} ${unitKey.toLowerCase()})`, amount, unit: unitKey, active: diff --git a/apps/qc-app/src/components/FilterPoints/RangeStager.vue b/apps/qc-app/src/components/FilterPoints/RangeStager.vue index 14dac2d4..e7b84973 100644 --- a/apps/qc-app/src/components/FilterPoints/RangeStager.vue +++ b/apps/qc-app/src/components/FilterPoints/RangeStager.vue @@ -79,6 +79,7 @@ import { stagePanMode, } from '@/utils/plotting/staging' import DatePickerField from '@/components/VisualizeData/DatePickerField.vue' +import { fromWallParts, wallParts } from '@/utils/timeZone' const { selectedSeries } = storeToRefs(usePlotlyStore()) @@ -205,7 +206,7 @@ const rangePresets = computed(() => { { label: 'YTD', compute: (b) => { - const jan1 = new Date(new Date().getFullYear(), 0, 1).getTime() + const jan1 = fromWallParts(wallParts(Date.now()).year, 0, 1) const start = Math.max(jan1, b.min) if (start >= b.max) return null return [start, b.max] diff --git a/apps/qc-app/src/components/Navigation/EditDrawer.vue b/apps/qc-app/src/components/Navigation/EditDrawer.vue index b85c841c..7b956450 100644 --- a/apps/qc-app/src/components/Navigation/EditDrawer.vue +++ b/apps/qc-app/src/components/Navigation/EditDrawer.vue @@ -1,6 +1,17 @@  diff --git a/apps/qc-app/src/components/Navigation/PerformanceCalibration.vue b/apps/qc-app/src/components/Navigation/PerformanceCalibration.vue index 17d6e90e..7ed7cca2 100644 --- a/apps/qc-app/src/components/Navigation/PerformanceCalibration.vue +++ b/apps/qc-app/src/components/Navigation/PerformanceCalibration.vue @@ -158,7 +158,7 @@ the algorithm, not the machine, so it's shipped with qc-utils, not measured at runtime. Recalibration only re-measures the three device primitives above; weights stay fixed. The - dispatch formula is weight × N / throughput, so + dispatch formula is weight × N / throughput, so one universal weight per op plus your per-device throughputs covers the full operation catalog.

diff --git a/apps/qc-app/src/components/Navigation/SelectDrawer.vue b/apps/qc-app/src/components/Navigation/SelectDrawer.vue index 3c1ebc17..1d718cbf 100644 --- a/apps/qc-app/src/components/Navigation/SelectDrawer.vue +++ b/apps/qc-app/src/components/Navigation/SelectDrawer.vue @@ -35,27 +35,6 @@ -
- - - Time range -
-
- -
- - -
diff --git a/apps/qc-app/src/components/Navigation/__tests__/EditDrawer.spec.ts b/apps/qc-app/src/components/Navigation/__tests__/EditDrawer.spec.ts new file mode 100644 index 00000000..2e284ff5 --- /dev/null +++ b/apps/qc-app/src/components/Navigation/__tests__/EditDrawer.spec.ts @@ -0,0 +1,75 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { ref } from 'vue' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' + +const editLock = ref<'readOnly' | 'preview' | null>(null) +vi.mock('@/composables/useEditLock', () => ({ + useEditLock: () => ({ editLock }), +})) + +const selectedOperation = ref(null) +vi.mock('@/store/userInterface', () => ({ + useUIStore: () => ({ selectedOperation }), +})) + +const selectedData = ref([1]) +vi.mock('@/store/dataVisualization', () => ({ + useDataVisStore: () => ({ selectedData }), +})) + +vi.mock('@/components/EditData/operations', () => { + const op = (id: string, group: string) => ({ + id, + title: id, + description: '', + icon: 'mdi-x', + group, + requiresSelection: group === 'edit', + }) + return { + operationsByGroup: { + filter: [op('valueThreshold', 'filter')], + edit: [op('changeValues', 'edit')], + add: [op('addPoints', 'add')], + }, + colorForOperation: () => 'primary', + } +}) + +import EditDrawer from '@/components/Navigation/EditDrawer.vue' + +const mountDrawer = () => + mount(EditDrawer, { + global: { plugins: [createTestPinia(), createTestVuetify()] }, + }) + +const isDisabled = (w: ReturnType, id: string) => + w.find(`[data-testid="op-${id}"]`).classes().includes('v-list-item--disabled') + +beforeEach(() => { + editLock.value = null + selectedOperation.value = null +}) + +describe('EditDrawer edit lock', () => { + it('enables every operation when unlocked', async () => { + const w = mountDrawer() + await flushPromises() + expect(w.find('[data-testid="edit-drawer-read-only"]').exists()).toBe(false) + for (const id of ['valueThreshold', 'changeValues', 'addPoints']) { + expect(isDisabled(w, id)).toBe(false) + } + }) + + it('disables every operation and explains why on a committed session', async () => { + editLock.value = 'readOnly' + const w = mountDrawer() + await flushPromises() + expect(w.find('[data-testid="edit-drawer-read-only"]').exists()).toBe(true) + for (const id of ['valueThreshold', 'changeValues', 'addPoints']) { + expect(isDisabled(w, id)).toBe(true) + } + }) +}) diff --git a/apps/qc-app/src/components/Navigation/__tests__/NavigationRail.spec.ts b/apps/qc-app/src/components/Navigation/__tests__/NavigationRail.spec.ts new file mode 100644 index 00000000..c3dcdc63 --- /dev/null +++ b/apps/qc-app/src/components/Navigation/__tests__/NavigationRail.spec.ts @@ -0,0 +1,304 @@ +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createTestPinia } from '@/utils/test/pinia' +import { createTestVuetify } from '@/utils/test/vuetify' +import { VLayout } from 'vuetify/components' +;(globalThis as any).ResizeObserver ||= class { + observe() {} + unobserve() {} + disconnect() {} +} + +const { + qcDatastream, + qcDatastreamId, + resetState, + clearEditTarget, + currentView, + selectedDrawer, + isDrawerOpen, + resumeDatastreamId, + hasUnsavedChanges, + inProgressSession, + hasSessionOperations, + push, + assign, + showView, +} = vi.hoisted(() => { + const { ref: r, computed: c } = require('vue') as typeof import('vue') + const qcDatastreamId = r(null) + const currentView = r('Edit') + const selectedDrawer = r('Edit') + const isDrawerOpen = r(true) + return { + qcDatastreamId, + qcDatastream: c(() => + qcDatastreamId.value ? { id: qcDatastreamId.value } : null + ), + resetState: vi.fn(() => { + qcDatastreamId.value = null + }), + clearEditTarget: vi.fn(async () => { + qcDatastreamId.value = null + }), + currentView, + selectedDrawer, + isDrawerOpen, + resumeDatastreamId: r(null), + hasUnsavedChanges: r(false), + inProgressSession: r<{ id: string } | null>(null), + hasSessionOperations: r(false), + push: vi.fn(async () => {}), + assign: vi.fn(), + showView: vi.fn((view: string) => { + currentView.value = view + selectedDrawer.value = view + isDrawerOpen.value = true + }), + } +}) + +vi.mock('@/store/dataVisualization', async () => { + const { defineStore } = await import('pinia') + return { + useDataVisStore: defineStore('dataVisualization', () => ({ + qcDatastream, + qcDatastreamId, + resetState, + setEditTarget: vi.fn(), + clearEditTarget, + })), + } +}) + +vi.mock('@/store/userInterface', async () => { + const { defineStore } = await import('pinia') + return { + DrawerType: { Edit: 'Edit', Select: 'Select', None: '' }, + useUIStore: defineStore('userInterface', () => ({ + currentView, + selectedDrawer, + isDrawerOpen, + showView, + onRailItemClicked: vi.fn((title: string) => { + if (selectedDrawer.value === title) { + isDrawerOpen.value = !isDrawerOpen.value + } else showView(title) + }), + })), + } +}) + +vi.mock('@/store/qcSession', async () => { + const { defineStore } = await import('pinia') + const { ref } = await import('vue') + return { + useQcSessionStore: defineStore('qcSession', () => ({ + resumeDatastreamId, + historyId: ref('h-1'), + sessions: ref([]), + inProgressSession, + isReadOnly: ref(false), + hasSessionOperations, + })), + } +}) + +vi.mock('@/store/hydroserver', async () => { + const { defineStore } = await import('pinia') + const { ref } = await import('vue') + return { + useHydroServer: defineStore('hydroserver', () => ({ + hs: ref({ session: { logout: vi.fn() } }), + })), + } +}) + +vi.mock('@/store/workspaces', async () => { + const { defineStore } = await import('pinia') + const { ref } = await import('vue') + return { + useWorkspaceStore: defineStore('workspaces', () => ({ + selectedWorkspace: ref(null), + clearSelection: vi.fn(), + })), + } +}) + +vi.mock('@/store/plotly', () => ({ + usePlotlyStore: () => ({ redraw: vi.fn() }), +})) + +vi.mock('@/composables/useEditSession', async () => { + const { ref } = await import('vue') + return { + ResumeSupersededError: class extends Error {}, + useEditSession: () => ({ + hasUnsavedChanges, + unsavedEditCount: ref(0), + saveDraft: vi.fn(), + discardUnsavedEdits: vi.fn(), + beginEditing: vi.fn(), + startSession: vi.fn(), + }), + } +}) + +vi.mock('@/router/router', () => ({ default: { push } })) + +vi.mock('@uwrl/qc-utils', () => ({ + Snackbar: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, +})) + +vi.mock('@/components/Navigation/SelectDrawer.vue', () => ({ + default: { name: 'SelectDrawer', render: () => null }, +})) +vi.mock('@/components/Navigation/PerformanceCalibration.vue', () => ({ + default: { name: 'PerformanceCalibration', render: () => null }, +})) + +import NavigationRail from '@/components/Navigation/NavigationRail.vue' +import { useLeaveSession } from '@/composables/useLeaveSession' + +function mountRail() { + return mount( + { render: () => h(VLayout, () => [h(NavigationRail)]) }, + { + global: { + plugins: [createTestPinia(), createTestVuetify()], + stubs: { + VTooltip: defineComponent({ + setup: (_, { slots }) => () => slots.activator?.({ props: {} }), + }), + VDialog: true, + }, + }, + } + ) +} + +describe('NavigationRail view switching', () => { + beforeEach(() => { + vi.clearAllMocks() + qcDatastreamId.value = 'm-1' + resumeDatastreamId.value = 'm-1' + currentView.value = 'Edit' + selectedDrawer.value = 'Edit' + hasUnsavedChanges.value = true + }) + + it('Select keeps the session, even with unsaved edits', async () => { + const wrapper = mountRail() + await wrapper.find('[data-testid="nav-rail-item-select"]').trigger('click') + await flushPromises() + + expect(currentView.value).toBe('Select') + expect(qcDatastreamId.value).toBe('m-1') + expect(resumeDatastreamId.value).toBe('m-1') + expect(clearEditTarget).not.toHaveBeenCalled() + expect(wrapper.find('.v-dialog').exists()).toBe(false) + wrapper.unmount() + }) + + it('Edit returns to the editor from the Select view', async () => { + currentView.value = 'Select' + selectedDrawer.value = 'Select' + const wrapper = mountRail() + const editRail = wrapper.find('[data-testid="nav-rail-item-edit"]') + expect(editRail.attributes('aria-disabled')).toBe('false') + await editRail.trigger('click') + await flushPromises() + + expect(currentView.value).toBe('Edit') + expect(qcDatastreamId.value).toBe('m-1') + wrapper.unmount() + }) + + it('Edit stays disabled with no edit target', async () => { + qcDatastreamId.value = null + currentView.value = 'Select' + selectedDrawer.value = 'Select' + const wrapper = mountRail() + const editRail = wrapper.find('[data-testid="nav-rail-item-edit"]') + expect(editRail.attributes('aria-disabled')).toBe('true') + await editRail.trigger('click') + await flushPromises() + + expect(currentView.value).toBe('Select') + wrapper.unmount() + }) +}) + +describe('NavigationRail leaving the editor', () => { + beforeEach(() => { + vi.clearAllMocks() + qcDatastreamId.value = 'm-1' + resumeDatastreamId.value = 'm-1' + currentView.value = 'Edit' + selectedDrawer.value = 'Edit' + hasUnsavedChanges.value = false + inProgressSession.value = null + hasSessionOperations.value = false + vi.stubGlobal('location', { ...window.location, assign }) + }) + + it('Home clears the resume pointer before reloading', async () => { + const wrapper = mountRail() + await wrapper.find('button[aria-label="Home"]').trigger('click') + await flushPromises() + + expect(resumeDatastreamId.value).toBeNull() + expect(qcDatastreamId.value).toBeNull() + expect(currentView.value).toBe('Select') + expect(assign).toHaveBeenCalledWith('/') + wrapper.unmount() + }) + + it('logging out ends the session first', async () => { + const wrapper = mountRail() + await wrapper.find('[data-testid="nav-rail-logout"]').trigger('click') + await flushPromises() + + expect(resumeDatastreamId.value).toBeNull() + expect(qcDatastreamId.value).toBeNull() + expect(assign).toHaveBeenCalledWith('/login') + wrapper.unmount() + }) + + // The router guard runs the leave flow for in-app navigation, so the rail + // only navigates. + it('switching workspace navigates and leaves the session to the guard', async () => { + const wrapper = mountRail() + await wrapper.find('[data-testid="nav-rail-workspaces"]').trigger('click') + await flushPromises() + + expect(push).toHaveBeenCalledWith({ + name: 'Workspaces', + query: { switch: '1' }, + }) + expect(clearEditTarget).not.toHaveBeenCalled() + expect(resumeDatastreamId.value).toBe('m-1') + wrapper.unmount() + }) + + it('asks about unsaved edits and stays put when the user cancels', async () => { + hasUnsavedChanges.value = true + inProgressSession.value = { id: 'qcs-1' } + const wrapper = mountRail() + await wrapper.find('button[aria-label="Home"]').trigger('click') + await flushPromises() + + const { leavePrompt, cancelLeave } = useLeaveSession() + expect(leavePrompt.value?.kind).toBe('unsaved') + expect(assign).not.toHaveBeenCalled() + + cancelLeave() + await flushPromises() + + expect(assign).not.toHaveBeenCalled() + expect(qcDatastreamId.value).toBe('m-1') + expect(resumeDatastreamId.value).toBe('m-1') + wrapper.unmount() + }) +}) diff --git a/apps/qc-app/src/components/VisualizeData.vue b/apps/qc-app/src/components/VisualizeData.vue index 51bb7eba..66caaf65 100644 --- a/apps/qc-app/src/components/VisualizeData.vue +++ b/apps/qc-app/src/components/VisualizeData.vue @@ -1,6 +1,6 @@