From 910ed151f199e9027f44817009a564cc8e7f9bbe Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 02:30:23 -0400 Subject: [PATCH] Keep admitted media frames in one stable dashboard session tree --- neode-ui/src/views/Dashboard.vue | 21 +------ .../views/dashboard/DashboardAppSessions.vue | 30 ++++++++++ .../__tests__/DashboardAppSessions.test.ts | 55 +++++++++++++++++++ 3 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 neode-ui/src/views/dashboard/DashboardAppSessions.vue create mode 100644 neode-ui/src/views/dashboard/__tests__/DashboardAppSessions.test.ts diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index e9822c84..cb1b6d0f 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -91,23 +91,8 @@ - - -
- -
-
- -
- -
-
+ + @@ -127,7 +112,7 @@ import { useRouter, useRoute } from 'vue-router' import { useAppStore } from '../stores/app' import { useAppLauncherStore } from '../stores/appLauncher' import { appConfirm } from '@/composables/useAppConfirm' -import AppSession from '@/views/AppSession.vue' +import DashboardAppSessions from '@/views/dashboard/DashboardAppSessions.vue' import { useLoginTransitionStore } from '../stores/loginTransition' import { playDashboardLoadOomph } from '@/composables/useLoginSounds' diff --git a/neode-ui/src/views/dashboard/DashboardAppSessions.vue b/neode-ui/src/views/dashboard/DashboardAppSessions.vue new file mode 100644 index 00000000..dbd982cd --- /dev/null +++ b/neode-ui/src/views/dashboard/DashboardAppSessions.vue @@ -0,0 +1,30 @@ + + + diff --git a/neode-ui/src/views/dashboard/__tests__/DashboardAppSessions.test.ts b/neode-ui/src/views/dashboard/__tests__/DashboardAppSessions.test.ts new file mode 100644 index 00000000..710a2da5 --- /dev/null +++ b/neode-ui/src/views/dashboard/__tests__/DashboardAppSessions.test.ts @@ -0,0 +1,55 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, onMounted, onUnmounted } from 'vue' +vi.mock('@/stores/appLauncher', async () => { + const {reactive} = await import('vue') + const state = reactive({panelAppId:null as string|null, mediaAppId:null as string|null, panelPath:null as string|null, + closePanel() {state.panelAppId=null;state.panelPath=null}}) + return {useAppLauncherStore:()=>state} +}) +import { useAppLauncherStore } from '@/stores/appLauncher' +import DashboardAppSessions from '../DashboardAppSessions.vue' +describe('stable dashboard app frame ownership', () => { + const mounted=vi.fn(), unmounted=vi.fn() + const Session=defineComponent({props:{appIdProp:String,pathProp:String,suspended:Boolean},setup(props) { + onMounted(()=>mounted(props.appIdProp));onUnmounted(()=>unmounted(props.appIdProp)) + return ()=>h('iframe',{'data-app':props.appIdProp,src:props.pathProp ?? '/browse','data-suspended':String(props.suspended)}) + }}) + beforeEach(()=> {vi.clearAllMocks();const store=useAppLauncherStore();store.panelAppId=null;store.mediaAppId=null;store.panelPath=null}) + it('keeps the exact playing frame and deep link through late promotion, close and reopen',async()=>{ + const store=useAppLauncherStore();store.panelAppId='music';store.panelPath='/browse/album/one' + const wrapper=mount(DashboardAppSessions,{global:{stubs:{AppSession:Session,TransitionGroup:false}}}) + const frame=wrapper.get('iframe').element as HTMLIFrameElement + frame.dataset.playbackPosition='17' + store.mediaAppId='music';await nextTick() + expect(wrapper.get('iframe').element).toBe(frame) + expect(mounted).toHaveBeenCalledTimes(1);expect(unmounted).not.toHaveBeenCalled() + store.closePanel();await nextTick() + expect(wrapper.get('iframe').element).toBe(frame) + expect(frame.getAttribute('src')).toBe('/browse/album/one') + expect(frame.dataset.suspended).toBe('true') + store.panelAppId='music';await nextTick() + expect(wrapper.get('iframe').element).toBe(frame) + expect(frame.dataset.playbackPosition).toBe('17') + expect(frame.getAttribute('src')).toBe('/browse/album/one') + expect(frame.dataset.suspended).toBe('false') + expect(unmounted).not.toHaveBeenCalled() + wrapper.unmount();expect(unmounted).toHaveBeenCalledOnce() + }) + it('keeps retained music while another app opens and promotes that app without recreating it',async()=>{ + const store=useAppLauncherStore();store.panelAppId='music';store.panelPath='/first' + const wrapper=mount(DashboardAppSessions,{global:{stubs:{AppSession:Session,TransitionGroup:false}}}) + const music=wrapper.get('iframe[data-app="music"]').element + store.mediaAppId='music';await nextTick() + store.panelPath='/station/two';store.panelAppId='radio';await nextTick() + const radio=wrapper.get('iframe[data-app="radio"]').element + expect(wrapper.get('iframe[data-app="music"]').element).toBe(music) + expect(music.getAttribute('src')).toBe('/first') + store.mediaAppId='radio';await nextTick() + expect(wrapper.get('iframe[data-app="radio"]').element).toBe(radio) + expect(mounted.mock.calls.map(call=>call[0])).toEqual(['music','radio']) + expect(unmounted).toHaveBeenCalledWith('music') + expect(unmounted).not.toHaveBeenCalledWith('radio') + wrapper.unmount() + }) +})