Compare commits
10 Commits
dda35baeca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dc53b79d84 | |||
| 179357bc93 | |||
| 3968bdc76f | |||
| 40945c818b | |||
| 7fcdde3657 | |||
| 1a0db9baf0 | |||
| 337386caa8 | |||
| fd624c2357 | |||
| 761fbaed55 | |||
| fc42d86ca0 |
@@ -11,4 +11,5 @@ alwaysApply: true
|
||||
- **Local run / build:** [docs/development.md](docs/development.md), [CONTRIBUTING.md](CONTRIBUTING.md). Dev ports: frontend 3000, backend 3080; prod: 8080. Build, test, lint, and format run via Docker (Makefile + docker-compose.tools.yml).
|
||||
- **Docs:** [docs/](docs/) (architecture, API, configuration, development, deployment). Some docs are in Russian.
|
||||
- **Coding:** Write tests first before implementing any functionality.
|
||||
- **Running tests:** When the user asks to run tests or to verify changes, use the run-tests skill: [.cursor/skills/run-tests/SKILL.md](.cursor/skills/run-tests/SKILL.md).
|
||||
- **Running lint:** When the user asks to run the linter or to verify changes, use the run-lint skill: [.cursor/skills/run-lint/SKILL.md](.cursor/skills/run-lint/SKILL.md).
|
||||
- **Running tests:** When the user asks to run tests or to verify changes, run lint first (run-lint skill), then use the run-tests skill: [.cursor/skills/run-tests/SKILL.md](.cursor/skills/run-tests/SKILL.md).
|
||||
|
||||
29
.cursor/skills/run-lint/SKILL.md
Normal file
29
.cursor/skills/run-lint/SKILL.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: run-lint
|
||||
description: Runs backend (golangci-lint) and frontend (ESLint) linters for the hnh-map monorepo. Use when the user asks to run the linter, lint, or to check code style and lint errors.
|
||||
---
|
||||
|
||||
# Run linter
|
||||
|
||||
## When to run
|
||||
|
||||
- User asks to run the linter, run lint, or check lint/style.
|
||||
- After making code changes that should be validated by the project linters.
|
||||
|
||||
## What to run
|
||||
|
||||
Lint runs **in Docker** via the Makefile; no local Go or Node is required.
|
||||
|
||||
From the repo root:
|
||||
|
||||
- **Both backend and frontend:** `make lint` (runs golangci-lint then frontend ESLint in Docker).
|
||||
|
||||
Uses `docker-compose.tools.yml`; the first run may build the backend-tools and frontend-tools images.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Backend-only changes** (e.g. `internal/`, `cmd/`): `make lint` still runs both; backend lint runs first.
|
||||
- **Frontend-only changes** (e.g. `frontend-nuxt/`): `make lint` runs both; frontend lint runs second.
|
||||
- **Both or unclear**: run `make lint`.
|
||||
|
||||
Report pass/fail and any linter errors or file/line references so the user can fix them.
|
||||
@@ -1,7 +1,10 @@
|
||||
# Backend tools image: Go + golangci-lint for test, fmt, lint.
|
||||
# Source is mounted at /hnh-map at run time via docker-compose.tools.yml.
|
||||
# Source is mounted at /src at run time; this WORKDIR is only for build-time go mod download.
|
||||
FROM golang:1.24-alpine
|
||||
|
||||
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0
|
||||
# v1.64+ required for Go 1.24 (export data format); see https://github.com/golangci/golangci-lint/issues/5225
|
||||
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.0
|
||||
|
||||
WORKDIR /hnh-map
|
||||
WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
16
Makefile
16
Makefile
@@ -3,25 +3,31 @@
|
||||
TOOLS_COMPOSE = docker compose -f docker-compose.tools.yml
|
||||
|
||||
dev:
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
|
||||
build:
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml build --no-cache
|
||||
|
||||
test: test-backend test-frontend
|
||||
|
||||
test-backend:
|
||||
$(TOOLS_COMPOSE) run --rm backend-tools go test ./...
|
||||
$(TOOLS_COMPOSE) build backend-tools
|
||||
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && go test ./..."
|
||||
|
||||
test-frontend:
|
||||
$(TOOLS_COMPOSE) build frontend-tools
|
||||
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm test"
|
||||
|
||||
lint:
|
||||
$(TOOLS_COMPOSE) run --rm backend-tools golangci-lint run
|
||||
$(TOOLS_COMPOSE) build backend-tools
|
||||
$(TOOLS_COMPOSE) build frontend-tools
|
||||
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && golangci-lint run"
|
||||
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run lint"
|
||||
|
||||
fmt:
|
||||
$(TOOLS_COMPOSE) run --rm backend-tools go fmt ./...
|
||||
$(TOOLS_COMPOSE) build backend-tools
|
||||
$(TOOLS_COMPOSE) build frontend-tools
|
||||
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && go fmt ./..."
|
||||
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run format"
|
||||
|
||||
generate-frontend:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
# One-off tools: test, lint, fmt. Use with: docker compose -f docker-compose.tools.yml run --rm <service> <cmd>
|
||||
# Source is mounted so commands run against current code.
|
||||
# Backend: mount at /src so the image's /go module cache (from build) is not overwritten.
|
||||
|
||||
services:
|
||||
backend-tools:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.tools
|
||||
working_dir: /src
|
||||
environment:
|
||||
GOPATH: /go
|
||||
GOMODCACHE: /go/pkg/mod
|
||||
volumes:
|
||||
- .:/hnh-map
|
||||
- .:/src
|
||||
# Default command; override when running (e.g. go test ./..., golangci-lint run).
|
||||
command: ["go", "test", "./..."]
|
||||
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import {
|
||||
ref,
|
||||
reactive,
|
||||
computed,
|
||||
watch,
|
||||
watchEffect,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
readonly,
|
||||
} from 'vue'
|
||||
|
||||
export { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick }
|
||||
export {
|
||||
ref,
|
||||
reactive,
|
||||
computed,
|
||||
watch,
|
||||
watchEffect,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
readonly,
|
||||
}
|
||||
|
||||
export function useRuntimeConfig() {
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Global error handling: on API auth failure, redirect to login
|
||||
const { onApiError } = useMapApi()
|
||||
const { fullUrl } = useAppPaths()
|
||||
const unsubscribe = onApiError(() => {
|
||||
if (import.meta.client) window.location.href = fullUrl('/login')
|
||||
})
|
||||
onUnmounted(() => unsubscribe())
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
@@ -14,13 +24,3 @@
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Global error handling: on API auth failure, redirect to login
|
||||
const { onApiError } = useMapApi()
|
||||
const { fullUrl } = useAppPaths()
|
||||
const unsubscribe = onApiError(() => {
|
||||
if (import.meta.client) window.location.href = fullUrl('/login')
|
||||
})
|
||||
onUnmounted(() => unsubscribe())
|
||||
</script>
|
||||
|
||||
@@ -21,3 +21,39 @@
|
||||
.leaflet-tile.tile-fresh {
|
||||
animation: tile-fresh-glow 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Leaflet tooltip: use theme colors (dark/light) */
|
||||
.leaflet-tooltip {
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
border-color: var(--color-base-300);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
border-top-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
border-bottom-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
border-left-color: var(--color-base-100);
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
border-right-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* Leaflet popup: use theme colors (dark/light) */
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -88,24 +88,27 @@
|
||||
</div>
|
||||
<MapControls
|
||||
:hide-markers="mapLogic.state.hideMarkers.value"
|
||||
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
|
||||
:selected-map-id="mapLogic.state.selectedMapId.value"
|
||||
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
|
||||
:overlay-map-id="mapLogic.state.overlayMapId.value"
|
||||
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
|
||||
:selected-marker-id="mapLogic.state.selectedMarkerId.value"
|
||||
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
|
||||
:selected-player-id="mapLogic.state.selectedPlayerId.value"
|
||||
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:players="players"
|
||||
:markers="allMarkers"
|
||||
:current-zoom="currentZoom"
|
||||
:current-map-id="mapLogic.state.mapid.value"
|
||||
:current-coords="mapLogic.state.displayCoords.value"
|
||||
:selected-marker-for-bookmark="selectedMarkerForBookmark"
|
||||
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
|
||||
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
|
||||
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
|
||||
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
|
||||
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
|
||||
@zoom-in="mapLogic.zoomIn(leafletMap)"
|
||||
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
|
||||
@reset-view="mapLogic.resetView(leafletMap)"
|
||||
@set-zoom="onSetZoom"
|
||||
@jump-to-marker="mapLogic.state.selectedMarkerId.value = $event"
|
||||
/>
|
||||
<MapContextMenu
|
||||
@@ -147,7 +150,7 @@ import { useMapNavigate } from '~/composables/useMapNavigate'
|
||||
import { useFullscreen } from '~/composables/useFullscreen'
|
||||
import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates'
|
||||
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
|
||||
import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api'
|
||||
import type { MapInfo, ConfigResponse, MeResponse, Marker as ApiMarker } from '~/types/api'
|
||||
import type L from 'leaflet'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -252,6 +255,10 @@ const maps = ref<MapInfo[]>([])
|
||||
const mapsLoaded = ref(false)
|
||||
const questGivers = ref<Array<{ id: number; name: string }>>([])
|
||||
const players = ref<Array<{ id: number; name: string }>>([])
|
||||
/** All markers from API for search suggestions (updated when markers load or on merge). */
|
||||
const allMarkers = ref<ApiMarker[]>([])
|
||||
/** Current map zoom level (1–6) for zoom slider. Updated on zoomend. */
|
||||
const currentZoom = ref(HnHDefaultZoom)
|
||||
/** Single source of truth: layout updates me, we derive auths for context menu. */
|
||||
const me = useState<MeResponse | null>('me', () => null)
|
||||
const auths = computed(() => me.value?.auths ?? [])
|
||||
@@ -347,6 +354,10 @@ function reloadPage() {
|
||||
if (import.meta.client) window.location.reload()
|
||||
}
|
||||
|
||||
function onSetZoom(z: number) {
|
||||
if (leafletMap) leafletMap.setZoom(z)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')
|
||||
@@ -462,6 +473,16 @@ onMounted(async () => {
|
||||
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
|
||||
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
|
||||
onMarkerContextMenu: mapLogic.openMarkerContextMenu,
|
||||
onAddMarkerToBookmark: (markerId, getMarkerById) => {
|
||||
const m = getMarkerById(markerId)
|
||||
if (!m) return
|
||||
openBookmarkModal(m.name, 'Add bookmark', {
|
||||
kind: 'add',
|
||||
mapId: m.map,
|
||||
x: Math.floor(m.position.x / TileSize),
|
||||
y: Math.floor(m.position.y / TileSize),
|
||||
})
|
||||
},
|
||||
resolveIconUrl: (path) => resolvePath(path),
|
||||
fallbackIconUrl: FALLBACK_MARKER_ICON,
|
||||
})
|
||||
@@ -479,7 +500,9 @@ onMounted(async () => {
|
||||
layersManager!.changeMap(mapTo)
|
||||
api.getMarkers().then((body) => {
|
||||
if (!mounted) return
|
||||
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
|
||||
const list = Array.isArray(body) ? body : []
|
||||
allMarkers.value = list
|
||||
layersManager!.updateMarkers(list)
|
||||
questGivers.value = layersManager!.getQuestGivers()
|
||||
})
|
||||
leafletMap!.setView(latLng, leafletMap!.getZoom())
|
||||
@@ -530,7 +553,9 @@ onMounted(async () => {
|
||||
// Markers load asynchronously after map is visible.
|
||||
api.getMarkers().then((body) => {
|
||||
if (!mounted) return
|
||||
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
|
||||
const list = Array.isArray(body) ? body : []
|
||||
allMarkers.value = list
|
||||
layersManager!.updateMarkers(list)
|
||||
questGivers.value = layersManager!.getQuestGivers()
|
||||
updateSelectedMarkerForBookmark()
|
||||
})
|
||||
@@ -650,6 +675,10 @@ onMounted(async () => {
|
||||
|
||||
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
|
||||
mapLogic.updateDisplayCoords(leafletMap)
|
||||
currentZoom.value = leafletMap.getZoom()
|
||||
leafletMap.on('zoomend', () => {
|
||||
if (leafletMap) currentZoom.value = leafletMap.getZoom()
|
||||
})
|
||||
leafletMap.on('drag', () => {
|
||||
mapLogic.state.trackingCharacterId.value = -1
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:readonly="readonly"
|
||||
:aria-describedby="ariaDescribedby"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation"
|
||||
@@ -41,7 +41,14 @@ const props = withDefaults(
|
||||
inputId?: string
|
||||
ariaDescribedby?: string
|
||||
}>(),
|
||||
{ required: false, autocomplete: 'off', inputId: undefined, ariaDescribedby: undefined }
|
||||
{
|
||||
required: false,
|
||||
autocomplete: 'off',
|
||||
inputId: undefined,
|
||||
ariaDescribedby: undefined,
|
||||
label: undefined,
|
||||
placeholder: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ inheritAttrs: false })
|
||||
defineOptions({ name: 'AppSkeleton', inheritAttrs: false })
|
||||
</script>
|
||||
|
||||
@@ -30,7 +30,7 @@ const props = withDefaults(
|
||||
email?: string
|
||||
size?: number
|
||||
}>(),
|
||||
{ size: 32 }
|
||||
{ size: 32, email: undefined }
|
||||
)
|
||||
|
||||
const gravatarError = ref(false)
|
||||
|
||||
6
frontend-nuxt/components/icons/IconCopy.vue
Normal file
6
frontend-nuxt/components/icons/IconCopy.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
</template>
|
||||
7
frontend-nuxt/components/icons/IconInfo.vue
Normal file
7
frontend-nuxt/components/icons/IconInfo.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Bookmark name"
|
||||
@keydown.enter.prevent="onSubmit"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog" @submit.prevent="onSubmit">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
<template v-if="bookmarks.length === 0">
|
||||
<p class="text-xs text-base-content/60 py-1">No saved locations.</p>
|
||||
<p class="text-xs text-base-content/50 py-0">Add current location or a selected quest giver below.</p>
|
||||
<p class="text-xs text-base-content/50 py-0">Add your first location below.</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
@@ -49,7 +49,7 @@
|
||||
class="btn btn-primary btn-sm w-full"
|
||||
:class="touchFriendly ? 'min-h-11' : ''"
|
||||
:disabled="!selectedMarkerForBookmark"
|
||||
title="Add selected quest giver as bookmark"
|
||||
:title="selectedMarkerForBookmark ? 'Add selected quest giver as bookmark' : 'Select a quest giver from the list above to add it as a bookmark.'"
|
||||
@click="onAddSelectedMarker"
|
||||
>
|
||||
<icons-icon-plus class="size-4" />
|
||||
|
||||
@@ -39,22 +39,25 @@
|
||||
<MapControlsContent
|
||||
v-model:hide-markers="hideMarkers"
|
||||
:selected-map-id-select="selectedMapIdSelect"
|
||||
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
|
||||
:overlay-map-id="overlayMapId"
|
||||
@update:overlay-map-id="(v) => (overlayMapId = v)"
|
||||
:selected-marker-id-select="selectedMarkerIdSelect"
|
||||
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
|
||||
:selected-player-id-select="selectedPlayerIdSelect"
|
||||
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:players="players"
|
||||
:markers="markers"
|
||||
:current-zoom="currentZoom"
|
||||
:current-map-id="currentMapId ?? undefined"
|
||||
:current-coords="currentCoords"
|
||||
:selected-marker-for-bookmark="selectedMarkerForBookmark"
|
||||
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
|
||||
@update:overlay-map-id="(v) => (overlayMapId = v)"
|
||||
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
|
||||
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
|
||||
@zoom-in="$emit('zoomIn')"
|
||||
@zoom-out="$emit('zoomOut')"
|
||||
@reset-view="$emit('resetView')"
|
||||
@set-zoom="$emit('setZoom', $event)"
|
||||
@jump-to-marker="$emit('jumpToMarker', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -130,23 +133,26 @@
|
||||
<MapControlsContent
|
||||
v-model:hide-markers="hideMarkers"
|
||||
:selected-map-id-select="selectedMapIdSelect"
|
||||
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
|
||||
:overlay-map-id="overlayMapId"
|
||||
@update:overlay-map-id="(v) => (overlayMapId = v)"
|
||||
:selected-marker-id-select="selectedMarkerIdSelect"
|
||||
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
|
||||
:selected-player-id-select="selectedPlayerIdSelect"
|
||||
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:players="players"
|
||||
:markers="markers"
|
||||
:current-zoom="currentZoom"
|
||||
:current-map-id="currentMapId ?? undefined"
|
||||
:current-coords="currentCoords"
|
||||
:selected-marker-for-bookmark="selectedMarkerForBookmark"
|
||||
:touch-friendly="true"
|
||||
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
|
||||
@update:overlay-map-id="(v) => (overlayMapId = v)"
|
||||
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
|
||||
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
|
||||
@zoom-in="$emit('zoomIn')"
|
||||
@zoom-out="$emit('zoomOut')"
|
||||
@reset-view="$emit('resetView')"
|
||||
@set-zoom="$emit('setZoom', $event)"
|
||||
@jump-to-marker="$emit('jumpToMarker', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,7 +175,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapInfo } from '~/types/api'
|
||||
import type { MapInfo, Marker as ApiMarker } from '~/types/api'
|
||||
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
|
||||
import MapControlsContent from '~/components/map/MapControlsContent.vue'
|
||||
|
||||
@@ -185,9 +191,11 @@ interface Player {
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
maps: MapInfo[]
|
||||
questGivers: QuestGiver[]
|
||||
players: Player[]
|
||||
maps?: MapInfo[]
|
||||
questGivers?: QuestGiver[]
|
||||
players?: Player[]
|
||||
markers?: ApiMarker[]
|
||||
currentZoom?: number
|
||||
currentMapId?: number | null
|
||||
currentCoords?: { x: number; y: number; z: number } | null
|
||||
selectedMarkerForBookmark?: SelectedMarkerForBookmark
|
||||
@@ -196,6 +204,8 @@ const props = withDefaults(
|
||||
maps: () => [],
|
||||
questGivers: () => [],
|
||||
players: () => [],
|
||||
markers: () => [],
|
||||
currentZoom: 1,
|
||||
currentMapId: null,
|
||||
currentCoords: null,
|
||||
selectedMarkerForBookmark: null,
|
||||
@@ -206,6 +216,7 @@ defineEmits<{
|
||||
zoomIn: []
|
||||
zoomOut: []
|
||||
resetView: []
|
||||
setZoom: [level: number]
|
||||
jumpToMarker: [id: number]
|
||||
}>()
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
v-if="currentMapId != null && currentCoords != null"
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:markers="markers"
|
||||
:overlay-map-id="props.overlayMapId"
|
||||
:current-map-id="currentMapId"
|
||||
:current-coords="currentCoords"
|
||||
:touch-friendly="touchFriendly"
|
||||
@@ -48,6 +50,19 @@
|
||||
<icons-icon-home />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
:min="zoomMin"
|
||||
:max="zoomMax"
|
||||
:value="currentZoom"
|
||||
class="range range-primary range-sm flex-1"
|
||||
:class="touchFriendly ? 'range-lg' : ''"
|
||||
aria-label="Zoom level"
|
||||
@input="onZoomSliderInput($event)"
|
||||
>
|
||||
<span class="text-xs font-mono w-6 text-right" aria-hidden="true">{{ currentZoom }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Display -->
|
||||
<section class="flex flex-col gap-2">
|
||||
@@ -56,7 +71,7 @@
|
||||
Display
|
||||
</h3>
|
||||
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
|
||||
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
|
||||
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" >
|
||||
<span>Hide markers</span>
|
||||
</label>
|
||||
</section>
|
||||
@@ -78,7 +93,16 @@
|
||||
</select>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label py-0"><span>Overlay Map</span></label>
|
||||
<label class="label py-0 flex items-center gap-1.5">
|
||||
<span>Overlay Map</span>
|
||||
<span
|
||||
class="inline-flex text-base-content/60 cursor-help"
|
||||
title="Overlay shows markers from another map on top of the current one."
|
||||
aria-label="Overlay shows markers from another map on top of the current one."
|
||||
>
|
||||
<icons-icon-info class="size-3.5" />
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="overlayMapId"
|
||||
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
|
||||
@@ -89,22 +113,43 @@
|
||||
</select>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label py-0"><span>Jump to Quest Giver</span></label>
|
||||
<label class="label py-0"><span>Jump to</span></label>
|
||||
<div class="join w-full flex">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm join-item flex-1 touch-manipulation"
|
||||
:class="[jumpToTab === 'quest' ? 'btn-active' : 'btn-ghost', touchFriendly ? 'min-h-11 text-base' : '']"
|
||||
aria-pressed="jumpToTab === 'quest'"
|
||||
@click="jumpToTab = 'quest'"
|
||||
>
|
||||
Quest giver
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm join-item flex-1 touch-manipulation"
|
||||
:class="[jumpToTab === 'player' ? 'btn-active' : 'btn-ghost', touchFriendly ? 'min-h-11 text-base' : '']"
|
||||
aria-pressed="jumpToTab === 'player'"
|
||||
@click="jumpToTab = 'player'"
|
||||
>
|
||||
Player
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
v-if="jumpToTab === 'quest'"
|
||||
v-model="selectedMarkerIdSelect"
|
||||
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
|
||||
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation mt-1"
|
||||
:class="touchFriendly ? 'min-h-11 text-base' : ''"
|
||||
aria-label="Select quest giver"
|
||||
>
|
||||
<option value="">Select quest giver</option>
|
||||
<option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label py-0"><span>Jump to Player</span></label>
|
||||
<select
|
||||
v-else
|
||||
v-model="selectedPlayerIdSelect"
|
||||
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
|
||||
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation mt-1"
|
||||
:class="touchFriendly ? 'min-h-11 text-base' : ''"
|
||||
aria-label="Select player"
|
||||
>
|
||||
<option value="">Select player</option>
|
||||
<option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option>
|
||||
@@ -126,9 +171,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapInfo } from '~/types/api'
|
||||
import type { MapInfo, Marker as ApiMarker } from '~/types/api'
|
||||
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
|
||||
import MapBookmarks from '~/components/map/MapBookmarks.vue'
|
||||
import { HnHMinZoom, HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
||||
|
||||
interface QuestGiver {
|
||||
id: number
|
||||
@@ -140,27 +186,33 @@ interface Player {
|
||||
name: string
|
||||
}
|
||||
|
||||
const zoomMin = HnHMinZoom
|
||||
const zoomMax = HnHMaxZoom
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
maps: MapInfo[]
|
||||
questGivers: QuestGiver[]
|
||||
players: Player[]
|
||||
markers?: ApiMarker[]
|
||||
touchFriendly?: boolean
|
||||
selectedMapIdSelect: string
|
||||
overlayMapId: number
|
||||
selectedMarkerIdSelect: string
|
||||
selectedPlayerIdSelect: string
|
||||
currentZoom?: number
|
||||
currentMapId?: number
|
||||
currentCoords?: { x: number; y: number; z: number } | null
|
||||
selectedMarkerForBookmark?: SelectedMarkerForBookmark
|
||||
}>(),
|
||||
{ touchFriendly: false, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null }
|
||||
{ touchFriendly: false, markers: () => [], currentZoom: 1, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
zoomIn: []
|
||||
zoomOut: []
|
||||
resetView: []
|
||||
setZoom: [level: number]
|
||||
jumpToMarker: [id: number]
|
||||
'update:hideMarkers': [v: boolean]
|
||||
'update:selectedMapIdSelect': [v: string]
|
||||
@@ -169,8 +221,16 @@ const emit = defineEmits<{
|
||||
'update:selectedPlayerIdSelect': [v: string]
|
||||
}>()
|
||||
|
||||
function onZoomSliderInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
const level = Number(value)
|
||||
if (!Number.isNaN(level)) emit('setZoom', level)
|
||||
}
|
||||
|
||||
const hideMarkers = defineModel<boolean>('hideMarkers', { required: true })
|
||||
|
||||
const jumpToTab = ref<'quest' | 'player'>('quest')
|
||||
|
||||
const selectedMapIdSelect = computed({
|
||||
get: () => props.selectedMapIdSelect,
|
||||
set: (v) => emit('update:selectedMapIdSelect', v),
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3>
|
||||
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
|
||||
<div class="flex gap-2">
|
||||
<input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" />
|
||||
<input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" />
|
||||
<input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" >
|
||||
<input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" >
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog" @submit.prevent="onSubmit">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="displayCoords"
|
||||
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100"
|
||||
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-base bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100 flex items-center gap-2"
|
||||
aria-label="Current grid position and zoom — click to copy share link"
|
||||
:title="copied ? 'Copied!' : 'Click to copy share link'"
|
||||
role="button"
|
||||
@@ -19,6 +19,7 @@
|
||||
Copied!
|
||||
</span>
|
||||
</span>
|
||||
<icons-icon-copy class="size-4 shrink-0 opacity-70" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
@keydown.enter="onEnter"
|
||||
@keydown.down.prevent="moveHighlight(1)"
|
||||
@keydown.up.prevent="moveHighlight(-1)"
|
||||
/>
|
||||
>
|
||||
<button
|
||||
v-if="query"
|
||||
type="button"
|
||||
@@ -78,7 +78,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MapInfo } from '~/types/api'
|
||||
import type { MapInfo, Marker as ApiMarker } from '~/types/api'
|
||||
import { TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import { useMapNavigate } from '~/composables/useMapNavigate'
|
||||
import { useRecentLocations } from '~/composables/useRecentLocations'
|
||||
|
||||
@@ -86,11 +87,13 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
maps: MapInfo[]
|
||||
questGivers: Array<{ id: number; name: string }>
|
||||
markers?: ApiMarker[]
|
||||
overlayMapId?: number
|
||||
currentMapId: number
|
||||
currentCoords: { x: number; y: number; z: number } | null
|
||||
touchFriendly?: boolean
|
||||
}>(),
|
||||
{ touchFriendly: false }
|
||||
{ touchFriendly: false, markers: () => [], overlayMapId: -1 }
|
||||
)
|
||||
|
||||
const { goToCoords } = useMapNavigate()
|
||||
@@ -147,22 +150,37 @@ const suggestions = computed<Suggestion[]>(() => {
|
||||
}
|
||||
|
||||
const list: Suggestion[] = []
|
||||
for (const qg of props.questGivers) {
|
||||
if (qg.name.toLowerCase().includes(q)) {
|
||||
const overlayId = props.overlayMapId ?? -1
|
||||
const visibleMarkers = (props.markers ?? []).filter(
|
||||
(m) =>
|
||||
!m.hidden &&
|
||||
(m.map === props.currentMapId || (overlayId >= 0 && m.map === overlayId))
|
||||
)
|
||||
const qLower = q.toLowerCase()
|
||||
for (const m of visibleMarkers) {
|
||||
if (m.name.toLowerCase().includes(qLower)) {
|
||||
const gridX = Math.floor(m.position.x / TileSize)
|
||||
const gridY = Math.floor(m.position.y / TileSize)
|
||||
list.push({
|
||||
key: `qg-${qg.id}`,
|
||||
label: qg.name,
|
||||
mapId: props.currentMapId,
|
||||
x: 0,
|
||||
y: 0,
|
||||
key: `marker-${m.id}`,
|
||||
label: `${m.name} · ${gridX}, ${gridY}`,
|
||||
mapId: m.map,
|
||||
x: gridX,
|
||||
y: gridY,
|
||||
zoom: undefined,
|
||||
markerId: qg.id,
|
||||
markerId: m.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (list.length > 0) return list.slice(0, 8)
|
||||
|
||||
return []
|
||||
// Prefer quest givers (match by id in questGivers) so they appear first when query matches both
|
||||
const qgIds = new Set(props.questGivers.map((qg) => qg.id))
|
||||
list.sort((a, b) => {
|
||||
const aQg = a.markerId != null && qgIds.has(a.markerId) ? 1 : 0
|
||||
const bQg = b.markerId != null && qgIds.has(b.markerId) ? 1 : 0
|
||||
if (bQg !== aQg) return bQg - aQg
|
||||
return 0
|
||||
})
|
||||
return list.slice(0, 8)
|
||||
})
|
||||
|
||||
function scheduleCloseDropdown() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { useAppPaths } from '../useAppPaths'
|
||||
|
||||
const useRuntimeConfigMock = vi.fn()
|
||||
vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock)
|
||||
|
||||
import { useAppPaths } from '../useAppPaths'
|
||||
|
||||
describe('useAppPaths with default base /', () => {
|
||||
beforeEach(() => {
|
||||
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } })
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { useMapApi } from '../useMapApi'
|
||||
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
app: { baseURL: '/' },
|
||||
public: { apiBase: '/map/api' },
|
||||
}))
|
||||
|
||||
import { useMapApi } from '../useMapApi'
|
||||
|
||||
function mockFetch(status: number, body: unknown, contentType = 'application/json') {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useMapBookmarks } from '../useMapBookmarks'
|
||||
|
||||
const stateByKey: Record<string, ReturnType<typeof ref>> = {}
|
||||
const useStateMock = vi.fn((key: string, init: () => unknown) => {
|
||||
if (!stateByKey[key]) {
|
||||
@@ -18,15 +20,13 @@ const localStorageMock = {
|
||||
storage[key] = value
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const k of Object.keys(storage)) delete storage[k]
|
||||
delete storage['hnh-map-bookmarks']
|
||||
}),
|
||||
}
|
||||
vi.stubGlobal('localStorage', localStorageMock)
|
||||
vi.stubGlobal('import.meta.server', false)
|
||||
vi.stubGlobal('import.meta.client', true)
|
||||
|
||||
import { useMapBookmarks } from '../useMapBookmarks'
|
||||
|
||||
describe('useMapBookmarks', () => {
|
||||
beforeEach(() => {
|
||||
storage['hnh-map-bookmarks'] = '[]'
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, reactive } from 'vue'
|
||||
import type { Map } from 'leaflet'
|
||||
|
||||
import { useMapLogic } from '../useMapLogic'
|
||||
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('reactive', reactive)
|
||||
|
||||
import { useMapLogic } from '../useMapLogic'
|
||||
|
||||
describe('useMapLogic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -27,7 +28,7 @@ describe('useMapLogic', () => {
|
||||
it('zoomIn calls map.zoomIn', () => {
|
||||
const { zoomIn } = useMapLogic()
|
||||
const mockMap = { zoomIn: vi.fn() }
|
||||
zoomIn(mockMap as unknown as import('leaflet').Map)
|
||||
zoomIn(mockMap as unknown as Map)
|
||||
expect(mockMap.zoomIn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -39,7 +40,7 @@ describe('useMapLogic', () => {
|
||||
it('zoomOutControl calls map.zoomOut', () => {
|
||||
const { zoomOutControl } = useMapLogic()
|
||||
const mockMap = { zoomOut: vi.fn() }
|
||||
zoomOutControl(mockMap as unknown as import('leaflet').Map)
|
||||
zoomOutControl(mockMap as unknown as Map)
|
||||
expect(mockMap.zoomOut).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -47,7 +48,7 @@ describe('useMapLogic', () => {
|
||||
const { state, resetView } = useMapLogic()
|
||||
state.trackingCharacterId.value = 42
|
||||
const mockMap = { setView: vi.fn() }
|
||||
resetView(mockMap as unknown as import('leaflet').Map)
|
||||
resetView(mockMap as unknown as Map)
|
||||
expect(state.trackingCharacterId.value).toBe(-1)
|
||||
expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false })
|
||||
})
|
||||
@@ -59,7 +60,7 @@ describe('useMapLogic', () => {
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
getZoom: vi.fn(() => 3),
|
||||
}
|
||||
updateDisplayCoords(mockMap as unknown as import('leaflet').Map)
|
||||
updateDisplayCoords(mockMap as unknown as Map)
|
||||
expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 })
|
||||
})
|
||||
|
||||
@@ -72,7 +73,7 @@ describe('useMapLogic', () => {
|
||||
it('toLatLng calls map.unproject', () => {
|
||||
const { toLatLng } = useMapLogic()
|
||||
const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) }
|
||||
const result = toLatLng(mockMap as unknown as import('leaflet').Map, 100, 200)
|
||||
const result = toLatLng(mockMap as unknown as Map, 100, 200)
|
||||
expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6)
|
||||
expect(result).toEqual({ lat: 1, lng: 2 })
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useToast } from '../useToast'
|
||||
|
||||
const stateByKey: Record<string, ReturnType<typeof ref>> = {}
|
||||
const useStateMock = vi.fn((key: string, init: () => unknown) => {
|
||||
if (!stateByKey[key]) {
|
||||
@@ -10,8 +12,6 @@ const useStateMock = vi.fn((key: string, init: () => unknown) => {
|
||||
})
|
||||
vi.stubGlobal('useState', useStateMock)
|
||||
|
||||
import { useToast } from '../useToast'
|
||||
|
||||
describe('useToast', () => {
|
||||
beforeEach(() => {
|
||||
stateByKey['hnh-map-toasts'] = ref([])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readonly } from 'vue'
|
||||
|
||||
export interface MapBookmark {
|
||||
id: string
|
||||
name: string
|
||||
@@ -29,7 +31,7 @@ function saveBookmarks(bookmarks: MapBookmark[]) {
|
||||
if (import.meta.server) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks.slice(0, MAX_BOOKMARKS)))
|
||||
} catch (_) {}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function useMapBookmarks() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type L from 'leaflet'
|
||||
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
||||
import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker'
|
||||
import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character'
|
||||
import {
|
||||
@@ -12,11 +12,21 @@ import {
|
||||
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api'
|
||||
|
||||
type LeafletModule = L
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export interface MapLayersOptions {
|
||||
/** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */
|
||||
L: typeof import('leaflet')
|
||||
L: LeafletModule
|
||||
map: L.Map
|
||||
markerLayer: L.LayerGroup
|
||||
layer: SmartTileLayerInstance
|
||||
@@ -24,10 +34,12 @@ export interface MapLayersOptions {
|
||||
getCurrentMapId: () => number
|
||||
setCurrentMapId: (id: number) => void
|
||||
setSelectedMapId: (id: number) => void
|
||||
getAuths: () => string[]
|
||||
getAuths?: () => string[]
|
||||
getTrackingCharacterId: () => number
|
||||
setTrackingCharacterId: (id: number) => void
|
||||
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
|
||||
/** Called when user clicks "Add to saved locations" in marker popup. Receives marker id and getter to resolve marker. */
|
||||
onAddMarkerToBookmark?: (markerId: number, getMarkerById: (id: number) => MapMarker | undefined) => void
|
||||
/** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */
|
||||
resolveIconUrl?: (path: string) => string
|
||||
/** Fallback icon URL when a marker image fails to load. */
|
||||
@@ -58,10 +70,11 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
|
||||
getCurrentMapId,
|
||||
setCurrentMapId,
|
||||
setSelectedMapId,
|
||||
getAuths,
|
||||
getAuths: _getAuths,
|
||||
getTrackingCharacterId,
|
||||
setTrackingCharacterId,
|
||||
onMarkerContextMenu,
|
||||
onAddMarkerToBookmark,
|
||||
resolveIconUrl,
|
||||
fallbackIconUrl,
|
||||
} = options
|
||||
@@ -112,7 +125,30 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
|
||||
(marker: MapMarker) => {
|
||||
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
|
||||
marker.setClickCallback(() => {
|
||||
if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
|
||||
if (marker.leafletMarker) {
|
||||
if (onAddMarkerToBookmark) {
|
||||
const gridX = Math.floor(marker.position.x / TileSize)
|
||||
const gridY = Math.floor(marker.position.y / TileSize)
|
||||
const div = document.createElement('div')
|
||||
div.className = 'map-marker-popup text-sm'
|
||||
div.innerHTML = `
|
||||
<p class="font-medium mb-1">${escapeHtml(marker.name)}</p>
|
||||
<p class="text-base-content/70 text-xs mb-2 font-mono">${gridX}, ${gridY}</p>
|
||||
<button type="button" class="btn btn-primary btn-xs w-full">Add to saved locations</button>
|
||||
`
|
||||
const btn = div.querySelector('button')
|
||||
if (btn) {
|
||||
btn.addEventListener('click', () => {
|
||||
onAddMarkerToBookmark(marker.id, findMarkerById)
|
||||
marker.leafletMarker?.closePopup()
|
||||
})
|
||||
}
|
||||
marker.leafletMarker.unbindPopup()
|
||||
marker.leafletMarker.bindPopup(div, { minWidth: 140, autoPan: true }).openPopup()
|
||||
} else {
|
||||
map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
|
||||
}
|
||||
}
|
||||
})
|
||||
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
|
||||
mev.originalEvent.preventDefault()
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface UseMapUpdatesReturn {
|
||||
|
||||
const RECONNECT_INITIAL_MS = 1000
|
||||
const RECONNECT_MAX_MS = 30000
|
||||
/** If no SSE message received for this long, treat connection as stale and reconnect. */
|
||||
const STALE_CONNECTION_MS = 65000
|
||||
const STALE_CHECK_INTERVAL_MS = 30000
|
||||
|
||||
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
|
||||
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options
|
||||
@@ -50,6 +53,8 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
|
||||
let batchScheduled = false
|
||||
let source: EventSource | null = null
|
||||
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let staleCheckIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let lastMessageTime = 0
|
||||
let reconnectDelayMs = RECONNECT_INITIAL_MS
|
||||
let destroyed = false
|
||||
|
||||
@@ -79,15 +84,22 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
|
||||
overlayLayer.cache[key] = u.T
|
||||
}
|
||||
const visible = getVisibleTileBounds()
|
||||
// u.Z is backend storage zoom (0..5); visible.zoom is map zoom (1..6). With zoomReverse, current backend Z = maxZoom - mapZoom.
|
||||
const currentBackendZ = visible ? layer.options.maxZoom - visible.zoom : null
|
||||
let needRedraw = false
|
||||
for (const u of updates) {
|
||||
if (visible && u.Z !== visible.zoom) continue
|
||||
if (visible && currentBackendZ != null && u.Z !== currentBackendZ) continue
|
||||
if (
|
||||
visible &&
|
||||
(u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY)
|
||||
)
|
||||
continue
|
||||
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z)
|
||||
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
|
||||
if (layer.map === u.M && !layer.refresh(u.X, u.Y, u.Z)) needRedraw = true
|
||||
if (overlayLayer.map === u.M && !overlayLayer.refresh(u.X, u.Y, u.Z)) needRedraw = true
|
||||
}
|
||||
if (needRedraw) {
|
||||
layer.redraw()
|
||||
overlayLayer.redraw()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,12 +111,30 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
|
||||
|
||||
function connect() {
|
||||
if (destroyed || !import.meta.client) return
|
||||
if (staleCheckIntervalId != null) {
|
||||
clearInterval(staleCheckIntervalId)
|
||||
staleCheckIntervalId = null
|
||||
}
|
||||
source = new EventSource(updatesUrl)
|
||||
if (connectionStateRef) connectionStateRef.value = 'connecting'
|
||||
|
||||
source.onopen = () => {
|
||||
if (connectionStateRef) connectionStateRef.value = 'open'
|
||||
lastMessageTime = Date.now()
|
||||
reconnectDelayMs = RECONNECT_INITIAL_MS
|
||||
staleCheckIntervalId = setInterval(() => {
|
||||
if (destroyed || !source) return
|
||||
if (Date.now() - lastMessageTime > STALE_CONNECTION_MS) {
|
||||
if (staleCheckIntervalId != null) {
|
||||
clearInterval(staleCheckIntervalId)
|
||||
staleCheckIntervalId = null
|
||||
}
|
||||
source.close()
|
||||
source = null
|
||||
if (connectionStateRef) connectionStateRef.value = 'error'
|
||||
connect()
|
||||
}
|
||||
}, STALE_CHECK_INTERVAL_MS)
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
@@ -121,6 +151,7 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
|
||||
}
|
||||
|
||||
source.onmessage = (event: MessageEvent) => {
|
||||
lastMessageTime = Date.now()
|
||||
if (connectionStateRef) connectionStateRef.value = 'open'
|
||||
try {
|
||||
const raw: unknown = event?.data
|
||||
@@ -157,6 +188,10 @@ export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesRet
|
||||
|
||||
function cleanup() {
|
||||
destroyed = true
|
||||
if (staleCheckIntervalId != null) {
|
||||
clearInterval(staleCheckIntervalId)
|
||||
staleCheckIntervalId = null
|
||||
}
|
||||
if (reconnectTimeoutId != null) {
|
||||
clearTimeout(reconnectTimeoutId)
|
||||
reconnectTimeoutId = null
|
||||
|
||||
@@ -26,7 +26,7 @@ function saveRecent(list: RecentLocation[]) {
|
||||
if (import.meta.server) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT)))
|
||||
} catch (_) {}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function useRecentLocations() {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readonly } from 'vue'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info'
|
||||
|
||||
export interface Toast {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt({
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
export default withNuxt(
|
||||
{ ignores: ['eslint.config.mjs'] },
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
type="checkbox"
|
||||
class="drawer-toggle"
|
||||
@change="onDrawerChange"
|
||||
/>
|
||||
>
|
||||
<div class="drawer-content flex flex-col h-screen overflow-hidden">
|
||||
<header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
|
||||
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
|
||||
@@ -87,7 +87,7 @@
|
||||
class="toggle toggle-sm toggle-primary shrink-0"
|
||||
:checked="dark"
|
||||
@change="onThemeToggle"
|
||||
/>
|
||||
>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -177,7 +177,7 @@
|
||||
class="toggle toggle-sm toggle-primary shrink-0"
|
||||
:checked="dark"
|
||||
@change="onThemeToggle"
|
||||
/>
|
||||
>
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
@@ -296,7 +296,7 @@ async function loadConfig(loadToken: number) {
|
||||
const config = await useMapApi().getConfig()
|
||||
if (loadToken !== loadId) return
|
||||
if (config?.title) title.value = config.title
|
||||
} catch (_) {}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type L from 'leaflet'
|
||||
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
|
||||
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
||||
import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
|
||||
export type LeafletApi = typeof import('leaflet')
|
||||
export type LeafletApi = L
|
||||
|
||||
function buildCharacterIconUrl(colors: CharacterColors): string {
|
||||
const svg =
|
||||
@@ -16,9 +16,10 @@ function buildCharacterIconUrl(colors: CharacterColors): string {
|
||||
export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
|
||||
return new L.Icon({
|
||||
iconUrl: buildCharacterIconUrl(colors),
|
||||
iconSize: [24, 32],
|
||||
iconAnchor: [12, 32],
|
||||
iconSize: [25, 32],
|
||||
iconAnchor: [12, 17],
|
||||
popupAnchor: [0, -32],
|
||||
tooltipAnchor: [12, 0],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,10 +55,17 @@ export interface MapCharacter {
|
||||
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
}
|
||||
|
||||
const CHARACTER_MOVE_DURATION_MS = 280
|
||||
|
||||
function easeOutQuad(t: number): number {
|
||||
return t * (2 - t)
|
||||
}
|
||||
|
||||
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
|
||||
let leafletMarker: L.Marker | null = null
|
||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
let ownedByMe = data.ownedByMe ?? false
|
||||
let animationFrameId: number | null = null
|
||||
const colors = getColorForCharacterId(data.id, { ownedByMe })
|
||||
let characterIcon = createCharacterIcon(L, colors)
|
||||
|
||||
@@ -81,6 +89,10 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
|
||||
},
|
||||
|
||||
remove(mapview: CharacterMapViewRef): void {
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
if (leafletMarker) {
|
||||
const layer = mapview.markerLayer ?? mapview.map
|
||||
layer.removeLayer(leafletMarker)
|
||||
@@ -91,12 +103,22 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
|
||||
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, { icon: characterIcon, title: character.name })
|
||||
leafletMarker = L.marker(position, { icon: characterIcon })
|
||||
const gridX = Math.floor(character.position.x / TileSize)
|
||||
const gridY = Math.floor(character.position.y / TileSize)
|
||||
const tooltipContent = `${character.name} · ${gridX}, ${gridY}`
|
||||
leafletMarker.bindTooltip(tooltipContent, {
|
||||
direction: 'top',
|
||||
permanent: false,
|
||||
offset: L.point(-10.5, -18),
|
||||
})
|
||||
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||
if (onClick) onClick(e)
|
||||
})
|
||||
const targetLayer = mapview.markerLayer ?? mapview.map
|
||||
leafletMarker.addTo(targetLayer)
|
||||
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
|
||||
if (markerEl) markerEl.setAttribute('aria-label', character.name)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -114,11 +136,51 @@ export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacte
|
||||
character.position = { ...updated.position }
|
||||
if (!leafletMarker && character.map === mapview.mapid) {
|
||||
character.add(mapview)
|
||||
return
|
||||
}
|
||||
if (leafletMarker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
leafletMarker.setLatLng(position)
|
||||
if (!leafletMarker) return
|
||||
|
||||
const newLatLng = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
|
||||
const updateTooltip = (): void => {
|
||||
const gridX = Math.floor(character.position.x / TileSize)
|
||||
const gridY = Math.floor(character.position.y / TileSize)
|
||||
leafletMarker?.setTooltipContent(`${character.name} · ${gridX}, ${gridY}`)
|
||||
}
|
||||
|
||||
const from = leafletMarker.getLatLng()
|
||||
const latDelta = newLatLng.lat - from.lat
|
||||
const lngDelta = newLatLng.lng - from.lng
|
||||
const distSq = latDelta * latDelta + lngDelta * lngDelta
|
||||
if (distSq < 1e-12) {
|
||||
updateTooltip()
|
||||
return
|
||||
}
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
const duration = CHARACTER_MOVE_DURATION_MS
|
||||
|
||||
const tick = (): void => {
|
||||
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start
|
||||
const t = Math.min(1, elapsed / duration)
|
||||
const eased = easeOutQuad(t)
|
||||
leafletMarker?.setLatLng({
|
||||
lat: from.lat + latDelta * eased,
|
||||
lng: from.lng + lngDelta * eased,
|
||||
})
|
||||
if (t >= 1) {
|
||||
animationFrameId = null
|
||||
leafletMarker?.setLatLng(newLatLng)
|
||||
updateTooltip()
|
||||
return
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
},
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type L from 'leaflet'
|
||||
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes'
|
||||
import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
|
||||
export interface MarkerData {
|
||||
id: number
|
||||
@@ -48,7 +48,7 @@ export interface MarkerIconOptions {
|
||||
fallbackIconUrl?: string
|
||||
}
|
||||
|
||||
export type LeafletApi = typeof import('leaflet')
|
||||
export type LeafletApi = L
|
||||
|
||||
export function createMarker(
|
||||
data: MarkerData,
|
||||
@@ -85,10 +85,16 @@ export function createMarker(
|
||||
if (!marker.hidden) {
|
||||
const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path)
|
||||
const fallback = iconOptions?.fallbackIconUrl
|
||||
const iconUrl =
|
||||
marker.name === 'Cave' && marker.image === 'gfx/terobjs/mm/custom'
|
||||
? resolve('gfx/terobjs/mm/cave.png')
|
||||
: marker.image === 'gfx/terobjs/mm/custom'
|
||||
? resolve('gfx/terobjs/mm/custom.png')
|
||||
: resolve(`${marker.image}.png`)
|
||||
let icon: L.Icon
|
||||
if (marker.image === 'gfx/terobjs/mm/custom') {
|
||||
if (marker.image === 'gfx/terobjs/mm/custom' && marker.name !== 'Cave') {
|
||||
icon = new ImageIcon({
|
||||
iconUrl: resolve('gfx/terobjs/mm/custom.png'),
|
||||
iconUrl,
|
||||
iconSize: [21, 23],
|
||||
iconAnchor: [11, 21],
|
||||
popupAnchor: [1, 3],
|
||||
@@ -97,14 +103,22 @@ export function createMarker(
|
||||
})
|
||||
} else {
|
||||
icon = new ImageIcon({
|
||||
iconUrl: resolve(`${marker.image}.png`),
|
||||
iconUrl,
|
||||
iconSize: [32, 32],
|
||||
fallbackIconUrl: fallback,
|
||||
})
|
||||
}
|
||||
|
||||
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||
leafletMarker = L.marker(position, { icon, title: marker.name })
|
||||
leafletMarker = L.marker(position, { icon })
|
||||
const gridX = Math.floor(marker.position.x / TileSize)
|
||||
const gridY = Math.floor(marker.position.y / TileSize)
|
||||
const tooltipContent = `${marker.name} · ${gridX}, ${gridY}`
|
||||
leafletMarker.bindTooltip(tooltipContent, {
|
||||
direction: 'top',
|
||||
permanent: false,
|
||||
offset: L.point(0, -14),
|
||||
})
|
||||
leafletMarker.addTo(mapview.markerLayer)
|
||||
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
|
||||
if (markerEl) markerEl.setAttribute('aria-label', marker.name)
|
||||
@@ -125,6 +139,9 @@ export function createMarker(
|
||||
if (leafletMarker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
leafletMarker.setLatLng(position)
|
||||
const gridX = Math.floor(updated.position.x / TileSize)
|
||||
const gridY = Math.floor(updated.position.y / TileSize)
|
||||
leafletMarker.setTooltipContent(`${marker.name} · ${gridX}, ${gridY}`)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export const SmartTileLayer = L.TileLayer.extend({
|
||||
return Util.template(this._url, Util.extend(data, this.options))
|
||||
},
|
||||
|
||||
refresh(x: number, y: number, z: number) {
|
||||
refresh(x: number, y: number, z: number): boolean {
|
||||
let zoom = z
|
||||
const maxZoom = this.options.maxZoom
|
||||
const zoomReverse = this.options.zoomReverse
|
||||
@@ -71,19 +71,20 @@ export const SmartTileLayer = L.TileLayer.extend({
|
||||
|
||||
const key = `${x}:${y}:${zoom}`
|
||||
const tile = this._tiles[key]
|
||||
if (!tile?.el) return
|
||||
if (!tile?.el) return false
|
||||
const newUrl = this.getTrueTileUrl({ x, y }, z)
|
||||
if (tile.el.dataset.tileUrl === newUrl) return
|
||||
if (tile.el.dataset.tileUrl === newUrl) return true
|
||||
tile.el.dataset.tileUrl = newUrl
|
||||
tile.el.src = newUrl
|
||||
tile.el.classList.add('tile-fresh')
|
||||
const el = tile.el
|
||||
setTimeout(() => el.classList.remove('tile-fresh'), 400)
|
||||
return true
|
||||
},
|
||||
}) as unknown as new (urlTemplate: string, options?: L.TileLayerOptions) => L.TileLayer & {
|
||||
cache: SmartTileLayerCache
|
||||
invalidTile: string
|
||||
map: number
|
||||
getTrueTileUrl: (coords: { x: number; y: number }, zoom: number) => string
|
||||
refresh: (x: number, y: number, z: number) => void
|
||||
refresh: (x: number, y: number, z: number) => boolean
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ export function uniqueListUpdate<T extends Identifiable>(
|
||||
if (addCallback) {
|
||||
elementsToAdd.forEach((it) => addCallback(it))
|
||||
}
|
||||
elementsToRemove.forEach((it) => delete list.elements[String(it.id)])
|
||||
const toRemove = new Set(elementsToRemove.map((it) => String(it.id)))
|
||||
list.elements = Object.fromEntries(
|
||||
Object.entries(list.elements).filter(([id]) => !toRemove.has(id))
|
||||
) as Record<string, T>
|
||||
elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
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 }),
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon: vi.fn().mockImplementation(() => ({})),
|
||||
},
|
||||
const Icon = vi.fn().mockImplementation(function (this: unknown) {
|
||||
return {}
|
||||
})
|
||||
const L = {
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon: vi.fn().mockImplementation(() => ({})),
|
||||
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,
|
||||
}))
|
||||
|
||||
import type L from 'leaflet'
|
||||
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
|
||||
|
||||
function getL(): L {
|
||||
return require('leaflet').default
|
||||
return leafletMock as unknown as L
|
||||
}
|
||||
|
||||
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
|
||||
@@ -44,12 +57,12 @@ function makeMapViewRef(mapid = 1): CharacterMapViewRef {
|
||||
map: {
|
||||
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
removeLayer: vi.fn(),
|
||||
} as unknown as import('leaflet').Map,
|
||||
} as unknown as Map,
|
||||
mapid,
|
||||
markerLayer: {
|
||||
removeLayer: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
} as unknown as import('leaflet').LayerGroup,
|
||||
} as unknown as LayerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +94,21 @@ describe('createCharacter', () => {
|
||||
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<typeof vi.fn> }
|
||||
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)
|
||||
@@ -124,4 +152,36 @@ describe('createCharacter', () => {
|
||||
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<typeof vi.fn> }
|
||||
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<typeof vi.fn> }
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
import L from 'leaflet'
|
||||
import type { Map, LayerGroup } from 'leaflet'
|
||||
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
const markerMock = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn().mockReturnThis(),
|
||||
bindTooltip: vi.fn().mockReturnThis(),
|
||||
setTooltipContent: vi.fn().mockReturnThis(),
|
||||
openPopup: vi.fn().mockReturnThis(),
|
||||
closePopup: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon: class {},
|
||||
},
|
||||
const point = (x: number, y: number) => ({ x, y })
|
||||
const L = {
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon: class {},
|
||||
Icon: vi.fn(),
|
||||
point,
|
||||
}
|
||||
return { __esModule: true, default: L, ...L }
|
||||
})
|
||||
|
||||
vi.mock('~/lib/LeafletCustomTypes', () => ({
|
||||
HnHMaxZoom: 6,
|
||||
ImageIcon: class {
|
||||
constructor(_opts: Record<string, unknown>) {}
|
||||
},
|
||||
TileSize: 100,
|
||||
ImageIcon: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
|
||||
|
||||
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -42,12 +46,12 @@ function makeMapViewRef(): MapViewRef {
|
||||
return {
|
||||
map: {
|
||||
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
} as unknown as import('leaflet').Map,
|
||||
} as unknown as Map,
|
||||
mapid: 1,
|
||||
markerLayer: {
|
||||
removeLayer: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
} as unknown as import('leaflet').LayerGroup,
|
||||
} as unknown as LayerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +61,7 @@ describe('createMarker', () => {
|
||||
})
|
||||
|
||||
it('creates a marker with correct properties', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
expect(marker.id).toBe(1)
|
||||
expect(marker.name).toBe('Tower')
|
||||
expect(marker.position).toEqual({ x: 100, y: 200 })
|
||||
@@ -69,46 +73,46 @@ describe('createMarker', () => {
|
||||
})
|
||||
|
||||
it('detects quest type', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }))
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }), undefined, L)
|
||||
expect(marker.type).toBe('quest')
|
||||
})
|
||||
|
||||
it('detects quest type for bumling', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }))
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }), undefined, L)
|
||||
expect(marker.type).toBe('quest')
|
||||
})
|
||||
|
||||
it('detects custom type', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'custom' }))
|
||||
const marker = createMarker(makeMarkerData({ image: 'custom' }), undefined, L)
|
||||
expect(marker.type).toBe('custom')
|
||||
})
|
||||
|
||||
it('extracts type from gfx path', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }))
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }), undefined, L)
|
||||
expect(marker.type).toBe('village')
|
||||
})
|
||||
|
||||
it('starts with null leaflet marker', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
expect(marker.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('add creates a leaflet marker for non-hidden markers', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
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 marker = createMarker(makeMarkerData({ hidden: true }), undefined, L)
|
||||
const mapview = makeMapViewRef()
|
||||
marker.add(mapview)
|
||||
expect(mapview.map.unproject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update changes position and name', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
const mapview = makeMapViewRef()
|
||||
|
||||
marker.update(mapview, {
|
||||
@@ -122,7 +126,7 @@ describe('createMarker', () => {
|
||||
})
|
||||
|
||||
it('setClickCallback and setContextMenu work', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
const clickCb = vi.fn()
|
||||
const contextCb = vi.fn()
|
||||
|
||||
@@ -131,7 +135,7 @@ describe('createMarker', () => {
|
||||
})
|
||||
|
||||
it('remove on a marker without leaflet marker does nothing', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const marker = createMarker(makeMarkerData(), undefined, L)
|
||||
const mapview = makeMapViewRef()
|
||||
marker.remove(mapview) // should not throw
|
||||
expect(marker.leafletMarker).toBeNull()
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
placeholder="Search users…"
|
||||
class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
|
||||
aria-label="Search users"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto">
|
||||
<div
|
||||
@@ -101,7 +101,7 @@
|
||||
placeholder="Search maps…"
|
||||
class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
|
||||
aria-label="Search maps"
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[60vh] overflow-y-auto">
|
||||
<table class="table table-sm table-zebra min-w-[32rem]">
|
||||
@@ -121,7 +121,7 @@
|
||||
</th>
|
||||
<th scope="col">Hidden</th>
|
||||
<th scope="col">Priority</th>
|
||||
<th scope="col" class="text-right"></th>
|
||||
<th scope="col" class="text-right"/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -159,7 +159,7 @@
|
||||
v-model="settings.prefix"
|
||||
type="text"
|
||||
class="input input-sm w-full min-h-11 touch-manipulation"
|
||||
/>
|
||||
>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset w-full max-w-xs">
|
||||
<label class="label" for="admin-settings-title">Title</label>
|
||||
@@ -168,7 +168,7 @@
|
||||
v-model="settings.title"
|
||||
type="text"
|
||||
class="input input-sm w-full min-h-11 touch-manipulation"
|
||||
/>
|
||||
>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide">
|
||||
@@ -177,7 +177,7 @@
|
||||
v-model="settings.defaultHide"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
>
|
||||
Default hide new maps
|
||||
</label>
|
||||
</fieldset>
|
||||
@@ -211,7 +211,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" />
|
||||
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" >
|
||||
<button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()">
|
||||
Choose merge file
|
||||
</button>
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
<div class="container mx-auto p-4 max-w-2xl min-w-0">
|
||||
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
|
||||
|
||||
<form v-if="map" @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<form v-if="map" class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="name">Name</label>
|
||||
<input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required />
|
||||
<input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required >
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.hidden" type="checkbox" class="checkbox" />
|
||||
<input v-model="form.hidden" type="checkbox" class="checkbox" >
|
||||
<span>Hidden</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.priority" type="checkbox" class="checkbox" />
|
||||
<input v-model="form.priority" type="checkbox" class="checkbox" >
|
||||
<span>Priority</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="container mx-auto p-4 max-w-2xl min-w-0">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
|
||||
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="user">Username</label>
|
||||
<input
|
||||
@@ -12,7 +12,7 @@
|
||||
class="input min-h-11 touch-manipulation"
|
||||
required
|
||||
:readonly="!isNew"
|
||||
/>
|
||||
>
|
||||
</fieldset>
|
||||
<p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p>
|
||||
<PasswordInput
|
||||
@@ -25,7 +25,7 @@
|
||||
<label class="label">Auths</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`">
|
||||
<input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" />
|
||||
<input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" >
|
||||
<span>{{ a }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -14,18 +14,18 @@
|
||||
</a>
|
||||
<div class="divider text-sm">or</div>
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="user">User</label>
|
||||
<input
|
||||
ref="userInputRef"
|
||||
id="user"
|
||||
ref="userInputRef"
|
||||
v-model="user"
|
||||
type="text"
|
||||
class="input min-h-11 touch-manipulation"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
>
|
||||
</fieldset>
|
||||
<PasswordInput
|
||||
v-model="pass"
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
<icons-icon-settings />
|
||||
Change password
|
||||
</h2>
|
||||
<form @submit.prevent="changePass" class="flex flex-col gap-2">
|
||||
<form class="flex flex-col gap-2" @submit.prevent="changePass">
|
||||
<PasswordInput
|
||||
v-model="newPass"
|
||||
placeholder="New password"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
This is the first run. Create the administrator account using the bootstrap password
|
||||
from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>).
|
||||
</p>
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<PasswordInput
|
||||
v-model="pass"
|
||||
label="Bootstrap password"
|
||||
|
||||
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 |
@@ -1,11 +1,14 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [Vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
|
||||
27
frontend-nuxt/vitest.setup.ts
Normal file
27
frontend-nuxt/vitest.setup.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Expose Vue reactivity and lifecycle on globalThis so that .vue components
|
||||
* that rely on Nuxt auto-imports (ref, computed, etc.) work in Vitest.
|
||||
*/
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
reactive,
|
||||
watch,
|
||||
watchEffect,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
readonly,
|
||||
} from 'vue'
|
||||
|
||||
Object.assign(globalThis, {
|
||||
ref,
|
||||
computed,
|
||||
reactive,
|
||||
watch,
|
||||
watchEffect,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
readonly,
|
||||
})
|
||||
@@ -26,9 +26,10 @@ const (
|
||||
MultipartMaxMemory = 100 << 20 // 100 MB
|
||||
MergeMaxMemory = 500 << 20 // 500 MB
|
||||
ClientVersion = "4"
|
||||
SSETickInterval = 5 * time.Second
|
||||
SSETileChannelSize = 1000
|
||||
SSEMergeChannelSize = 5
|
||||
SSETickInterval = 1 * time.Second
|
||||
SSEKeepaliveInterval = 30 * time.Second
|
||||
SSETileChannelSize = 2000
|
||||
SSEMergeChannelSize = 5
|
||||
)
|
||||
|
||||
// App is the main application (map server) state.
|
||||
|
||||
@@ -93,16 +93,41 @@ func TestTopicClose(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopicDropsSlowSubscriber(t *testing.T) {
|
||||
func TestTopicSkipsFullChannel(t *testing.T) {
|
||||
topic := &app.Topic[int]{}
|
||||
slow := make(chan *int) // unbuffered, will block
|
||||
slow := make(chan *int) // unbuffered, so Send will skip this subscriber
|
||||
fast := make(chan *int, 10)
|
||||
topic.Watch(slow)
|
||||
topic.Watch(fast)
|
||||
|
||||
val := 42
|
||||
topic.Send(&val) // should drop the slow subscriber
|
||||
topic.Send(&val) // slow is full (unbuffered), message dropped for slow only; fast receives
|
||||
topic.Send(&val)
|
||||
|
||||
// Fast subscriber got both messages
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case got := <-fast:
|
||||
if *got != 42 {
|
||||
t.Fatalf("fast got %d", *got)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("expected fast to have message %d", i+1)
|
||||
}
|
||||
}
|
||||
// Slow subscriber was skipped (channel full), not closed - channel still open and empty
|
||||
select {
|
||||
case _, ok := <-slow:
|
||||
if !ok {
|
||||
t.Fatal("slow channel should not be closed when subscriber is skipped")
|
||||
}
|
||||
t.Fatal("slow should have received no message")
|
||||
default:
|
||||
// slow is open and empty, which is correct
|
||||
}
|
||||
topic.Close()
|
||||
_, ok := <-slow
|
||||
if ok {
|
||||
t.Fatal("expected slow subscriber channel to be closed")
|
||||
t.Fatal("expected slow channel closed after topic.Close()")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func (h *Handlers) clientLocate(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
rw.Header().Set("Content-Type", "text/plain")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write([]byte(result))
|
||||
_, _ = rw.Write([]byte(result))
|
||||
}
|
||||
|
||||
func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -85,7 +85,7 @@ func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(rw).Encode(result.Response)
|
||||
_ = json.NewEncoder(rw).Encode(result.Response)
|
||||
}
|
||||
|
||||
func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -64,9 +64,11 @@ func (env *testEnv) createUser(t *testing.T, username, password string, auths ap
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
u := app.User{Pass: hash, Auths: auths}
|
||||
raw, _ := json.Marshal(u)
|
||||
env.st.Update(context.Background(), func(tx *bbolt.Tx) error {
|
||||
if err := env.st.Update(context.Background(), func(tx *bbolt.Tx) error {
|
||||
return env.st.PutUser(tx, username, raw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (env *testEnv) loginSession(t *testing.T, username string) string {
|
||||
@@ -90,7 +92,7 @@ func TestAPISetup_NoUsers(t *testing.T) {
|
||||
}
|
||||
|
||||
var resp struct{ SetupRequired bool }
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if !resp.SetupRequired {
|
||||
t.Fatal("expected setupRequired=true")
|
||||
}
|
||||
@@ -105,7 +107,7 @@ func TestAPISetup_WithUsers(t *testing.T) {
|
||||
env.h.APISetup(rr, req)
|
||||
|
||||
var resp struct{ SetupRequired bool }
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp.SetupRequired {
|
||||
t.Fatal("expected setupRequired=false with users")
|
||||
}
|
||||
@@ -196,7 +198,7 @@ func TestAPIMe_Authenticated(t *testing.T) {
|
||||
Username string `json:"username"`
|
||||
Auths []string `json:"auths"`
|
||||
}
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp.Username != "alice" {
|
||||
t.Fatalf("expected alice, got %s", resp.Username)
|
||||
}
|
||||
@@ -245,7 +247,7 @@ func TestAPIMeTokens_Authenticated(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp struct{ Tokens []string }
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if len(resp.Tokens) != 1 {
|
||||
t.Fatalf("expected 1 token, got %d", len(resp.Tokens))
|
||||
}
|
||||
@@ -396,7 +398,7 @@ func TestAdminSettings(t *testing.T) {
|
||||
DefaultHide bool `json:"defaultHide"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
json.NewDecoder(rr3.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(rr3.Body).Decode(&resp)
|
||||
if resp.Prefix != "pfx" || !resp.DefaultHide || resp.Title != "New Title" {
|
||||
t.Fatalf("unexpected settings: %+v", resp)
|
||||
}
|
||||
@@ -493,7 +495,7 @@ func TestAPIGetChars_NoMarkersAuth(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var chars []interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&chars)
|
||||
_ = json.NewDecoder(rr.Body).Decode(&chars)
|
||||
if len(chars) != 0 {
|
||||
t.Fatal("expected empty array without markers auth")
|
||||
}
|
||||
@@ -511,7 +513,7 @@ func TestAPIGetMarkers_NoMarkersAuth(t *testing.T) {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var markers []interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&markers)
|
||||
_ = json.NewDecoder(rr.Body).Decode(&markers)
|
||||
if len(markers) != 0 {
|
||||
t.Fatal("expected empty array without markers auth")
|
||||
}
|
||||
@@ -583,7 +585,7 @@ func TestAdminUserByName(t *testing.T) {
|
||||
Username string `json:"username"`
|
||||
Auths []string `json:"auths"`
|
||||
}
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp.Username != "bob" {
|
||||
t.Fatalf("expected bob, got %s", resp.Username)
|
||||
}
|
||||
@@ -613,9 +615,11 @@ func TestClientRouter_Locate(t *testing.T) {
|
||||
}
|
||||
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
|
||||
raw, _ := json.Marshal(gd)
|
||||
env.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := env.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return env.st.PutGrid(tx, "g1", raw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/client/"+tokens[0]+"/locate?gridID=g1", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
@@ -55,14 +55,23 @@ func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
|
||||
tileCache := []services.TileCache{}
|
||||
raw, _ := json.Marshal(tileCache)
|
||||
fmt.Fprint(rw, "data: ")
|
||||
rw.Write(raw)
|
||||
_, _ = rw.Write(raw)
|
||||
fmt.Fprint(rw, "\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(app.SSETickInterval)
|
||||
defer ticker.Stop()
|
||||
keepaliveTicker := time.NewTicker(app.SSEKeepaliveInterval)
|
||||
defer keepaliveTicker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-keepaliveTicker.C:
|
||||
if _, err := fmt.Fprint(rw, ": keepalive\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
case e, ok := <-c:
|
||||
if !ok {
|
||||
return
|
||||
@@ -93,13 +102,15 @@ func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
fmt.Fprint(rw, "event: merge\n")
|
||||
fmt.Fprint(rw, "data: ")
|
||||
rw.Write(raw)
|
||||
_, _ = rw.Write(raw)
|
||||
fmt.Fprint(rw, "\n\n")
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
raw, _ := json.Marshal(tileCache)
|
||||
fmt.Fprint(rw, "data: ")
|
||||
rw.Write(raw)
|
||||
if _, err := rw.Write(raw); err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(rw, "\n\n")
|
||||
tileCache = tileCache[:0]
|
||||
flusher.Flush()
|
||||
@@ -152,7 +163,7 @@ func (h *Handlers) GridTile(rw http.ResponseWriter, req *http.Request) {
|
||||
rw.Header().Set("Content-Type", "image/png")
|
||||
rw.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
rw.Write(transparentPNG)
|
||||
_, _ = rw.Write(transparentPNG)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,9 @@ var migrations = []func(tx *bbolt.Tx) error{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users.Put(k, raw)
|
||||
if err := users.Put(k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestRunMigrations_FreshDB(t *testing.T) {
|
||||
t.Fatalf("migrations failed on fresh DB: %v", err)
|
||||
}
|
||||
|
||||
db.View(func(tx *bbolt.Tx) error {
|
||||
if err := db.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(store.BucketConfig)
|
||||
if b == nil {
|
||||
t.Fatal("expected config bucket after migrations")
|
||||
@@ -40,13 +40,15 @@ func TestRunMigrations_FreshDB(t *testing.T) {
|
||||
t.Fatalf("expected default title, got %s", title)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if tx, _ := db.Begin(false); tx != nil {
|
||||
if tx.Bucket(store.BucketOAuthStates) == nil {
|
||||
t.Fatal("expected oauth_states bucket after migrations")
|
||||
}
|
||||
tx.Rollback()
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ func TestRunMigrations_Idempotent(t *testing.T) {
|
||||
t.Fatalf("second run failed: %v", err)
|
||||
}
|
||||
|
||||
db.View(func(tx *bbolt.Tx) error {
|
||||
if err := db.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(store.BucketConfig)
|
||||
if b == nil {
|
||||
t.Fatal("expected config bucket")
|
||||
@@ -71,7 +73,9 @@ func TestRunMigrations_Idempotent(t *testing.T) {
|
||||
t.Fatal("expected version key")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigrations_SetsVersion(t *testing.T) {
|
||||
@@ -81,11 +85,13 @@ func TestRunMigrations_SetsVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
var version string
|
||||
db.View(func(tx *bbolt.Tx) error {
|
||||
if err := db.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket(store.BucketConfig)
|
||||
version = string(b.Get([]byte("version")))
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if version == "" || version == "0" {
|
||||
t.Fatalf("expected non-zero version, got %q", version)
|
||||
|
||||
@@ -101,7 +101,9 @@ func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
|
||||
return err
|
||||
}
|
||||
for _, tok := range u.Tokens {
|
||||
s.st.DeleteToken(tx, tok)
|
||||
if err := s.st.DeleteToken(tx, tok); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.st.DeleteUser(tx, username)
|
||||
@@ -129,17 +131,25 @@ func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultH
|
||||
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if prefix != nil {
|
||||
s.st.PutConfig(tx, "prefix", []byte(*prefix))
|
||||
if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if defaultHide != nil {
|
||||
if *defaultHide {
|
||||
s.st.PutConfig(tx, "defaultHide", []byte("1"))
|
||||
if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
s.st.DeleteConfig(tx, "defaultHide")
|
||||
if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if title != nil {
|
||||
s.st.PutConfig(tx, "title", []byte(*title))
|
||||
if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -264,7 +274,9 @@ func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
grids.Delete(id)
|
||||
if err := grids.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -310,7 +322,9 @@ func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int
|
||||
g.Coord.X += diff.X
|
||||
g.Coord.Y += diff.Y
|
||||
raw, _ := json.Marshal(g)
|
||||
grids.Put(k, raw)
|
||||
if err := grids.Put(k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -367,7 +381,9 @@ func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
|
||||
}
|
||||
m.Hidden = true
|
||||
raw, _ = json.Marshal(m)
|
||||
grid.Put(key, raw)
|
||||
if err := grid.Put(key, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ func TestToggleMapHidden(t *testing.T) {
|
||||
admin, _ := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
admin.UpdateMap(ctx, 1, "world", false, false)
|
||||
_ = admin.UpdateMap(ctx, 1, "world", false, false)
|
||||
|
||||
mi, err := admin.ToggleMapHidden(ctx, 1)
|
||||
if err != nil {
|
||||
@@ -257,19 +257,27 @@ func TestWipe(t *testing.T) {
|
||||
admin, st := newTestAdmin(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
st.PutGrid(tx, "g1", []byte("data"))
|
||||
st.PutMap(tx, 1, []byte("data"))
|
||||
st.PutTile(tx, 1, 0, "0_0", []byte("data"))
|
||||
st.CreateMarkersBuckets(tx)
|
||||
return nil
|
||||
})
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutGrid(tx, "g1", []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutMap(tx, 1, []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := st.CreateMarkersBuckets(tx)
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := admin.Wipe(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if st.GetGrid(tx, "g1") != nil {
|
||||
t.Fatal("expected grids wiped")
|
||||
}
|
||||
@@ -283,7 +291,9 @@ func TestWipe(t *testing.T) {
|
||||
t.Fatal("expected markers wiped")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMap_NotFound(t *testing.T) {
|
||||
|
||||
@@ -53,7 +53,7 @@ func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Se
|
||||
return nil
|
||||
}
|
||||
var sess *app.Session
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetSession(tx, c.Value)
|
||||
if raw == nil {
|
||||
return nil
|
||||
@@ -77,7 +77,9 @@ func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Se
|
||||
}
|
||||
sess.Auths = u.Auths
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
@@ -165,7 +167,7 @@ func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *a
|
||||
// SetupRequired returns true if no users exist (first run).
|
||||
func (s *AuthService) SetupRequired(ctx context.Context) bool {
|
||||
var required bool
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if s.st.UserCount(tx) == 0 {
|
||||
required = true
|
||||
}
|
||||
@@ -181,7 +183,7 @@ func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootst
|
||||
}
|
||||
var created bool
|
||||
var u *app.User
|
||||
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if s.st.GetUser(tx, "admin") != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -200,7 +202,9 @@ func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootst
|
||||
created = true
|
||||
u = &user
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return nil
|
||||
}
|
||||
if created {
|
||||
return u
|
||||
}
|
||||
@@ -239,7 +243,7 @@ func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string)
|
||||
}
|
||||
token := hex.EncodeToString(tokenRaw)
|
||||
var tokens []string
|
||||
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
_ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
uRaw := s.st.GetUser(tx, username)
|
||||
u := app.User{}
|
||||
if uRaw != nil {
|
||||
@@ -250,7 +254,9 @@ func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string)
|
||||
u.Tokens = append(u.Tokens, token)
|
||||
tokens = u.Tokens
|
||||
buf, _ := json.Marshal(u)
|
||||
s.st.PutUser(tx, username, buf)
|
||||
if err := s.st.PutUser(tx, username, buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.st.PutToken(tx, token, username)
|
||||
})
|
||||
return tokens
|
||||
@@ -522,7 +528,9 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
|
||||
user.Email = email
|
||||
}
|
||||
raw, _ = json.Marshal(user)
|
||||
s.st.PutUser(tx, username, raw)
|
||||
if err := s.st.PutUser(tx, username, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -41,9 +41,11 @@ func createUser(t *testing.T, st *store.Store, username, password string, auths
|
||||
}
|
||||
u := app.User{Pass: hash, Auths: auths}
|
||||
raw, _ := json.Marshal(u)
|
||||
st.Update(context.Background(), func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(context.Background(), func(tx *bbolt.Tx) error {
|
||||
return st.PutUser(tx, username, raw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRequired_EmptyDB(t *testing.T) {
|
||||
@@ -246,9 +248,11 @@ func TestGetUserTokensAndPrefix(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.PutConfig(tx, "prefix", []byte("myprefix"))
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
auth.GenerateTokenForUser(ctx, "alice")
|
||||
tokens, prefix := auth.GetUserTokensAndPrefix(ctx, "alice")
|
||||
@@ -288,9 +292,11 @@ func TestValidateClientToken_NoUploadPerm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP})
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.PutToken(tx, "tok123", "alice")
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := auth.ValidateClientToken(ctx, "tok123")
|
||||
if err == nil {
|
||||
|
||||
@@ -141,7 +141,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grids.Put([]byte(grid), raw)
|
||||
if err := grids.Put([]byte(grid), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
greq.GridRequests = append(greq.GridRequests, grid)
|
||||
}
|
||||
}
|
||||
@@ -192,7 +194,9 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grids.Put([]byte(grid), raw)
|
||||
if err := grids.Put([]byte(grid), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
greq.GridRequests = append(greq.GridRequests, grid)
|
||||
}
|
||||
}
|
||||
@@ -207,7 +211,7 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
}
|
||||
}
|
||||
if len(maps) > 1 {
|
||||
grids.ForEach(func(k, v []byte) error {
|
||||
if err := grids.ForEach(func(k, v []byte) error {
|
||||
gd := app.GridData{}
|
||||
if err := json.Unmarshal(v, &gd); err != nil {
|
||||
return err
|
||||
@@ -244,16 +248,22 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
File: td.File,
|
||||
})
|
||||
}
|
||||
grids.Put(k, raw)
|
||||
if err := grids.Put(k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for mergeid, merge := range maps {
|
||||
if mapid == mergeid {
|
||||
continue
|
||||
}
|
||||
mapB.Delete([]byte(strconv.Itoa(mergeid)))
|
||||
if err := mapB.Delete([]byte(strconv.Itoa(mergeid))); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("reporting merge", "from", mergeid, "to", mapid)
|
||||
s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
|
||||
}
|
||||
@@ -271,10 +281,10 @@ func (s *ClientService) ProcessGridUpdate(ctx context.Context, grup GridUpdate)
|
||||
func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraData string, fileReader io.Reader) error {
|
||||
if extraData != "" {
|
||||
ed := ExtraData{}
|
||||
json.Unmarshal([]byte(extraData), &ed)
|
||||
_ = json.Unmarshal([]byte(extraData), &ed)
|
||||
if ed.Season == 3 {
|
||||
needTile := false
|
||||
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetGrid(tx, id)
|
||||
if raw == nil {
|
||||
return fmt.Errorf("unknown grid id: %s", id)
|
||||
@@ -301,7 +311,9 @@ func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraD
|
||||
}
|
||||
raw, _ = json.Marshal(cur)
|
||||
return s.st.PutGrid(tx, id, raw)
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if !needTile {
|
||||
slog.Debug("ignoring tile upload: winter")
|
||||
return nil
|
||||
@@ -316,7 +328,7 @@ func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraD
|
||||
cur := app.GridData{}
|
||||
mapid := 0
|
||||
|
||||
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetGrid(tx, id)
|
||||
if raw == nil {
|
||||
return fmt.Errorf("unknown grid id: %s", id)
|
||||
@@ -331,7 +343,9 @@ func (s *ClientService) ProcessGridUpload(ctx context.Context, id string, extraD
|
||||
}
|
||||
raw, _ = json.Marshal(cur)
|
||||
return s.st.PutGrid(tx, id, raw)
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if updateTile {
|
||||
gridDir := fmt.Sprintf("%s/grids", s.mapSvc.GridStorage())
|
||||
@@ -374,7 +388,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
|
||||
}
|
||||
|
||||
gridDataByID := make(map[string]app.GridData)
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
for _, craw := range craws {
|
||||
raw := s.st.GetGrid(tx, craw.GridID)
|
||||
if raw != nil {
|
||||
@@ -385,7 +399,9 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username, _ := ctx.Value(app.ClientUsernameKey).(string)
|
||||
|
||||
@@ -465,6 +481,9 @@ func (s *ClientService) UploadMarkers(ctx context.Context, data []byte) error {
|
||||
if img == "" {
|
||||
img = "gfx/terobjs/mm/custom"
|
||||
}
|
||||
if mraw.Name == "Cave" {
|
||||
img = "gfx/terobjs/mm/cave"
|
||||
}
|
||||
id, err := idB.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -478,8 +497,12 @@ func (s *ClientService) UploadMarkers(ctx context.Context, data []byte) error {
|
||||
Image: img,
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
grid.Put(key, raw)
|
||||
idB.Put(idKey, key)
|
||||
if err := grid.Put(key, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := idB.Put(idKey, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -58,9 +58,11 @@ func TestClientLocate_Found(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
|
||||
raw, _ := json.Marshal(gd)
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.PutGrid(tx, "g1", raw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.Locate(ctx, "g1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -89,3 +91,31 @@ func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
|
||||
if markersb != nil {
|
||||
markersgrid := markersb.Bucket(store.BucketMarkersGrid)
|
||||
if markersgrid != nil {
|
||||
markersgrid.ForEach(func(k, v []byte) error {
|
||||
if err := markersgrid.ForEach(func(k, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
@@ -125,7 +125,9 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
|
||||
maps[gridMap[marker.GridID]].Markers[marker.GridID] = append(maps[gridMap[marker.GridID]].Markers[marker.GridID], marker)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -218,7 +220,11 @@ func (s *ExportService) Merge(ctx context.Context, zr *zip.Reader) error {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
io.Copy(f, r)
|
||||
if _, err := io.Copy(f, r); err != nil {
|
||||
r.Close()
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
r.Close()
|
||||
f.Close()
|
||||
newTiles[strings.TrimSuffix(filepath.Base(fhdr.Name), ".png")] = struct{}{}
|
||||
@@ -290,8 +296,12 @@ func (s *ExportService) processMergeJSON(
|
||||
Image: img,
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
mgrid.Put(key, raw)
|
||||
idB.Put(idKey, key)
|
||||
if err := mgrid.Put(key, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := idB.Put(idKey, key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +343,9 @@ func (s *ExportService) processMergeJSON(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grids.Put([]byte(grid), raw)
|
||||
if err := grids.Put([]byte(grid), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -372,11 +384,13 @@ func (s *ExportService) processMergeJSON(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grids.Put([]byte(grid), raw)
|
||||
if err := grids.Put([]byte(grid), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(existingMaps) > 1 {
|
||||
grids.ForEach(func(k, v []byte) error {
|
||||
if err := grids.ForEach(func(k, v []byte) error {
|
||||
gd := app.GridData{}
|
||||
if err := json.Unmarshal(v, &gd); err != nil {
|
||||
return err
|
||||
@@ -413,16 +427,22 @@ func (s *ExportService) processMergeJSON(
|
||||
File: td.File,
|
||||
})
|
||||
}
|
||||
grids.Put(k, raw)
|
||||
if err := grids.Put(k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for mergeid, merge := range existingMaps {
|
||||
if mapid == mergeid {
|
||||
continue
|
||||
}
|
||||
mapB.Delete([]byte(strconv.Itoa(mergeid)))
|
||||
if err := mapB.Delete([]byte(strconv.Itoa(mergeid))); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("reporting merge", "from", mergeid, "to", mapid)
|
||||
s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
|
||||
}
|
||||
|
||||
@@ -47,12 +47,14 @@ func TestExport_WithGrid(t *testing.T) {
|
||||
gdRaw, _ := json.Marshal(gd)
|
||||
mi := app.MapInfo{ID: 1, Name: "test", Hidden: false}
|
||||
miRaw, _ := json.Marshal(mi)
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutGrid(tx, "g1", gdRaw); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.PutMap(tx, 1, miRaw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := export.Export(ctx, &buf)
|
||||
if err != nil {
|
||||
|
||||
@@ -218,7 +218,7 @@ func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z
|
||||
|
||||
// SaveTile persists a tile and broadcasts the update.
|
||||
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
|
||||
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
_ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
td := &app.TileData{
|
||||
MapID: mapid,
|
||||
Coord: c,
|
||||
@@ -293,7 +293,7 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
b.ForEach(func(k, v []byte) error {
|
||||
if err := b.ForEach(func(k, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
@@ -306,8 +306,12 @@ func (s *MapService) RebuildZooms(ctx context.Context) error {
|
||||
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
|
||||
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
|
||||
return nil
|
||||
})
|
||||
tx.DeleteBucket(store.BucketTiles)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.DeleteBucket(store.BucketTiles); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
slog.Error("RebuildZooms: failed to update store", "error", err)
|
||||
@@ -364,7 +368,7 @@ func (s *MapService) WatchMerges() chan *app.Merge {
|
||||
// GetAllTileCache returns all tiles for the initial SSE cache dump.
|
||||
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
|
||||
var cache []TileCache
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
_ = s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -57,9 +57,11 @@ func TestGetConfig(t *testing.T) {
|
||||
svc, st := newTestMapService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.PutConfig(tx, "title", []byte("Test Map"))
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config, err := svc.GetConfig(ctx, app.Auths{app.AUTH_MAP})
|
||||
if err != nil {
|
||||
@@ -93,9 +95,11 @@ func TestGetConfig_Empty(t *testing.T) {
|
||||
func TestGetPage(t *testing.T) {
|
||||
svc, st := newTestMapService(t)
|
||||
ctx := context.Background()
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.PutConfig(tx, "title", []byte("Map Page"))
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
page, err := svc.GetPage(ctx)
|
||||
if err != nil {
|
||||
@@ -112,9 +116,11 @@ func TestGetGrid(t *testing.T) {
|
||||
|
||||
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 5, Y: 10}}
|
||||
raw, _ := json.Marshal(gd)
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.PutGrid(tx, "g1", raw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := svc.GetGrid(ctx, "g1")
|
||||
if err != nil {
|
||||
@@ -158,11 +164,17 @@ func TestGetMaps_HiddenFilter(t *testing.T) {
|
||||
mi2 := app.MapInfo{ID: 2, Name: "hidden", Hidden: true}
|
||||
raw1, _ := json.Marshal(mi1)
|
||||
raw2, _ := json.Marshal(mi2)
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
st.PutMap(tx, 1, raw1)
|
||||
st.PutMap(tx, 2, raw2)
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutMap(tx, 1, raw1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutMap(tx, 2, raw2); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
maps, err := svc.GetMaps(ctx, false)
|
||||
if err != nil {
|
||||
@@ -201,14 +213,18 @@ func TestGetMarkers_WithData(t *testing.T) {
|
||||
m := app.Marker{Name: "Tower", ID: 1, GridID: "g1", Position: app.Position{X: 10, Y: 20}, Image: "gfx/terobjs/mm/tower"}
|
||||
mRaw, _ := json.Marshal(m)
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
st.PutGrid(tx, "g1", gdRaw)
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutGrid(tx, "g1", gdRaw); err != nil {
|
||||
return err
|
||||
}
|
||||
grid, _, err := st.CreateMarkersBuckets(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return grid.Put([]byte("g1_10_20"), mRaw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
markers, err := svc.GetMarkers(ctx)
|
||||
if err != nil {
|
||||
@@ -233,9 +249,11 @@ func TestGetTile(t *testing.T) {
|
||||
|
||||
td := app.TileData{MapID: 1, Coord: app.Coord{X: 0, Y: 0}, Zoom: 0, File: "grids/g1.png", Cache: 12345}
|
||||
raw, _ := json.Marshal(td)
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.PutTile(tx, 1, 0, "0_0", raw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := svc.GetTile(ctx, 1, app.Coord{X: 0, Y: 0}, 0)
|
||||
if got == nil {
|
||||
@@ -287,9 +305,11 @@ func TestGetAllTileCache_WithData(t *testing.T) {
|
||||
|
||||
td := app.TileData{MapID: 1, Coord: app.Coord{X: 1, Y: 2}, Zoom: 0, Cache: 999}
|
||||
raw, _ := json.Marshal(td)
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.PutTile(tx, 1, 0, "1_2", raw)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cache := svc.GetAllTileCache(ctx)
|
||||
if len(cache) != 1 {
|
||||
|
||||
@@ -25,12 +25,14 @@ func TestUserCRUD(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Verify user doesn't exist on empty DB.
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if got := st.GetUser(tx, "alice"); got != nil {
|
||||
t.Fatal("expected nil user before creation")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create user.
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
@@ -40,7 +42,7 @@ func TestUserCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify user exists and count is correct (separate transaction for accurate Stats).
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
got := st.GetUser(tx, "alice")
|
||||
if got == nil || string(got) != `{"pass":"hash"}` {
|
||||
t.Fatalf("expected user data, got %s", got)
|
||||
@@ -49,7 +51,9 @@ func TestUserCRUD(t *testing.T) {
|
||||
t.Fatalf("expected 1 user, got %d", c)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Delete user.
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
@@ -58,31 +62,41 @@ func TestUserCRUD(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if got := st.GetUser(tx, "alice"); got != nil {
|
||||
t.Fatal("expected nil user after deletion")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForEachUser(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
st.PutUser(tx, "alice", []byte("1"))
|
||||
st.PutUser(tx, "bob", []byte("2"))
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutUser(tx, "alice", []byte("1")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutUser(tx, "bob", []byte("2")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.ForEachUser(tx, func(k, _ []byte) error {
|
||||
names = append(names, string(k))
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Fatalf("expected 2 users, got %d", len(names))
|
||||
}
|
||||
@@ -91,12 +105,14 @@ func TestForEachUser(t *testing.T) {
|
||||
func TestUserCountEmptyBucket(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if c := st.UserCount(tx); c != 0 {
|
||||
t.Fatalf("expected 0 users on empty db, got %d", c)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCRUD(t *testing.T) {
|
||||
@@ -211,19 +227,27 @@ func TestForEachMap(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
st.PutMap(tx, 1, []byte("a"))
|
||||
st.PutMap(tx, 2, []byte("b"))
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutMap(tx, 1, []byte("a")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutMap(tx, 2, []byte("b")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.ForEachMap(tx, func(_, _ []byte) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 maps, got %d", count)
|
||||
}
|
||||
@@ -290,20 +314,30 @@ func TestForEachTile(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
st.PutTile(tx, 1, 0, "0_0", []byte("a"))
|
||||
st.PutTile(tx, 1, 1, "0_0", []byte("b"))
|
||||
st.PutTile(tx, 2, 0, "1_1", []byte("c"))
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutTile(tx, 1, 0, "0_0", []byte("a")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutTile(tx, 1, 1, "0_0", []byte("b")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.PutTile(tx, 2, 0, "1_1", []byte("c")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
return st.ForEachTile(tx, func(_, _, _, _ []byte) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("expected 3 tiles, got %d", count)
|
||||
}
|
||||
@@ -313,7 +347,7 @@ func TestTilesMapBucket(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if b := st.GetTilesMapBucket(tx, 1); b != nil {
|
||||
t.Fatal("expected nil bucket before creation")
|
||||
}
|
||||
@@ -328,31 +362,39 @@ func TestTilesMapBucket(t *testing.T) {
|
||||
t.Fatal("expected non-nil after create")
|
||||
}
|
||||
return st.DeleteTilesMapBucket(tx, 1)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTilesBucket(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
st.PutTile(tx, 1, 0, "0_0", []byte("a"))
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.PutTile(tx, 1, 0, "0_0", []byte("a")); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.DeleteTilesBucket(tx)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if got := st.GetTile(tx, 1, 0, "0_0"); got != nil {
|
||||
t.Fatal("expected nil after bucket deletion")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkerBuckets(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if b := st.GetMarkersGridBucket(tx); b != nil {
|
||||
t.Fatal("expected nil grid bucket before creation")
|
||||
}
|
||||
@@ -376,7 +418,9 @@ func TestMarkerBuckets(t *testing.T) {
|
||||
t.Fatal("expected non-zero sequence")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStateCRUD(t *testing.T) {
|
||||
@@ -408,23 +452,29 @@ func TestBucketExistsAndDelete(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if st.BucketExists(tx, store.BucketUsers) {
|
||||
t.Fatal("expected bucket to not exist")
|
||||
}
|
||||
st.PutUser(tx, "alice", []byte("x"))
|
||||
if err := st.PutUser(tx, "alice", []byte("x")); err != nil {
|
||||
return err
|
||||
}
|
||||
if !st.BucketExists(tx, store.BucketUsers) {
|
||||
t.Fatal("expected bucket to exist")
|
||||
}
|
||||
return st.DeleteBucket(tx, store.BucketUsers)
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if err := st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if st.BucketExists(tx, store.BucketUsers) {
|
||||
t.Fatal("expected bucket to be deleted")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteBucketNonExistent(t *testing.T) {
|
||||
|
||||
@@ -15,7 +15,9 @@ func (t *Topic[T]) Watch(c chan *T) {
|
||||
t.c = append(t.c, c)
|
||||
}
|
||||
|
||||
// Send broadcasts to all subscribers.
|
||||
// Send broadcasts to all subscribers. If a subscriber's channel is full,
|
||||
// the message is dropped for that subscriber only; the subscriber is not
|
||||
// removed, so the connection stays alive and later updates are still delivered.
|
||||
func (t *Topic[T]) Send(b *T) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
@@ -23,9 +25,7 @@ func (t *Topic[T]) Send(b *T) {
|
||||
select {
|
||||
case t.c[i] <- b:
|
||||
default:
|
||||
close(t.c[i])
|
||||
t.c[i] = t.c[len(t.c)-1]
|
||||
t.c = t.c[:len(t.c)-1]
|
||||
// Channel full: drop this message for this subscriber, keep them subscribed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user