Add configuration files and update project documentation
- Introduced .editorconfig for consistent coding styles across the project. - Added .golangci.yml for Go linting configuration. - Updated AGENTS.md to clarify project structure and components. - Enhanced CONTRIBUTING.md with Makefile usage for common tasks. - Updated Dockerfiles to use Go 1.24 and improved build instructions. - Refined README.md and deployment documentation for clarity. - Added testing documentation in testing.md for backend and frontend tests. - Introduced Makefile for streamlined development commands and tasks.
This commit is contained in:
@@ -9,75 +9,83 @@ export interface CharacterData {
|
||||
map: number
|
||||
}
|
||||
|
||||
export interface MapViewRef {
|
||||
export interface CharacterMapViewRef {
|
||||
map: L.Map
|
||||
mapid: number
|
||||
markerLayer?: L.LayerGroup
|
||||
}
|
||||
|
||||
export class Character {
|
||||
export interface MapCharacter {
|
||||
id: number
|
||||
name: string
|
||||
position: { x: number; y: number }
|
||||
type: string
|
||||
id: number
|
||||
map: number
|
||||
marker: L.Marker | null = null
|
||||
text: string
|
||||
value: number
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
|
||||
constructor(characterData: CharacterData) {
|
||||
this.name = characterData.name
|
||||
this.position = characterData.position
|
||||
this.type = characterData.type
|
||||
this.id = characterData.id
|
||||
this.map = characterData.map
|
||||
this.text = this.name
|
||||
this.value = this.id
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return `${this.name}`
|
||||
}
|
||||
|
||||
remove(mapview: MapViewRef): void {
|
||||
if (this.marker) {
|
||||
const layer = mapview.markerLayer ?? mapview.map
|
||||
layer.removeLayer(this.marker)
|
||||
this.marker = null
|
||||
}
|
||||
}
|
||||
|
||||
add(mapview: MapViewRef): void {
|
||||
if (this.map === mapview.mapid) {
|
||||
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||
this.marker = L.marker(position, { title: this.name })
|
||||
this.marker.on('click', this.callCallback.bind(this))
|
||||
const targetLayer = mapview.markerLayer ?? mapview.map
|
||||
this.marker.addTo(targetLayer)
|
||||
}
|
||||
}
|
||||
|
||||
update(mapview: MapViewRef, updated: CharacterData): void {
|
||||
if (this.map !== updated.map) {
|
||||
this.remove(mapview)
|
||||
}
|
||||
this.map = updated.map
|
||||
this.position = updated.position
|
||||
if (!this.marker && this.map === mapview.mapid) {
|
||||
this.add(mapview)
|
||||
}
|
||||
if (this.marker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
this.marker.setLatLng(position)
|
||||
}
|
||||
}
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
this.onClick = callback
|
||||
}
|
||||
|
||||
callCallback(e: L.LeafletMouseEvent): void {
|
||||
if (this.onClick != null) this.onClick(e)
|
||||
}
|
||||
leafletMarker: L.Marker | null
|
||||
remove: (mapview: CharacterMapViewRef) => void
|
||||
add: (mapview: CharacterMapViewRef) => void
|
||||
update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void
|
||||
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
}
|
||||
|
||||
export function createCharacter(data: CharacterData): MapCharacter {
|
||||
let leafletMarker: L.Marker | null = null
|
||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
|
||||
const character: MapCharacter = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
position: { ...data.position },
|
||||
type: data.type,
|
||||
map: data.map,
|
||||
text: data.name,
|
||||
value: data.id,
|
||||
|
||||
get leafletMarker() {
|
||||
return leafletMarker
|
||||
},
|
||||
|
||||
remove(mapview: CharacterMapViewRef): void {
|
||||
if (leafletMarker) {
|
||||
const layer = mapview.markerLayer ?? mapview.map
|
||||
layer.removeLayer(leafletMarker)
|
||||
leafletMarker = null
|
||||
}
|
||||
},
|
||||
|
||||
add(mapview: CharacterMapViewRef): void {
|
||||
if (character.map === mapview.mapid) {
|
||||
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||
leafletMarker = L.marker(position, { title: character.name })
|
||||
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||
if (onClick) onClick(e)
|
||||
})
|
||||
const targetLayer = mapview.markerLayer ?? mapview.map
|
||||
leafletMarker.addTo(targetLayer)
|
||||
}
|
||||
},
|
||||
|
||||
update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void {
|
||||
if (character.map !== updated.map) {
|
||||
character.remove(mapview)
|
||||
}
|
||||
character.map = updated.map
|
||||
character.position = { ...updated.position }
|
||||
if (!leafletMarker && character.map === mapview.mapid) {
|
||||
character.add(mapview)
|
||||
}
|
||||
if (leafletMarker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
leafletMarker.setLatLng(position)
|
||||
}
|
||||
},
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
onClick = callback
|
||||
},
|
||||
}
|
||||
|
||||
return character
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ export const HnHDefaultZoom = 6
|
||||
/** When scaleFactor exceeds this, render one label per tile instead of a full grid (avoids 100k+ DOM nodes at zoom 1). */
|
||||
const GRID_COORD_SCALE_FACTOR_THRESHOLD = 8
|
||||
|
||||
export interface GridCoordLayerOptions extends L.GridLayerOptions {
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export const GridCoordLayer = L.GridLayer.extend({
|
||||
options: {
|
||||
visible: true,
|
||||
@@ -64,7 +68,7 @@ export const GridCoordLayer = L.GridLayer.extend({
|
||||
}
|
||||
return element
|
||||
},
|
||||
}) as unknown as new (options?: L.GridLayerOptions) => L.GridLayer
|
||||
}) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
|
||||
|
||||
export const ImageIcon = L.Icon.extend({
|
||||
options: {
|
||||
|
||||
@@ -16,99 +16,111 @@ export interface MapViewRef {
|
||||
markerLayer: L.LayerGroup
|
||||
}
|
||||
|
||||
export interface MapMarker {
|
||||
id: number
|
||||
position: { x: number; y: number }
|
||||
name: string
|
||||
image: string
|
||||
type: string
|
||||
text: string
|
||||
value: number
|
||||
hidden: boolean
|
||||
map: number
|
||||
leafletMarker: L.Marker | null
|
||||
remove: (mapview: MapViewRef) => void
|
||||
add: (mapview: MapViewRef) => void
|
||||
update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void
|
||||
jumpTo: (map: L.Map) => void
|
||||
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
}
|
||||
|
||||
function detectType(name: string): string {
|
||||
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
|
||||
if (name === 'custom') return 'custom'
|
||||
return name.substring('gfx/terobjs/mm/'.length)
|
||||
}
|
||||
|
||||
export class Marker {
|
||||
id: number
|
||||
position: { x: number; y: number }
|
||||
name: string
|
||||
image: string
|
||||
type: string
|
||||
marker: L.Marker | null = null
|
||||
text: string
|
||||
value: number
|
||||
hidden: boolean
|
||||
map: number
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
export function createMarker(data: MarkerData): MapMarker {
|
||||
let leafletMarker: L.Marker | null = null
|
||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
|
||||
constructor(markerData: MarkerData) {
|
||||
this.id = markerData.id
|
||||
this.position = markerData.position
|
||||
this.name = markerData.name
|
||||
this.image = markerData.image
|
||||
this.type = detectType(this.image)
|
||||
this.text = this.name
|
||||
this.value = this.id
|
||||
this.hidden = markerData.hidden
|
||||
this.map = markerData.map
|
||||
}
|
||||
const marker: MapMarker = {
|
||||
id: data.id,
|
||||
position: { ...data.position },
|
||||
name: data.name,
|
||||
image: data.image,
|
||||
type: detectType(data.image),
|
||||
text: data.name,
|
||||
value: data.id,
|
||||
hidden: data.hidden,
|
||||
map: data.map,
|
||||
|
||||
remove(_mapview: MapViewRef): void {
|
||||
if (this.marker) {
|
||||
this.marker.remove()
|
||||
this.marker = null
|
||||
}
|
||||
}
|
||||
get leafletMarker() {
|
||||
return leafletMarker
|
||||
},
|
||||
|
||||
add(mapview: MapViewRef): void {
|
||||
if (!this.hidden) {
|
||||
let icon: L.Icon
|
||||
if (this.image === 'gfx/terobjs/mm/custom') {
|
||||
icon = new ImageIcon({
|
||||
iconUrl: 'gfx/terobjs/mm/custom.png',
|
||||
iconSize: [21, 23],
|
||||
iconAnchor: [11, 21],
|
||||
popupAnchor: [1, 3],
|
||||
tooltipAnchor: [1, 3],
|
||||
})
|
||||
} else {
|
||||
icon = new ImageIcon({ iconUrl: `${this.image}.png`, iconSize: [32, 32] })
|
||||
remove(_mapview: MapViewRef): void {
|
||||
if (leafletMarker) {
|
||||
leafletMarker.remove()
|
||||
leafletMarker = null
|
||||
}
|
||||
},
|
||||
|
||||
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||
this.marker = L.marker(position, { icon, title: this.name })
|
||||
this.marker.addTo(mapview.markerLayer)
|
||||
this.marker.on('click', this.callClickCallback.bind(this))
|
||||
this.marker.on('contextmenu', this.callContextCallback.bind(this))
|
||||
}
|
||||
add(mapview: MapViewRef): void {
|
||||
if (!marker.hidden) {
|
||||
let icon: L.Icon
|
||||
if (marker.image === 'gfx/terobjs/mm/custom') {
|
||||
icon = new ImageIcon({
|
||||
iconUrl: 'gfx/terobjs/mm/custom.png',
|
||||
iconSize: [21, 23],
|
||||
iconAnchor: [11, 21],
|
||||
popupAnchor: [1, 3],
|
||||
tooltipAnchor: [1, 3],
|
||||
})
|
||||
} else {
|
||||
icon = new ImageIcon({ iconUrl: `${marker.image}.png`, iconSize: [32, 32] })
|
||||
}
|
||||
|
||||
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||
leafletMarker = L.marker(position, { icon, title: marker.name })
|
||||
leafletMarker.addTo(mapview.markerLayer)
|
||||
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||
if (onClick) onClick(e)
|
||||
})
|
||||
leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => {
|
||||
if (onContext) onContext(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
update(mapview: MapViewRef, updated: MarkerData | MapMarker): void {
|
||||
marker.position = { ...updated.position }
|
||||
marker.name = updated.name
|
||||
marker.hidden = updated.hidden
|
||||
marker.map = updated.map
|
||||
if (leafletMarker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
leafletMarker.setLatLng(position)
|
||||
}
|
||||
},
|
||||
|
||||
jumpTo(map: L.Map): void {
|
||||
if (leafletMarker) {
|
||||
const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||
leafletMarker.setLatLng(position)
|
||||
}
|
||||
},
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
onClick = callback
|
||||
},
|
||||
|
||||
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
onContext = callback
|
||||
},
|
||||
}
|
||||
|
||||
update(mapview: MapViewRef, updated: MarkerData): void {
|
||||
this.position = updated.position
|
||||
this.name = updated.name
|
||||
this.hidden = updated.hidden
|
||||
this.map = updated.map
|
||||
if (this.marker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
this.marker.setLatLng(position)
|
||||
}
|
||||
}
|
||||
|
||||
jumpTo(map: L.Map): void {
|
||||
if (this.marker) {
|
||||
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||
this.marker.setLatLng(position)
|
||||
}
|
||||
}
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
this.onClick = callback
|
||||
}
|
||||
|
||||
callClickCallback(e: L.LeafletMouseEvent): void {
|
||||
if (this.onClick != null) this.onClick(e)
|
||||
}
|
||||
|
||||
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
this.onContext = callback
|
||||
}
|
||||
|
||||
callContextCallback(e: L.LeafletMouseEvent): void {
|
||||
if (this.onContext != null) this.onContext(e)
|
||||
}
|
||||
return marker
|
||||
}
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
/**
|
||||
* Elements should have unique field "id"
|
||||
*/
|
||||
export interface Identifiable {
|
||||
id: number | string
|
||||
}
|
||||
|
||||
export class UniqueList<T extends Identifiable> {
|
||||
elements: Record<string, T> = {}
|
||||
|
||||
update(
|
||||
dataList: T[],
|
||||
addCallback?: (it: T) => void,
|
||||
removeCallback?: (it: T) => void,
|
||||
updateCallback?: (oldElement: T, newElement: T) => void
|
||||
): void {
|
||||
const elementsToAdd = dataList.filter((it) => this.elements[String(it.id)] === undefined)
|
||||
const elementsToRemove: T[] = []
|
||||
for (const id of Object.keys(this.elements)) {
|
||||
if (dataList.find((up) => String(up.id) === id) === undefined) {
|
||||
const el = this.elements[id]
|
||||
if (el) elementsToRemove.push(el)
|
||||
}
|
||||
}
|
||||
if (removeCallback) {
|
||||
elementsToRemove.forEach((it) => removeCallback(it))
|
||||
}
|
||||
if (updateCallback) {
|
||||
dataList.forEach((newElement) => {
|
||||
const oldElement = this.elements[String(newElement.id)]
|
||||
if (oldElement) {
|
||||
updateCallback(oldElement, newElement)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (addCallback) {
|
||||
elementsToAdd.forEach((it) => addCallback(it))
|
||||
}
|
||||
elementsToRemove.forEach((it) => delete this.elements[String(it.id)])
|
||||
elementsToAdd.forEach((it) => (this.elements[String(it.id)] = it))
|
||||
}
|
||||
|
||||
getElements(): T[] {
|
||||
return Object.values(this.elements)
|
||||
}
|
||||
|
||||
byId(id: number | string): T | undefined {
|
||||
return this.elements[String(id)]
|
||||
}
|
||||
export interface UniqueList<T extends Identifiable> {
|
||||
elements: Record<string, T>
|
||||
}
|
||||
|
||||
export function createUniqueList<T extends Identifiable>(): UniqueList<T> {
|
||||
return { elements: {} }
|
||||
}
|
||||
|
||||
export function uniqueListUpdate<T extends Identifiable>(
|
||||
list: UniqueList<T>,
|
||||
dataList: T[],
|
||||
addCallback?: (it: T) => void,
|
||||
removeCallback?: (it: T) => void,
|
||||
updateCallback?: (oldElement: T, newElement: T) => void
|
||||
): void {
|
||||
const elementsToAdd = dataList.filter((it) => list.elements[String(it.id)] === undefined)
|
||||
const elementsToRemove: T[] = []
|
||||
for (const id of Object.keys(list.elements)) {
|
||||
if (dataList.find((up) => String(up.id) === id) === undefined) {
|
||||
const el = list.elements[id]
|
||||
if (el) elementsToRemove.push(el)
|
||||
}
|
||||
}
|
||||
if (removeCallback) {
|
||||
elementsToRemove.forEach((it) => removeCallback(it))
|
||||
}
|
||||
if (updateCallback) {
|
||||
dataList.forEach((newElement) => {
|
||||
const oldElement = list.elements[String(newElement.id)]
|
||||
if (oldElement) {
|
||||
updateCallback(oldElement, newElement)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (addCallback) {
|
||||
elementsToAdd.forEach((it) => addCallback(it))
|
||||
}
|
||||
elementsToRemove.forEach((it) => delete list.elements[String(it.id)])
|
||||
elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it))
|
||||
}
|
||||
|
||||
export function uniqueListGetElements<T extends Identifiable>(list: UniqueList<T>): T[] {
|
||||
return Object.values(list.elements)
|
||||
}
|
||||
|
||||
export function uniqueListById<T extends Identifiable>(list: UniqueList<T>, id: number | string): T | undefined {
|
||||
return list.elements[String(id)]
|
||||
}
|
||||
|
||||
107
frontend-nuxt/lib/__tests__/Character.test.ts
Normal file
107
frontend-nuxt/lib/__tests__/Character.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
const markerMock = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return {
|
||||
default: { marker: vi.fn(() => markerMock) },
|
||||
marker: vi.fn(() => markerMock),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('~/lib/LeafletCustomTypes', () => ({
|
||||
HnHMaxZoom: 6,
|
||||
}))
|
||||
|
||||
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
|
||||
|
||||
function makeCharData(overrides: Partial<CharacterData> = {}): 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 import('leaflet').Map,
|
||||
mapid,
|
||||
markerLayer: {
|
||||
removeLayer: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
} as unknown as import('leaflet').LayerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createCharacter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('creates character with correct properties', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
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())
|
||||
expect(char.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('add creates marker when character is on correct map', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(mapview.map.unproject).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('add does not create marker for different map', () => {
|
||||
const char = createCharacter(makeCharData({ map: 2 }))
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(mapview.map.unproject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update changes position and map', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
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())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.remove(mapview) // should not throw
|
||||
expect(char.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('setClickCallback works', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
const cb = vi.fn()
|
||||
char.setClickCallback(cb)
|
||||
})
|
||||
})
|
||||
139
frontend-nuxt/lib/__tests__/Marker.test.ts
Normal file
139
frontend-nuxt/lib/__tests__/Marker.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
const markerMock = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon: class {},
|
||||
},
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon: class {},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('~/lib/LeafletCustomTypes', () => ({
|
||||
HnHMaxZoom: 6,
|
||||
ImageIcon: class {
|
||||
constructor(_opts: Record<string, unknown>) {}
|
||||
},
|
||||
}))
|
||||
|
||||
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
|
||||
|
||||
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
|
||||
return {
|
||||
id: 1,
|
||||
position: { x: 100, y: 200 },
|
||||
name: 'Tower',
|
||||
image: 'gfx/terobjs/mm/tower',
|
||||
hidden: false,
|
||||
map: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMapViewRef(): MapViewRef {
|
||||
return {
|
||||
map: {
|
||||
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
} as unknown as import('leaflet').Map,
|
||||
mapid: 1,
|
||||
markerLayer: {
|
||||
removeLayer: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
} as unknown as import('leaflet').LayerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createMarker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('creates a marker with correct properties', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
expect(marker.id).toBe(1)
|
||||
expect(marker.name).toBe('Tower')
|
||||
expect(marker.position).toEqual({ x: 100, y: 200 })
|
||||
expect(marker.image).toBe('gfx/terobjs/mm/tower')
|
||||
expect(marker.hidden).toBe(false)
|
||||
expect(marker.map).toBe(1)
|
||||
expect(marker.value).toBe(1)
|
||||
expect(marker.text).toBe('Tower')
|
||||
})
|
||||
|
||||
it('detects quest type', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }))
|
||||
expect(marker.type).toBe('quest')
|
||||
})
|
||||
|
||||
it('detects quest type for bumling', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }))
|
||||
expect(marker.type).toBe('quest')
|
||||
})
|
||||
|
||||
it('detects custom type', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'custom' }))
|
||||
expect(marker.type).toBe('custom')
|
||||
})
|
||||
|
||||
it('extracts type from gfx path', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }))
|
||||
expect(marker.type).toBe('village')
|
||||
})
|
||||
|
||||
it('starts with null leaflet marker', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
expect(marker.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('add creates a leaflet marker for non-hidden markers', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const mapview = makeMapViewRef()
|
||||
marker.add(mapview)
|
||||
expect(mapview.map.unproject).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('add does nothing for hidden markers', () => {
|
||||
const marker = createMarker(makeMarkerData({ hidden: true }))
|
||||
const mapview = makeMapViewRef()
|
||||
marker.add(mapview)
|
||||
expect(mapview.map.unproject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update changes position and name', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const mapview = makeMapViewRef()
|
||||
|
||||
marker.update(mapview, {
|
||||
...makeMarkerData(),
|
||||
position: { x: 300, y: 400 },
|
||||
name: 'Castle',
|
||||
})
|
||||
|
||||
expect(marker.position).toEqual({ x: 300, y: 400 })
|
||||
expect(marker.name).toBe('Castle')
|
||||
})
|
||||
|
||||
it('setClickCallback and setContextMenu work', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const clickCb = vi.fn()
|
||||
const contextCb = vi.fn()
|
||||
|
||||
marker.setClickCallback(clickCb)
|
||||
marker.setContextMenu(contextCb)
|
||||
})
|
||||
|
||||
it('remove on a marker without leaflet marker does nothing', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const mapview = makeMapViewRef()
|
||||
marker.remove(mapview) // should not throw
|
||||
expect(marker.leafletMarker).toBeNull()
|
||||
})
|
||||
})
|
||||
134
frontend-nuxt/lib/__tests__/UniqueList.test.ts
Normal file
134
frontend-nuxt/lib/__tests__/UniqueList.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
createUniqueList,
|
||||
uniqueListUpdate,
|
||||
uniqueListGetElements,
|
||||
uniqueListById,
|
||||
} from '../UniqueList'
|
||||
|
||||
interface Item {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
describe('createUniqueList', () => {
|
||||
it('creates an empty list', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
expect(list.elements).toEqual({})
|
||||
expect(uniqueListGetElements(list)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueListUpdate', () => {
|
||||
it('adds new elements', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
const addCb = vi.fn()
|
||||
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }], addCb)
|
||||
|
||||
expect(addCb).toHaveBeenCalledTimes(2)
|
||||
expect(uniqueListGetElements(list)).toHaveLength(2)
|
||||
expect(uniqueListById(list, 1)).toEqual({ id: 1, name: 'a' })
|
||||
expect(uniqueListById(list, 2)).toEqual({ id: 2, name: 'b' })
|
||||
})
|
||||
|
||||
it('removes elements no longer present', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
const removeCb = vi.fn()
|
||||
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }], undefined, removeCb)
|
||||
|
||||
expect(removeCb).toHaveBeenCalledTimes(1)
|
||||
expect(removeCb).toHaveBeenCalledWith({ id: 2, name: 'b' })
|
||||
expect(uniqueListGetElements(list)).toHaveLength(1)
|
||||
expect(uniqueListById(list, 2)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls update callback for existing elements', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
const updateCb = vi.fn()
|
||||
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'updated' }], undefined, undefined, updateCb)
|
||||
|
||||
expect(updateCb).toHaveBeenCalledTimes(1)
|
||||
expect(updateCb).toHaveBeenCalledWith({ id: 1, name: 'a' }, { id: 1, name: 'updated' })
|
||||
})
|
||||
|
||||
it('handles all callbacks together', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
const addCb = vi.fn()
|
||||
const removeCb = vi.fn()
|
||||
const updateCb = vi.fn()
|
||||
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'keep' }, { id: 2, name: 'remove' }])
|
||||
|
||||
uniqueListUpdate(
|
||||
list,
|
||||
[{ id: 1, name: 'kept' }, { id: 3, name: 'new' }],
|
||||
addCb,
|
||||
removeCb,
|
||||
updateCb,
|
||||
)
|
||||
|
||||
expect(addCb).toHaveBeenCalledTimes(1)
|
||||
expect(addCb).toHaveBeenCalledWith({ id: 3, name: 'new' })
|
||||
expect(removeCb).toHaveBeenCalledTimes(1)
|
||||
expect(removeCb).toHaveBeenCalledWith({ id: 2, name: 'remove' })
|
||||
expect(updateCb).toHaveBeenCalledTimes(1)
|
||||
expect(updateCb).toHaveBeenCalledWith({ id: 1, name: 'keep' }, { id: 1, name: 'kept' })
|
||||
})
|
||||
|
||||
it('works with string IDs', () => {
|
||||
interface StringItem {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
const list = createUniqueList<StringItem>()
|
||||
uniqueListUpdate(list, [{ id: 'abc', label: 'first' }])
|
||||
expect(uniqueListById(list, 'abc')).toEqual({ id: 'abc', label: 'first' })
|
||||
})
|
||||
|
||||
it('handles empty update', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
|
||||
|
||||
const removeCb = vi.fn()
|
||||
uniqueListUpdate(list, [], undefined, removeCb)
|
||||
|
||||
expect(removeCb).toHaveBeenCalledTimes(1)
|
||||
expect(uniqueListGetElements(list)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles update with no callbacks', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
|
||||
uniqueListUpdate(list, [{ id: 2, name: 'b' }])
|
||||
expect(uniqueListGetElements(list)).toHaveLength(1)
|
||||
expect(uniqueListById(list, 2)).toEqual({ id: 2, name: 'b' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueListGetElements', () => {
|
||||
it('returns all elements as array', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
|
||||
const elements = uniqueListGetElements(list)
|
||||
expect(elements).toHaveLength(2)
|
||||
expect(elements.map(e => e.id).sort()).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueListById', () => {
|
||||
it('finds element by id', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
uniqueListUpdate(list, [{ id: 42, name: 'target' }])
|
||||
expect(uniqueListById(list, 42)?.name).toBe('target')
|
||||
})
|
||||
|
||||
it('returns undefined for missing id', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
expect(uniqueListById(list, 999)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user