Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import BaseModal from '../BaseModal.vue'
describe('BaseModal', () => {
afterEach(() => {
document.body.style.overflow = ''
})
it('locks page scroll while open and restores it when closed', async () => {
const wrapper = mount(BaseModal, {
props: { show: true, title: 'Test modal' },
slots: { default: '<p>Modal content</p>' },
attachTo: document.body,
})
expect(document.body.style.overflow).toBe('hidden')
await wrapper.setProps({ show: false })
expect(document.body.style.overflow).toBe('')
wrapper.unmount()
})
it('closes itself when the route changes', async () => {
// Tab views are KeepAlive'd, so navigating away deactivates the owner
// rather than unmounting it — a Teleported modal would otherwise keep
// floating over the destination screen. Seen with the Lightning modal's
// "Open a channel" / "Setup Guide" actions, which route away from inside
// the wallet's own send/receive modal.
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/elsewhere', component: { template: '<div />' } },
],
})
await router.push('/')
await router.isReady()
const wrapper = mount(BaseModal, {
props: { show: true, title: 'Test modal' },
slots: { default: '<p>Modal content</p>' },
global: { plugins: [router] },
})
expect(wrapper.emitted('close')).toBeUndefined()
await router.push('/elsewhere')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('close')).toHaveLength(1)
wrapper.unmount()
})
it('does not emit close on a route change while hidden', async () => {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div />' } },
{ path: '/elsewhere', component: { template: '<div />' } },
],
})
await router.push('/')
await router.isReady()
const wrapper = mount(BaseModal, {
props: { show: false, title: 'Test modal' },
global: { plugins: [router] },
})
await router.push('/elsewhere')
await wrapper.vm.$nextTick()
expect(wrapper.emitted('close')).toBeUndefined()
wrapper.unmount()
})
})
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import LineChart from '../LineChart.vue'
// Mock canvas context
const mockContext = {
clearRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
stroke: vi.fn(),
fill: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
closePath: vi.fn(),
setLineDash: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
scale: vi.fn(),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn(),
}),
canvas: { width: 600, height: 200 },
strokeStyle: '',
fillStyle: '',
lineWidth: 0,
font: '',
textAlign: '',
textBaseline: '',
globalAlpha: 1,
}
beforeEach(() => {
vi.clearAllMocks()
HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue(mockContext)
})
describe('LineChart', () => {
const sampleDatasets = [
{ label: 'CPU', data: [10, 20, 30, 40, 50], color: '#fb923c' },
]
it('renders a canvas element', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
expect(wrapper.find('canvas').exists()).toBe(true)
})
it('accepts datasets prop', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
expect(wrapper.exists()).toBe(true)
})
it('renders with empty datasets', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: [] },
})
expect(wrapper.find('canvas').exists()).toBe(true)
})
it('renders with multiple datasets', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: [
{ label: 'CPU', data: [10, 20, 30], color: '#fb923c' },
{ label: 'Memory', data: [50, 60, 70], color: '#4ade80' },
],
},
})
expect(wrapper.exists()).toBe(true)
})
it('accepts optional height and width props', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: sampleDatasets,
height: 300,
width: 600,
},
})
const canvas = wrapper.find('canvas')
expect(canvas.attributes('width')).toBe('600')
expect(canvas.attributes('height')).toBe('300')
})
it('uses default width of 400 and height of 180', () => {
const wrapper = shallowMount(LineChart, {
props: { datasets: sampleDatasets },
})
const canvas = wrapper.find('canvas')
expect(canvas.attributes('width')).toBe('400')
expect(canvas.attributes('height')).toBe('180')
})
it('renders with dataset containing single data point', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: [{ label: 'Test', data: [42], color: '#3b82f6' }],
},
})
expect(wrapper.exists()).toBe(true)
})
it('accepts yMax and yLabel props', () => {
const wrapper = shallowMount(LineChart, {
props: {
datasets: sampleDatasets,
yMax: 100,
yLabel: 'Percent',
},
})
expect(wrapper.exists()).toBe(true)
})
})
@@ -0,0 +1,145 @@
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import MediaLightbox from '../cloud/MediaLightbox.vue'
import { usePipSession } from '../../composables/usePipSession'
import type { FileBrowserItem } from '../../api/filebrowser-client'
// jsdom has no picture-in-picture implementation — stub the pieces the
// component and the session touch so `enterpictureinpicture` /
// `leavepictureinpicture` can be dispatched like a real browser would.
beforeEach(() => {
// jsdom doesn't implement these either — MediaLightbox's onUnmounted
// revokes every cached blob URL, which throws "not implemented" otherwise.
if (!URL.createObjectURL) URL.createObjectURL = vi.fn(() => 'blob:stub')
if (!URL.revokeObjectURL) URL.revokeObjectURL = vi.fn()
Object.defineProperty(document, 'pictureInPictureEnabled', {
value: true,
configurable: true,
})
if (!('pictureInPictureElement' in document)) {
Object.defineProperty(document, 'pictureInPictureElement', {
value: null,
configurable: true,
})
}
if (!HTMLVideoElement.prototype.requestPictureInPicture) {
HTMLVideoElement.prototype.requestPictureInPicture = async function () {
return null as unknown as PictureInPictureWindow
}
}
if (!document.exitPictureInPicture) {
document.exitPictureInPicture = async () => {}
}
})
afterEach(() => {
usePipSession().release()
document.body.innerHTML = ''
})
const videoItem: FileBrowserItem = {
name: 'clip.mp4',
path: '/clip.mp4',
isDir: false,
} as FileBrowserItem
// MediaLightbox teleports its content to <body>, so its markup lives
// outside the mounted wrapper's own root element — query the document
// directly rather than through `wrapper.find`.
function findVideo(): HTMLVideoElement {
const video = document.body.querySelector('video')
if (!video) throw new Error('video not rendered')
return video as HTMLVideoElement
}
function findBackdrop(): HTMLElement {
const backdrop = document.body.querySelector('.lightbox-backdrop')
if (!backdrop) throw new Error('backdrop not rendered')
return backdrop as HTMLElement
}
async function mountLightbox() {
const wrapper = mount(MediaLightbox, {
props: {
items: [videoItem],
startIndex: 0,
show: true,
fetchBlobUrl: vi.fn().mockResolvedValue('blob:fetch'),
streamUrl: vi.fn().mockResolvedValue('blob:stream'),
},
attachTo: document.body,
})
await flushPromises()
return wrapper
}
describe('MediaLightbox picture-in-picture handoff', () => {
it('entering PiP emits close exactly once and adopts the video before doing so', async () => {
const wrapper = await mountLightbox()
const video = findVideo()
video.dispatchEvent(new Event('enterpictureinpicture'))
// Adopted synchronously, before the animation/emit has finished — the
// video is no longer a descendant of the lightbox's own subtree.
expect(usePipSession().active.value).toBe(true)
expect(usePipSession().element.value).toBe(video)
expect(findBackdrop().contains(video)).toBe(false)
expect(wrapper.emitted('close')).toBeUndefined()
// Bounded fallback fires the close even without a real transitionend
// (jsdom does not run CSS transitions).
await new Promise((resolve) => setTimeout(resolve, 400))
expect(wrapper.emitted('close')).toHaveLength(1)
wrapper.unmount()
// Still connected to the document after the owner unmounts.
expect(document.body.contains(video)).toBe(true)
})
it('applies the handoff class on the PiP path', async () => {
await mountLightbox()
const video = findVideo()
video.dispatchEvent(new Event('enterpictureinpicture'))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(findBackdrop().classList.contains('lightbox-pip-handoff')).toBe(true)
})
it('applies no handoff class on a button-driven close', async () => {
const wrapper = await mountLightbox()
const closeButton = document.body.querySelector(
'.lightbox-topbar .lightbox-btn:last-child'
) as HTMLButtonElement
closeButton.click()
await flushPromises()
expect(wrapper.emitted('close')).toHaveLength(1)
expect(findBackdrop().classList.contains('lightbox-pip-handoff')).toBe(false)
})
it('releases the session when picture-in-picture is left', async () => {
await mountLightbox()
const video = findVideo()
video.dispatchEvent(new Event('enterpictureinpicture'))
expect(usePipSession().active.value).toBe(true)
video.dispatchEvent(new Event('leavepictureinpicture'))
expect(usePipSession().active.value).toBe(false)
})
it('does not change props or emits declared by the component', async () => {
// Contract guard mirrored from the diff-based acceptance criterion:
// this component is used by a second, parallel instance (plan 01-14)
// and must keep working with an unmodified prop/emit set.
const wrapper = await mountLightbox()
expect(wrapper.props('startIndex')).toBe(0)
expect(wrapper.props('show')).toBe(true)
expect(typeof wrapper.props('fetchBlobUrl')).toBe('function')
})
})
@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MeshMap from '../MeshMap.vue'
const meshState = vi.hoisted(() => ({
nodePositions: new Map(),
federatedPositions: new Map(),
peers: [],
status: null,
deadmanStatus: null,
updateSelfPosition: vi.fn(),
}))
vi.mock('@/stores/mesh', () => ({
useMeshStore: () => meshState,
}))
vi.mock('leaflet', () => ({
default: {
map: vi.fn(() => ({
invalidateSize: vi.fn(),
fitBounds: vi.fn(),
remove: vi.fn(),
})),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
divIcon: vi.fn((opts) => opts),
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
polyline: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({ pad: vi.fn() })),
},
}))
describe('MeshMap', () => {
beforeEach(() => {
meshState.nodePositions.clear()
meshState.federatedPositions.clear()
meshState.peers = []
meshState.status = null
meshState.deadmanStatus = null
meshState.updateSelfPosition.mockClear()
})
it('treats denied browser location as optional for peer positions', async () => {
let errorHandler!: (error: { code: number; message: string }) => void
const watchPosition = vi.fn((_success, error) => {
errorHandler = error
return 7
})
const clearWatch = vi.fn()
const resizeObserver = vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
}))
vi.stubGlobal('navigator', {
geolocation: { watchPosition, clearWatch },
})
vi.stubGlobal('ResizeObserver', resizeObserver)
const wrapper = mount(MeshMap)
expect(wrapper.text()).toContain('Waiting for mesh device positions.')
await wrapper.get('[role="switch"]').trigger('click')
errorHandler({ code: 1, message: 'denied' })
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Location permission denied. Peer locations can still appear on the map.')
expect(wrapper.text()).toContain('Local location is off. Other device positions will appear when received.')
expect(wrapper.text()).not.toContain('location sharing is required')
vi.unstubAllGlobals()
})
})
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import PWAInstallPrompt from '../PWAInstallPrompt.vue'
describe('PWAInstallPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
// Mock matchMedia to return non-standalone
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
})
it('renders without errors', () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
expect(wrapper.exists()).toBe(true)
})
it('does not show prompt initially', () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
expect(wrapper.text()).not.toContain('Install Archipelago')
})
it('shows prompt after beforeinstallprompt event', async () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Fire the beforeinstallprompt event
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Install Archipelago')
})
it('hides prompt when dismissed', async () => {
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Show prompt
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
// Click dismiss button
const dismissBtn = wrapper.findAll('button').find(b => b.text().includes('Not now'))
expect(dismissBtn).toBeDefined()
await dismissBtn!.trigger('click')
expect(sessionStorage.getItem('archipelago_pwa_install_dismissed')).toBe('1')
})
it('does not show if already dismissed this session', async () => {
sessionStorage.setItem('archipelago_pwa_install_dismissed', '1')
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
// Fire beforeinstallprompt — should not show
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Install Archipelago')
})
it('does not show in standalone mode', async () => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockReturnValue({ matches: true }),
})
const wrapper = shallowMount(PWAInstallPrompt, {
global: { stubs: { Teleport: true, Transition: true } },
})
const event = new Event('beforeinstallprompt')
Object.defineProperty(event, 'preventDefault', { value: vi.fn() })
window.dispatchEvent(event)
await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Install Archipelago')
})
})
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import i18n from '@/i18n'
import ScreensaverRing from '../ScreensaverRing.vue'
import SendBitcoinModal from '../SendBitcoinModal.vue'
import WalletScanModal from '../WalletScanModal.vue'
// Both modals fetch balances/fees on open. None of that is what this suite is
// about — stub the transport so mounting is deterministic and offline.
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn().mockResolvedValue({}),
},
}))
// WalletScanModal reaches for camera/QR APIs jsdom does not implement.
beforeEach(() => {
if (!navigator.mediaDevices) {
Object.defineProperty(navigator, 'mediaDevices', {
value: { getUserMedia: vi.fn().mockRejectedValue(new Error('no camera')), enumerateDevices: vi.fn().mockResolvedValue([]) },
configurable: true,
})
}
})
// Teleported modal markup outlives the wrapper's root, so clear it between
// cases — otherwise one modal's nodes answer the next one's queries.
afterEach(() => {
document.body.innerHTML = ''
})
const mountOpts = { props: { show: true }, global: { plugins: [i18n] } }
describe('paid tick renders the branded ring (FED-06)', () => {
it('SendBitcoinModal: payment success shows exactly one badge ring, no ripple burst', async () => {
const wrapper = mount(SendBitcoinModal, mountOpts)
// Drive the component into its settled-payment state directly — this
// suite is about what success *renders*, not how a payment settles.
;(wrapper.vm as unknown as Record<string, unknown>).successInfo = {
amount: 12345,
methodLabel: 'Sent via Lightning',
}
await flushPromises()
const rings = wrapper.findAllComponents(ScreensaverRing)
expect(rings).toHaveLength(1)
expect(rings[0]?.props('size')).toBe('badge')
// BaseModal teleports its content to <body>, so the rendered markup lives
// outside the wrapper's own root element — assert against the document.
// The checkmark core survives the ring swap...
expect(document.querySelector('.burst-core')).not.toBeNull()
expect(document.querySelector('.burst-check')).not.toBeNull()
// ...and the CSS ripple elements it replaced are gone entirely.
expect(document.querySelectorAll('.burst-ring')).toHaveLength(0)
// Decoration only: the amount and SENT copy are untouched.
expect(document.body.textContent).toContain('12,345')
expect(document.body.textContent).toContain('SENT')
wrapper.unmount()
})
it('WalletScanModal: success pane shows the same badge ring and keeps its checkmark', async () => {
const wrapper = mount(WalletScanModal, mountOpts)
;(wrapper.vm as unknown as Record<string, unknown>).pane = 'success'
await flushPromises()
const rings = wrapper.findAllComponents(ScreensaverRing)
expect(rings).toHaveLength(1)
expect(rings[0]?.props('size')).toBe('badge')
expect(document.querySelector('.scan-success-core')).not.toBeNull()
expect(document.querySelector('svg path[d="M5 13l4 4L19 7"]')).not.toBeNull()
// The plain fixed-size circle the ring replaced is gone.
expect(document.querySelectorAll('.success-ring')).toHaveLength(0)
wrapper.unmount()
})
})
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ScreensaverRing from '../ScreensaverRing.vue'
// The ring is shared by the screensaver (default), SystemDangerZone (compact)
// and — as of FED-06 — the payment-success tick (badge). These assertions pin
// the variant mapping so adding a size can never silently re-point an existing
// call site at different geometry.
describe('ScreensaverRing', () => {
it('maps each size variant to its own ring class', () => {
expect(mount(ScreensaverRing).classes()).toContain('viz-ring-default')
expect(mount(ScreensaverRing, { props: { size: 'compact' } }).classes()).toContain(
'viz-ring-compact',
)
expect(mount(ScreensaverRing, { props: { size: 'badge' } }).classes()).toContain(
'viz-ring-badge',
)
})
it('keeps the existing variants off the badge class', () => {
expect(mount(ScreensaverRing).classes()).not.toContain('viz-ring-badge')
expect(mount(ScreensaverRing, { props: { size: 'compact' } }).classes()).not.toContain(
'viz-ring-badge',
)
})
it('renders one segment per segmentCount, defaulting to 48', () => {
expect(mount(ScreensaverRing).findAll('.viz-segment')).toHaveLength(48)
expect(
mount(ScreensaverRing, { props: { segmentCount: 12 } }).findAll('.viz-segment'),
).toHaveLength(12)
expect(
mount(ScreensaverRing, { props: { size: 'badge', segmentCount: 24 } }).findAll('.viz-segment'),
).toHaveLength(24)
})
})
@@ -0,0 +1,166 @@
// 02-05 Task 2: bounds MeshMap.vue's Leaflet instance across Mesh.vue's
// activate/deactivate lifecycle (established in 02-04 — Mesh.vue joined
// KEEP_ALIVE_PATHS). Kept in its own file rather than
// src/views/__tests__/meshTabCache.test.ts because `vi.mock('@/stores/mesh')`
// and `vi.mock('leaflet')` are hoisted file-wide and would otherwise clobber
// that file's need for the REAL mesh/transport stores (mirrors the
// MarketplaceRefresh.test.ts precedent set in 02-02 for the same class of
// vi.mock-hoisting conflict — Rule 1/3 auto-fix, documented in
// 02-05-SUMMARY.md).
//
// FLAGGED (see 02-05-SUMMARY.md): RESEARCH.md's premise that Mesh.vue owns a
// live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation
// in the codebase belongs to NetworkMap3D.vue (Federation.vue's graph, out of
// this plan's scope). This file therefore only covers the Leaflet map's
// activate/deactivate lifecycle — the D3-specific truths from the plan are
// vacuously satisfied (there is nothing to leak).
import { flushPromises, mount } from '@vue/test-utils'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import MeshMap from '../MeshMap.vue'
const mapInstances: Array<{ invalidateSize: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> }> = []
let resizeObserverInstances: Array<{ observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = []
vi.mock('@/stores/mesh', () => ({
useMeshStore: () => ({
nodePositions: new Map(),
federatedPositions: new Map(),
peers: [],
status: null,
deadmanStatus: null,
updateSelfPosition: vi.fn(),
}),
}))
vi.mock('leaflet', () => ({
default: {
map: vi.fn(() => {
const instance = { invalidateSize: vi.fn(), fitBounds: vi.fn(), remove: vi.fn(), setView: vi.fn() }
mapInstances.push(instance)
return instance
}),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
divIcon: vi.fn((opts: unknown) => opts),
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
polyline: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
},
}))
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
function mountMapHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(MeshMap, { key: 'map' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host)
}
async function toggleTab(wrapper: ReturnType<typeof mountMapHost>, show: boolean) {
;(wrapper.vm as unknown as { show: boolean }).show = show
await wrapper.vm.$nextTick()
}
describe('Mesh graphics lifecycle (Task 2): Leaflet map (MeshMap.vue)', () => {
beforeEach(() => {
vi.useFakeTimers()
mapInstances.length = 0
resizeObserverInstances = []
vi.stubGlobal('ResizeObserver', vi.fn(() => {
const inst = { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() }
resizeObserverInstances.push(inst)
return inst
}))
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
height: 200, width: 200, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => undefined,
} as DOMRect)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('entering and leaving the tab three times constructs exactly one map instance', async () => {
const wrapper = mountMapHost()
await flushPromises()
vi.advanceTimersByTime(300) // the onMounted-arm's fallback initMap()
await flushPromises()
expect(mapInstances.length).toBe(1)
for (let i = 0; i < 3; i++) {
await toggleTab(wrapper, false)
await toggleTab(wrapper, true)
await flushPromises()
}
expect(mapInstances.length).toBe(1)
wrapper.unmount()
})
it('reactivating calls the Leaflet map size-invalidation so a map laid out off screen re-tiles at its real size', async () => {
const wrapper = mountMapHost()
await flushPromises()
vi.advanceTimersByTime(300)
await flushPromises()
const instance = mapInstances[0]!
instance.invalidateSize.mockClear()
await toggleTab(wrapper, false)
await toggleTab(wrapper, true)
await flushPromises()
expect(instance.invalidateSize).toHaveBeenCalled()
wrapper.unmount()
})
it('a window resize listener is removed on deactivate and re-added exactly once on activate', async () => {
const addSpy = vi.spyOn(window, 'addEventListener')
const removeSpy = vi.spyOn(window, 'removeEventListener')
const wrapper = mountMapHost()
await flushPromises()
// armMapVisibility's own idempotent idiom (remove-then-add) means mount
// itself issues one defensive remove alongside the one add — baseline
// both counts here rather than assuming remove starts at zero.
const addCountAtMount = addSpy.mock.calls.filter((c) => c[0] === 'resize').length
const removeCountAtMount = removeSpy.mock.calls.filter((c) => c[0] === 'resize').length
expect(addCountAtMount).toBe(1)
await toggleTab(wrapper, false)
expect(removeSpy.mock.calls.filter((c) => c[0] === 'resize').length).toBe(removeCountAtMount + 1)
await toggleTab(wrapper, true)
expect(addSpy.mock.calls.filter((c) => c[0] === 'resize').length).toBe(addCountAtMount + 1) // once more, not twice
wrapper.unmount()
})
it('deactivating disconnects the ResizeObserver; reactivating re-observes the container', async () => {
const wrapper = mountMapHost()
await flushPromises()
const inst = resizeObserverInstances[0]!
expect(inst.observe).toHaveBeenCalledTimes(1)
await toggleTab(wrapper, false)
expect(inst.disconnect).toHaveBeenCalledTimes(1)
await toggleTab(wrapper, true)
expect(inst.observe).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
})