Archipelago — open-source initial import
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user