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:
2026-03-01 01:51:47 +03:00
parent 0466ff3087
commit 6529d7370e
92 changed files with 13411 additions and 8438 deletions

View File

@@ -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
}

View File

@@ -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: {

View File

@@ -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
}

View File

@@ -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)]
}

View 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)
})
})

View 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()
})
})

View 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()
})
})