Enhance API documentation and improve marker functionality
- Updated API documentation for clarity and consistency, including detailed descriptions of authentication and user account endpoints. - Added a new cave marker image to enhance visual representation in the frontend. - Implemented normalization for cave marker images during upload to ensure consistent storage format. - Expanded test coverage for client services, including new tests for marker uploads and image normalization.
This commit is contained in:
158
docs/api.md
158
docs/api.md
@@ -1,79 +1,79 @@
|
|||||||
# HTTP API
|
# HTTP API
|
||||||
|
|
||||||
The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login).
|
The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login).
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
- **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`.
|
- **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`.
|
||||||
- **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display).
|
- **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display).
|
||||||
- **POST /map/api/logout** — sign out (invalidates the session).
|
- **POST /map/api/logout** — sign out (invalidates the session).
|
||||||
- **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`.
|
- **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`.
|
||||||
|
|
||||||
### OAuth
|
### OAuth
|
||||||
|
|
||||||
- **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`.
|
- **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`.
|
||||||
- **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`).
|
- **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`).
|
||||||
- **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state.
|
- **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state.
|
||||||
|
|
||||||
## User account
|
## User account
|
||||||
|
|
||||||
- **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session.
|
- **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session.
|
||||||
- **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`.
|
- **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`.
|
||||||
- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`.
|
- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`.
|
||||||
|
|
||||||
## Map data
|
## Map data
|
||||||
|
|
||||||
- **GET /map/api/config** — client configuration (title, auths). Requires a session.
|
- **GET /map/api/config** — client configuration (title, auths). Requires a session.
|
||||||
- **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens.
|
- **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens.
|
||||||
- **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data).
|
- **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data).
|
||||||
- **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed).
|
- **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed).
|
||||||
|
|
||||||
## Admin (all endpoints below require `admin` permission)
|
## Admin (all endpoints below require `admin` permission)
|
||||||
|
|
||||||
- **GET /map/api/admin/users** — list of usernames.
|
- **GET /map/api/admin/users** — list of usernames.
|
||||||
- **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`.
|
- **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`.
|
||||||
- **GET /map/api/admin/users/:name** — user data.
|
- **GET /map/api/admin/users/:name** — user data.
|
||||||
- **DELETE /map/api/admin/users/:name** — delete a user.
|
- **DELETE /map/api/admin/users/:name** — delete a user.
|
||||||
- **GET /map/api/admin/settings** — settings (prefix, defaultHide, title).
|
- **GET /map/api/admin/settings** — settings (prefix, defaultHide, title).
|
||||||
- **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional).
|
- **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional).
|
||||||
- **GET /map/api/admin/maps** — list of maps for the admin panel.
|
- **GET /map/api/admin/maps** — list of maps for the admin panel.
|
||||||
- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority).
|
- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority).
|
||||||
- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility.
|
- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility.
|
||||||
- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database.
|
- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database.
|
||||||
- **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map.
|
- **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map.
|
||||||
- **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress.
|
- **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress.
|
||||||
- **GET /map/api/admin/export** — download data export (ZIP).
|
- **GET /map/api/admin/export** — download data export (ZIP).
|
||||||
- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers).
|
- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers).
|
||||||
- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`.
|
- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`.
|
||||||
- **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`.
|
- **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`.
|
||||||
- **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`.
|
- **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`.
|
||||||
|
|
||||||
## Game client
|
## Game client
|
||||||
|
|
||||||
The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication.
|
The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication.
|
||||||
|
|
||||||
- **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise.
|
- **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise.
|
||||||
- **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`.
|
- **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`.
|
||||||
- **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests.
|
- **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests.
|
||||||
- **POST /client/{token}/gridUpload** — upload a tile image (multipart).
|
- **POST /client/{token}/gridUpload** — upload a tile image (multipart).
|
||||||
- **POST /client/{token}/positionUpdate** — update character positions.
|
- **POST /client/{token}/positionUpdate** — update character positions.
|
||||||
- **POST /client/{token}/markerUpdate** — upload markers.
|
- **POST /client/{token}/markerUpdate** — upload markers.
|
||||||
|
|
||||||
## SSE (Server-Sent Events)
|
## SSE (Server-Sent Events)
|
||||||
|
|
||||||
- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache.
|
- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache.
|
||||||
|
|
||||||
## Tile images
|
## Tile images
|
||||||
|
|
||||||
- **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist.
|
- **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist.
|
||||||
|
|
||||||
## Response codes
|
## Response codes
|
||||||
|
|
||||||
- **200** — success.
|
- **200** — success.
|
||||||
- **400** — bad request (wrong method, body, or parameters).
|
- **400** — bad request (wrong method, body, or parameters).
|
||||||
- **401** — unauthorized (missing or invalid session).
|
- **401** — unauthorized (missing or invalid session).
|
||||||
- **403** — forbidden (insufficient permissions).
|
- **403** — forbidden (insufficient permissions).
|
||||||
- **404** — not found.
|
- **404** — not found.
|
||||||
- **500** — internal error.
|
- **500** — internal error.
|
||||||
|
|
||||||
Error format: JSON body `{"error": "message", "code": "CODE"}`.
|
Error format: JSON body `{"error": "message", "code": "CODE"}`.
|
||||||
|
|||||||
@@ -1,159 +1,165 @@
|
|||||||
import type L from 'leaflet'
|
import type L from 'leaflet'
|
||||||
import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes'
|
import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes'
|
||||||
|
|
||||||
export interface MarkerData {
|
export interface MarkerData {
|
||||||
id: number
|
id: number
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
name: string
|
name: string
|
||||||
image: string
|
image: string
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
map: number
|
map: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapViewRef {
|
export interface MapViewRef {
|
||||||
map: L.Map
|
map: L.Map
|
||||||
mapid: number
|
mapid: number
|
||||||
markerLayer: L.LayerGroup
|
markerLayer: L.LayerGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapMarker {
|
export interface MapMarker {
|
||||||
id: number
|
id: number
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
name: string
|
name: string
|
||||||
image: string
|
image: string
|
||||||
type: string
|
type: string
|
||||||
text: string
|
text: string
|
||||||
value: number
|
value: number
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
map: number
|
map: number
|
||||||
leafletMarker: L.Marker | null
|
leafletMarker: L.Marker | null
|
||||||
remove: (mapview: MapViewRef) => void
|
remove: (mapview: MapViewRef) => void
|
||||||
add: (mapview: MapViewRef) => void
|
add: (mapview: MapViewRef) => void
|
||||||
update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void
|
update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void
|
||||||
jumpTo: (map: L.Map) => void
|
jumpTo: (map: L.Map) => void
|
||||||
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||||
setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void
|
setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectType(name: string): string {
|
function detectType(name: string): string {
|
||||||
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
|
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
|
||||||
if (name === 'custom') return 'custom'
|
if (name === 'custom') return 'custom'
|
||||||
return name.substring('gfx/terobjs/mm/'.length)
|
return name.substring('gfx/terobjs/mm/'.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarkerIconOptions {
|
export interface MarkerIconOptions {
|
||||||
/** Resolves relative icon path to absolute URL (e.g. with app base path). */
|
/** Resolves relative icon path to absolute URL (e.g. with app base path). */
|
||||||
resolveIconUrl: (path: string) => string
|
resolveIconUrl: (path: string) => string
|
||||||
/** Optional fallback URL when the icon image fails to load. */
|
/** Optional fallback URL when the icon image fails to load. */
|
||||||
fallbackIconUrl?: string
|
fallbackIconUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LeafletApi = L
|
export type LeafletApi = L
|
||||||
|
|
||||||
export function createMarker(
|
export function createMarker(
|
||||||
data: MarkerData,
|
data: MarkerData,
|
||||||
iconOptions: MarkerIconOptions | undefined,
|
iconOptions: MarkerIconOptions | undefined,
|
||||||
L: LeafletApi
|
L: LeafletApi
|
||||||
): MapMarker {
|
): MapMarker {
|
||||||
let leafletMarker: L.Marker | null = null
|
let leafletMarker: L.Marker | null = null
|
||||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||||
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||||
|
|
||||||
const marker: MapMarker = {
|
const marker: MapMarker = {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
position: { ...data.position },
|
position: { ...data.position },
|
||||||
name: data.name,
|
name: data.name,
|
||||||
image: data.image,
|
image: data.image,
|
||||||
type: detectType(data.image),
|
type: detectType(data.image),
|
||||||
text: data.name,
|
text: data.name,
|
||||||
value: data.id,
|
value: data.id,
|
||||||
hidden: data.hidden,
|
hidden: data.hidden,
|
||||||
map: data.map,
|
map: data.map,
|
||||||
|
|
||||||
get leafletMarker() {
|
get leafletMarker() {
|
||||||
return leafletMarker
|
return leafletMarker
|
||||||
},
|
},
|
||||||
|
|
||||||
remove(_mapview: MapViewRef): void {
|
remove(_mapview: MapViewRef): void {
|
||||||
if (leafletMarker) {
|
if (leafletMarker) {
|
||||||
leafletMarker.remove()
|
leafletMarker.remove()
|
||||||
leafletMarker = null
|
leafletMarker = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
add(mapview: MapViewRef): void {
|
add(mapview: MapViewRef): void {
|
||||||
if (!marker.hidden) {
|
if (!marker.hidden) {
|
||||||
const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path)
|
const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path)
|
||||||
const fallback = iconOptions?.fallbackIconUrl
|
const fallback = iconOptions?.fallbackIconUrl
|
||||||
let icon: L.Icon
|
const iconUrl =
|
||||||
if (marker.image === 'gfx/terobjs/mm/custom') {
|
marker.name === 'Cave' && marker.image === 'gfx/terobjs/mm/custom'
|
||||||
icon = new ImageIcon({
|
? resolve('gfx/terobjs/mm/cave.png')
|
||||||
iconUrl: resolve('gfx/terobjs/mm/custom.png'),
|
: marker.image === 'gfx/terobjs/mm/custom'
|
||||||
iconSize: [21, 23],
|
? resolve('gfx/terobjs/mm/custom.png')
|
||||||
iconAnchor: [11, 21],
|
: resolve(`${marker.image}.png`)
|
||||||
popupAnchor: [1, 3],
|
let icon: L.Icon
|
||||||
tooltipAnchor: [1, 3],
|
if (marker.image === 'gfx/terobjs/mm/custom' && marker.name !== 'Cave') {
|
||||||
fallbackIconUrl: fallback,
|
icon = new ImageIcon({
|
||||||
})
|
iconUrl,
|
||||||
} else {
|
iconSize: [21, 23],
|
||||||
icon = new ImageIcon({
|
iconAnchor: [11, 21],
|
||||||
iconUrl: resolve(`${marker.image}.png`),
|
popupAnchor: [1, 3],
|
||||||
iconSize: [32, 32],
|
tooltipAnchor: [1, 3],
|
||||||
fallbackIconUrl: fallback,
|
fallbackIconUrl: fallback,
|
||||||
})
|
})
|
||||||
}
|
} else {
|
||||||
|
icon = new ImageIcon({
|
||||||
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
iconUrl,
|
||||||
leafletMarker = L.marker(position, { icon })
|
iconSize: [32, 32],
|
||||||
const gridX = Math.floor(marker.position.x / TileSize)
|
fallbackIconUrl: fallback,
|
||||||
const gridY = Math.floor(marker.position.y / TileSize)
|
})
|
||||||
const tooltipContent = `${marker.name} · ${gridX}, ${gridY}`
|
}
|
||||||
leafletMarker.bindTooltip(tooltipContent, {
|
|
||||||
direction: 'top',
|
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||||
permanent: false,
|
leafletMarker = L.marker(position, { icon })
|
||||||
offset: L.point(0, -14),
|
const gridX = Math.floor(marker.position.x / TileSize)
|
||||||
})
|
const gridY = Math.floor(marker.position.y / TileSize)
|
||||||
leafletMarker.addTo(mapview.markerLayer)
|
const tooltipContent = `${marker.name} · ${gridX}, ${gridY}`
|
||||||
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
|
leafletMarker.bindTooltip(tooltipContent, {
|
||||||
if (markerEl) markerEl.setAttribute('aria-label', marker.name)
|
direction: 'top',
|
||||||
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
permanent: false,
|
||||||
if (onClick) onClick(e)
|
offset: L.point(0, -14),
|
||||||
})
|
})
|
||||||
leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => {
|
leafletMarker.addTo(mapview.markerLayer)
|
||||||
if (onContext) onContext(e)
|
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
|
||||||
})
|
if (markerEl) markerEl.setAttribute('aria-label', marker.name)
|
||||||
}
|
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
},
|
if (onClick) onClick(e)
|
||||||
|
})
|
||||||
update(mapview: MapViewRef, updated: MarkerData | MapMarker): void {
|
leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => {
|
||||||
marker.position = { ...updated.position }
|
if (onContext) onContext(e)
|
||||||
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)
|
update(mapview: MapViewRef, updated: MarkerData | MapMarker): void {
|
||||||
leafletMarker.setLatLng(position)
|
marker.position = { ...updated.position }
|
||||||
const gridX = Math.floor(updated.position.x / TileSize)
|
marker.name = updated.name
|
||||||
const gridY = Math.floor(updated.position.y / TileSize)
|
marker.hidden = updated.hidden
|
||||||
leafletMarker.setTooltipContent(`${marker.name} · ${gridX}, ${gridY}`)
|
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 {
|
const gridX = Math.floor(updated.position.x / TileSize)
|
||||||
if (leafletMarker) {
|
const gridY = Math.floor(updated.position.y / TileSize)
|
||||||
const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
leafletMarker.setTooltipContent(`${marker.name} · ${gridX}, ${gridY}`)
|
||||||
leafletMarker.setLatLng(position)
|
}
|
||||||
}
|
},
|
||||||
},
|
|
||||||
|
jumpTo(map: L.Map): void {
|
||||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
if (leafletMarker) {
|
||||||
onClick = callback
|
const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||||
},
|
leafletMarker.setLatLng(position)
|
||||||
|
}
|
||||||
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
|
},
|
||||||
onContext = callback
|
|
||||||
},
|
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||||
}
|
onClick = callback
|
||||||
|
},
|
||||||
return marker
|
|
||||||
}
|
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||||
|
onContext = callback
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return marker
|
||||||
|
}
|
||||||
|
|||||||
BIN
frontend-nuxt/public/gfx/terobjs/mm/cave.png
Normal file
BIN
frontend-nuxt/public/gfx/terobjs/mm/cave.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,93 +1,121 @@
|
|||||||
package services_test
|
package services_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/andyleap/hnh-map/internal/app"
|
"github.com/andyleap/hnh-map/internal/app"
|
||||||
"github.com/andyleap/hnh-map/internal/app/services"
|
"github.com/andyleap/hnh-map/internal/app/services"
|
||||||
"github.com/andyleap/hnh-map/internal/app/store"
|
"github.com/andyleap/hnh-map/internal/app/store"
|
||||||
"go.etcd.io/bbolt"
|
"go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFixMultipartContentType_NeedsQuoting(t *testing.T) {
|
func TestFixMultipartContentType_NeedsQuoting(t *testing.T) {
|
||||||
ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123"
|
ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123"
|
||||||
got := services.FixMultipartContentType(ct)
|
got := services.FixMultipartContentType(ct)
|
||||||
want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"`
|
want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"`
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("expected %q, got %q", want, got)
|
t.Fatalf("expected %q, got %q", want, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) {
|
func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) {
|
||||||
ct := `multipart/form-data; boundary="----WebKitFormBoundary"`
|
ct := `multipart/form-data; boundary="----WebKitFormBoundary"`
|
||||||
got := services.FixMultipartContentType(ct)
|
got := services.FixMultipartContentType(ct)
|
||||||
if got != ct {
|
if got != ct {
|
||||||
t.Fatalf("expected unchanged, got %q", got)
|
t.Fatalf("expected unchanged, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFixMultipartContentType_Normal(t *testing.T) {
|
func TestFixMultipartContentType_Normal(t *testing.T) {
|
||||||
ct := "multipart/form-data; boundary=----WebKitFormBoundary"
|
ct := "multipart/form-data; boundary=----WebKitFormBoundary"
|
||||||
got := services.FixMultipartContentType(ct)
|
got := services.FixMultipartContentType(ct)
|
||||||
if got != ct {
|
if got != ct {
|
||||||
t.Fatalf("expected unchanged, got %q", got)
|
t.Fatalf("expected unchanged, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestClientService(t *testing.T) (*services.ClientService, *store.Store) {
|
func newTestClientService(t *testing.T) (*services.ClientService, *store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
st := store.New(db)
|
st := store.New(db)
|
||||||
mapSvc := services.NewMapService(services.MapServiceDeps{
|
mapSvc := services.NewMapService(services.MapServiceDeps{
|
||||||
Store: st,
|
Store: st,
|
||||||
GridStorage: t.TempDir(),
|
GridStorage: t.TempDir(),
|
||||||
GridUpdates: &app.Topic[app.TileData]{},
|
GridUpdates: &app.Topic[app.TileData]{},
|
||||||
})
|
})
|
||||||
client := services.NewClientService(services.ClientServiceDeps{
|
client := services.NewClientService(services.ClientServiceDeps{
|
||||||
Store: st,
|
Store: st,
|
||||||
MapSvc: mapSvc,
|
MapSvc: mapSvc,
|
||||||
WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) },
|
WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) },
|
||||||
})
|
})
|
||||||
return client, st
|
return client, st
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientLocate_Found(t *testing.T) {
|
func TestClientLocate_Found(t *testing.T) {
|
||||||
client, st := newTestClientService(t)
|
client, st := newTestClientService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
|
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
|
||||||
raw, _ := json.Marshal(gd)
|
raw, _ := json.Marshal(gd)
|
||||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||||
return st.PutGrid(tx, "g1", raw)
|
return st.PutGrid(tx, "g1", raw)
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
result, err := client.Locate(ctx, "g1")
|
result, err := client.Locate(ctx, "g1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result != "1;2;3" {
|
if result != "1;2;3" {
|
||||||
t.Fatalf("expected 1;2;3, got %q", result)
|
t.Fatalf("expected 1;2;3, got %q", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientLocate_NotFound(t *testing.T) {
|
func TestClientLocate_NotFound(t *testing.T) {
|
||||||
client, _ := newTestClientService(t)
|
client, _ := newTestClientService(t)
|
||||||
_, err := client.Locate(context.Background(), "ghost")
|
_, err := client.Locate(context.Background(), "ghost")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for unknown grid")
|
t.Fatal("expected error for unknown grid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) {
|
func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) {
|
||||||
client, _ := newTestClientService(t)
|
client, _ := newTestClientService(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}})
|
result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
t.Fatal("expected non-nil result")
|
t.Fatal("expected non-nil result")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUploadMarkers_NormalizesCaveImage(t *testing.T) {
|
||||||
|
client, st := newTestClientService(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
body := []byte(`[{"Name":"Cave","GridID":"g1","X":10,"Y":20,"Image":"gfx/terobjs/mm/custom"}]`)
|
||||||
|
if err := client.UploadMarkers(ctx, body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var stored app.Marker
|
||||||
|
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||||
|
grid := st.GetMarkersGridBucket(tx)
|
||||||
|
if grid == nil {
|
||||||
|
t.Fatal("markers grid bucket not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := grid.Get([]byte("g1_10_20"))
|
||||||
|
if v == nil {
|
||||||
|
t.Fatal("marker g1_10_20 not found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(v, &stored)
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Image != "gfx/terobjs/mm/cave" {
|
||||||
|
t.Fatalf("expected stored marker Image gfx/terobjs/mm/cave, got %q", stored.Image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user