import { describe, it, expect, vi, beforeEach } from 'vitest' import type L from 'leaflet' import type { Map, LayerGroup } from 'leaflet' import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character' const { leafletMock } = vi.hoisted(() => { const markerMock = { on: vi.fn().mockReturnThis(), addTo: vi.fn().mockReturnThis(), setLatLng: vi.fn().mockReturnThis(), setIcon: vi.fn().mockReturnThis(), bindTooltip: vi.fn().mockReturnThis(), setTooltipContent: vi.fn().mockReturnThis(), getLatLng: vi.fn().mockReturnValue({ lat: 0, lng: 0 }), } const Icon = vi.fn().mockImplementation(function (this: unknown) { return {} }) const L = { marker: vi.fn(() => markerMock), Icon, point: vi.fn((x: number, y: number) => ({ x, y })), } return { leafletMock: L } }) vi.mock('leaflet', () => ({ __esModule: true, default: leafletMock, marker: leafletMock.marker, Icon: leafletMock.Icon, })) vi.mock('~/lib/LeafletCustomTypes', () => ({ HnHMaxZoom: 6, TileSize: 100, })) function getL(): L { return leafletMock as unknown as L } function makeCharData(overrides: Partial = {}): CharacterData { return { name: 'Hero', position: { x: 100, y: 200 }, type: 'player', id: 1, map: 1, ...overrides, } } function makeMapViewRef(mapid = 1): CharacterMapViewRef { return { map: { unproject: vi.fn(() => ({ lat: 0, lng: 0 })), removeLayer: vi.fn(), } as unknown as Map, mapid, markerLayer: { removeLayer: vi.fn(), addLayer: vi.fn(), } as unknown as LayerGroup, } } describe('createCharacter', () => { beforeEach(() => { vi.clearAllMocks() }) it('creates character with correct properties', () => { const char = createCharacter(makeCharData(), getL()) expect(char.id).toBe(1) expect(char.name).toBe('Hero') expect(char.position).toEqual({ x: 100, y: 200 }) expect(char.type).toBe('player') expect(char.map).toBe(1) expect(char.text).toBe('Hero') expect(char.value).toBe(1) }) it('starts with null leaflet marker', () => { const char = createCharacter(makeCharData(), getL()) expect(char.leafletMarker).toBeNull() }) it('add creates marker when character is on correct map', () => { const char = createCharacter(makeCharData(), getL()) const mapview = makeMapViewRef(1) char.add(mapview) expect(mapview.map.unproject).toHaveBeenCalled() }) it('add creates marker without title and binds Leaflet tooltip', () => { const char = createCharacter(makeCharData({ position: { x: 100, y: 200 } }), getL()) const mapview = makeMapViewRef(1) char.add(mapview) expect(leafletMock.marker).toHaveBeenCalledWith( expect.anything(), expect.not.objectContaining({ title: expect.anything() }) ) const marker = char.leafletMarker as { bindTooltip: ReturnType } expect(marker.bindTooltip).toHaveBeenCalledWith( 'Hero · 1, 2', expect.objectContaining({ direction: 'top', permanent: false }) ) }) it('add does not create marker for different map', () => { const char = createCharacter(makeCharData({ map: 2 }), getL()) const mapview = makeMapViewRef(1) char.add(mapview) expect(mapview.map.unproject).not.toHaveBeenCalled() }) it('update changes position and map', () => { const char = createCharacter(makeCharData(), getL()) const mapview = makeMapViewRef(1) char.update(mapview, { ...makeCharData(), position: { x: 300, y: 400 }, map: 2, }) expect(char.position).toEqual({ x: 300, y: 400 }) expect(char.map).toBe(2) }) it('remove on a character without leaflet marker does nothing', () => { const char = createCharacter(makeCharData(), getL()) const mapview = makeMapViewRef(1) char.remove(mapview) // should not throw expect(char.leafletMarker).toBeNull() }) it('setClickCallback works', () => { const char = createCharacter(makeCharData(), getL()) const cb = vi.fn() char.setClickCallback(cb) }) it('update with changed ownedByMe updates marker icon', () => { const char = createCharacter(makeCharData({ ownedByMe: false }), getL()) const mapview = makeMapViewRef(1) char.add(mapview) const marker = char.leafletMarker as { setIcon: ReturnType } expect(marker.setIcon).not.toHaveBeenCalled() char.update(mapview, makeCharData({ ownedByMe: true })) expect(marker.setIcon).toHaveBeenCalledTimes(1) }) it('update with position change updates tooltip content when marker exists', () => { const char = createCharacter(makeCharData(), getL()) const mapview = makeMapViewRef(1) char.add(mapview) const marker = char.leafletMarker as { setTooltipContent: ReturnType } marker.setTooltipContent.mockClear() char.update(mapview, makeCharData({ position: { x: 350, y: 450 } })) expect(marker.setTooltipContent).toHaveBeenCalledWith('Hero · 3, 4') }) it('remove cancels active position animation', () => { const cancelSpy = vi.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {}) let rafCallback: (() => void) | null = null vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb: (() => void) | (FrameRequestCallback)) => { rafCallback = typeof cb === 'function' ? cb : () => {} return 1 }) const char = createCharacter(makeCharData(), getL()) const mapview = makeMapViewRef(1) mapview.map.unproject = vi.fn(() => ({ lat: 1, lng: 1 })) char.add(mapview) const marker = char.leafletMarker as { getLatLng: ReturnType } marker.getLatLng.mockReturnValue({ lat: 0, lng: 0 }) char.update(mapview, makeCharData({ position: { x: 200, y: 200 } })) expect(rafCallback).not.toBeNull() cancelSpy.mockClear() char.remove(mapview) expect(cancelSpy).toHaveBeenCalledWith(1) cancelSpy.mockRestore() vi.restoreAllMocks() }) })