Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { convertToDayjsParam, diffDate, formatFullTime, formatTime, isMillisecondTimestamp } from '../src/utils'
describe('dayjs', () => {
it('判断时间戳是否毫秒', () => {
expect(isMillisecondTimestamp(1617962553000)).toBe(true)
expect(isMillisecondTimestamp(1617962553)).toBe(false)
expect(isMillisecondTimestamp(new Date())).toBe(false)
})
it('转换为 dayjs 接收参数', () => {
const date = new Date()
expect(convertToDayjsParam(1617962553000)).toBe(1617962553000)
expect(convertToDayjsParam(1617962553)).toBe(1617962553000)
expect(convertToDayjsParam(date)).toBe(date)
})
it('格式化时间', () => {
expect(formatTime(1617962553000)).toBe('2021-04-09')
expect(formatTime(1617962553)).toBe('2021-04-09')
expect(formatFullTime(1617962553)).toBe('2021年04月09日 18:02:33')
})
it('计算日期差值', () => {
expect(diffDate(1713775988000, 1714207988000)).toBe(-5)
})
})
+198
View File
@@ -0,0 +1,198 @@
/* eslint-disable test/no-import-node-test */
import assert from 'node:assert/strict'
import test from 'node:test'
import {
assertSafeOutputPath,
buildAmapSearchParams,
buildQueryPlan,
dedupePois,
normalizeAmapPage,
normalizeAmapPoi,
parseAmapLocation,
parseCliArgs,
} from '../scripts/fetch-amap-pois.mjs'
const typeQuery = {
id: 'types:parks',
kind: 'types',
label: 'parks',
types: ['110101'],
}
const keywordQuery = {
id: 'keyword:1',
kind: 'keyword',
label: 'greenway',
keyword: 'greenway',
}
function rawPoi(overrides = {}) {
return {
id: 'B001',
name: 'Test Park',
parent: [],
location: '113.935,22.748',
type: 'Tourist Attraction;Park',
typecode: '110101',
pname: 'Guangdong Province',
pcode: '440000',
cityname: 'Shenzhen',
citycode: '0755',
adname: 'Guangming District',
adcode: '440311',
address: ['Park Road'],
business: {
alias: [],
tel: '0755-12345678',
rating: '4.7',
opentime_today: '06:00-22:00',
opentime_week: 'Mon-Sun 06:00-22:00',
},
photos: [
{ title: 'Gate', url: 'https://example.invalid/gate.jpg' },
{ title: 'Duplicate', url: 'https://example.invalid/gate.jpg' },
],
...overrides,
}
}
test('parseAmapLocation accepts valid coordinates and rejects malformed values', () => {
assert.deepEqual(parseAmapLocation('113.935,22.748'), {
longitude: 113.935,
latitude: 22.748,
})
assert.equal(parseAmapLocation('113.935'), null)
assert.equal(parseAmapLocation('181,22.748'), null)
})
test('normalizeAmapPoi maps only reviewed candidate fields without raw response data', () => {
const normalized = normalizeAmapPoi(rawPoi(), typeQuery)
assert.ok(normalized)
assert.equal(normalized.id, 'B001')
assert.equal(normalized.coordinateSystem, 'GCJ02')
assert.equal(normalized.address, 'Park Road')
assert.equal(normalized.parentId, null)
assert.deepEqual(normalized.business, {
alias: null,
telephone: '0755-12345678',
sourceRating: 4.7,
openingHoursToday: '06:00-22:00',
openingHoursWeek: 'Mon-Sun 06:00-22:00',
})
assert.deepEqual(normalized.photos, [
{ title: 'Gate', url: 'https://example.invalid/gate.jpg' },
])
assert.deepEqual(normalized.matchedQueries, [{
id: 'types:parks',
kind: 'types',
label: 'parks',
}])
assert.equal('raw' in normalized, false)
})
test('normalizeAmapPage applies exact adcode filtering and rejects unusable records', () => {
const page = normalizeAmapPage([
rawPoi(),
rawPoi({ id: 'B002', adcode: '440309' }),
rawPoi({ id: 'B003', location: [] }),
], typeQuery)
assert.deepEqual(page.pois.map(poi => poi.id), ['B001'])
assert.equal(page.filteredOutByAdcode, 1)
assert.equal(page.rejectedInvalid, 1)
})
test('dedupePois merges matches and unique photos by AMap ID without mutating inputs', () => {
const first = normalizeAmapPoi(rawPoi(), typeQuery)
const second = normalizeAmapPoi(rawPoi({
address: [],
photos: [
{ title: [], url: 'https://example.invalid/gate.jpg' },
{ title: 'Lake', url: 'https://example.invalid/lake.jpg' },
],
}), keywordQuery)
assert.ok(first)
assert.ok(second)
const firstSnapshot = JSON.stringify(first)
const secondSnapshot = JSON.stringify(second)
const result = dedupePois([first, second])
assert.equal(result.length, 1)
assert.equal(result[0].id, 'B001')
assert.equal(result[0].address, 'Park Road')
assert.equal(result[0].sourceOccurrences, 2)
assert.deepEqual(result[0].matchedQueries.map(query => query.id), [
'types:parks',
'keyword:1',
])
assert.deepEqual(result[0].photos, [
{ title: 'Gate', url: 'https://example.invalid/gate.jpg' },
{ title: 'Lake', url: 'https://example.invalid/lake.jpg' },
])
assert.equal(JSON.stringify(first), firstSnapshot)
assert.equal(JSON.stringify(second), secondSnapshot)
})
test('buildQueryPlan keeps type scans and keyword recall as separate requests', () => {
const plan = buildQueryPlan(
[{ name: 'parks', types: ['110101', '110103'] }],
['greenway'],
)
assert.deepEqual(plan, [
{
id: 'types:parks',
kind: 'types',
label: 'parks',
types: ['110101', '110103'],
},
{
id: 'keyword:1',
kind: 'keyword',
label: 'greenway',
keyword: 'greenway',
},
])
})
test('AMap request parameters always enforce Guangming District and strict city filtering', () => {
const params = buildAmapSearchParams(typeQuery, 25, 3)
assert.equal(params.get('region'), '440311')
assert.equal(params.get('city_limit'), 'true')
assert.equal(params.get('page_size'), '25')
assert.equal(params.get('page_num'), '3')
assert.equal(params.get('types'), '110101')
assert.equal(params.get('show_fields'), 'business,photos')
assert.equal(params.has('key'), false)
})
test('CLI rejects key arguments without echoing their value', () => {
const secret = 'must-not-appear'
assert.throws(
() => parseCliArgs([`--key=${secret}`]),
error => !error.message.includes(secret),
)
})
test('CLI limits pagination to the AMap 200-result window', () => {
assert.equal(parseCliArgs(['--no-keywords']).maxPages, 8)
assert.throws(
() => parseCliArgs(['--page-size', '25', '--max-pages', '9']),
/cannot exceed 8/,
)
})
test('generated output cannot be directed into source or test directories', () => {
const projectRoot = '/workspace/map-app'
assert.throws(
() => assertSafeOutputPath('src/data/candidates.json', projectRoot),
/Refusing to write/,
)
assert.equal(
assertSafeOutputPath('artifacts/candidates.json', projectRoot),
'/workspace/map-app/artifacts/candidates.json',
)
})
+64
View File
@@ -0,0 +1,64 @@
import type { PoiCategory, PoiSummary } from '@/domain/poi'
import { describe, expect, it } from 'vitest'
import { buildMarkerIdMap, createPoiMarkers } from '@/services/map'
const categories: PoiCategory[] = [{
code: 'park',
name: '公园绿地',
markerIcon: '/static/markers/park.png',
selectedMarkerIcon: '/static/markers/park-selected.png',
sortOrder: 1,
status: 'enabled',
}]
function summary(id: string): PoiSummary {
return {
id,
name: id,
categoryCode: 'park',
categoryName: '公园绿地',
tagCodes: ['family'],
tags: [{ code: 'family', name: '亲子友好', sortOrder: 1, status: 'enabled' }],
longitude: 113.9,
latitude: 22.7,
coordinateSystem: 'GCJ02',
coverImage: {
id: 'cover',
url: '/static/poi/placeholder.png',
source: 'test',
sourceReference: null,
copyrightStatus: 'cleared',
licenseEvidence: 'test',
reviewedBy: 'test',
reviewedAt: '2026-07-29T00:00:00Z',
updatedAt: '2026-07-29T00:00:00Z',
alt: 'test',
},
summary: 'test',
description: 'test description',
address: 'test address',
recommendationIndex: 4,
recommendationSource: 'official_editorial',
openingHoursText: '开放时间待确认',
}
}
describe('map marker adapter', () => {
it('keeps numeric IDs stable when filter order changes', () => {
const first = buildMarkerIdMap(['poi-b', 'poi-a'])
const second = buildMarkerIdMap(['poi-a', 'poi-b'])
expect(Array.from(first.entries())).toEqual(Array.from(second.entries()))
})
it('maps selected POI to selected marker asset', () => {
const pois = [summary('poi-a'), summary('poi-b')]
const map = buildMarkerIdMap(pois.map(poi => poi.id))
const markers = createPoiMarkers(pois, categories, map, 'poi-b')
const selectedMarkerId = Array.from(map.entries())
.find(([, poiId]) => poiId === 'poi-b')?.[0]
const selected = markers.find(marker => marker.id === selectedMarkerId)
expect(selected?.iconPath).toBe('/static/markers/park-selected.png')
expect(selected?.zIndex).toBe(10)
expect(selected?.height).toBeGreaterThan(selected?.width ?? 0)
})
})
+47
View File
@@ -0,0 +1,47 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { DEFAULT_GUANGMING_VIEWPORT, useLocationStore, useMapStore } from '@/stores'
describe('map session store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('keeps category, selected POI and viewport while navigating back', () => {
const store = useMapStore()
store.setCategory('urban_park')
store.selectPoi('poi_hongqiao_park')
store.updateViewport({ longitude: 113.94, latitude: 22.76, scale: 13 })
expect(store.categoryCode).toBe('urban_park')
expect(store.selectedPoiId).toBe('poi_hongqiao_park')
expect(store.viewport).toEqual({ longitude: 113.94, latitude: 22.76, scale: 13 })
})
it('ignores invalid viewport updates', () => {
const store = useMapStore()
store.updateViewport({ longitude: Number.NaN, latitude: 22.76, scale: 13 })
expect(store.viewport).toEqual(DEFAULT_GUANGMING_VIEWPORT)
})
it('keeps a validated user location in memory independently from the map viewport', () => {
const mapStore = useMapStore()
const locationStore = useLocationStore()
expect(locationStore.updateLocation(113.94, 22.76, 18)).toBe(true)
expect(locationStore.snapshot).toMatchObject({
longitude: 113.94,
latitude: 22.76,
accuracy: 18,
coordinateSystem: 'GCJ02',
})
expect(locationStore.status).toBe('ready')
expect(mapStore.viewport).toEqual(DEFAULT_GUANGMING_VIEWPORT)
})
it('rejects malformed user coordinates', () => {
const store = useLocationStore()
expect(store.updateLocation(181, 22.76, 10)).toBe(false)
expect(store.snapshot).toBeNull()
})
})
+242
View File
@@ -0,0 +1,242 @@
import { describe, expect, it } from 'vitest'
import { poiDataset } from '../src/data/poi/dataset'
import { createPoiRepository } from '../src/data/poi/repository'
import {
filterPois,
findPoiById,
formatOpeningHours,
formatRecommendationLabel,
type PoiDataset,
validatePoiDataset,
} from '../src/domain/poi'
function cloneDataset(): PoiDataset {
return JSON.parse(JSON.stringify(poiDataset)) as PoiDataset
}
describe('poi POC dataset', () => {
it('is usable while explicitly limiting its claim to a reviewed sample', () => {
const result = validatePoiDataset(poiDataset)
expect(result.isDatasetUsable).toBe(true)
expect(result.isValid).toBe(true)
expect(result.invalidPoiIds).toEqual([])
expect(result.validPois).toHaveLength(poiDataset.pois.length)
expect(result.validPois.length).toBeGreaterThanOrEqual(20)
expect(poiDataset.meta.coverageScope).toBe('reviewed_sample')
expect(poiDataset.meta.authoritativeBaselineProvided).toBe(false)
expect(poiDataset.meta.canClaimFullCoverage).toBe(false)
expect(poiDataset.meta.coverageLabel).toContain('非全域权威清单')
expect(result.issues.filter(issue => issue.code === 'POC_PLACEHOLDER_ASSET')).toHaveLength(poiDataset.images.length)
})
it('provides the seven requested presentation fields for every sample POI', () => {
for (const poi of poiDataset.pois) {
expect(poi.name.trim()).not.toBe('')
expect(poi.coverImageId.trim()).not.toBe('')
expect(Number.isFinite(poi.longitude)).toBe(true)
expect(Number.isFinite(poi.latitude)).toBe(true)
expect(poi.coordinateSystem).toBe('GCJ02')
expect(poi.description.trim()).not.toBe('')
expect(poi.openingHours).toBeDefined()
expect(poi.recommendationIndex).toBeGreaterThanOrEqual(1)
expect(poi.recommendationIndex).toBeLessThanOrEqual(5)
expect(poi.recommendationSource).toBe('poc_default')
expect(poi.tagCodes.length).toBeGreaterThanOrEqual(1)
expect(poi.tagCodes.length).toBeLessThanOrEqual(5)
expect(poi.sourceReference).toMatch(/^amap:[A-Z0-9]+$/)
}
})
})
describe('poi dataset validation', () => {
it('rejects a structurally invalid dataset as a whole', () => {
const invalid = cloneDataset()
invalid.categories[1].code = invalid.categories[0].code
const result = validatePoiDataset(invalid)
expect(result.isDatasetUsable).toBe(false)
expect(result.validPois).toEqual([])
expect(result.issues.some(issue => issue.code === 'DATASET_STRUCTURE_INVALID')).toBe(true)
})
it('returns a structural issue instead of throwing for malformed array members', () => {
const malformed = cloneDataset() as unknown as Record<string, unknown>
malformed.categories = [null]
expect(() => validatePoiDataset(malformed)).not.toThrow()
const result = validatePoiDataset(malformed)
expect(result.isDatasetUsable).toBe(false)
expect(result.issues).toEqual(expect.arrayContaining([
expect.objectContaining({ code: 'DATASET_STRUCTURE_INVALID', path: '$' }),
]))
})
it('isolates a POI with invalid fields without hiding valid records', () => {
const invalid = cloneDataset()
invalid.pois[0].description = ''
invalid.pois[0].tagCodes = []
const result = validatePoiDataset(invalid)
expect(result.isDatasetUsable).toBe(true)
expect(result.isValid).toBe(false)
expect(result.invalidPoiIds).toEqual(['poi_gm_culture_center'])
expect(result.validPois).toHaveLength(poiDataset.pois.length - 1)
expect(result.issues.some(issue => issue.code === 'REQUIRED_FIELD_MISSING')).toBe(true)
expect(result.issues.some(issue => issue.code === 'TAG_INVALID')).toBe(true)
})
it('blocks invalid coordinate systems, out-of-area points and fake scores', () => {
const invalid = cloneDataset()
const target = invalid.pois[0] as unknown as {
id: string
coordinateSystem: string
longitude: number
latitude: number
recommendationIndex: number
}
target.coordinateSystem = 'WGS84'
target.longitude = 120
target.latitude = 30
target.recommendationIndex = 4.5
const result = validatePoiDataset(invalid)
const targetCodes = result.issues
.filter(issue => issue.poiId === target.id)
.map(issue => issue.code)
expect(targetCodes).toContain('COORDINATE_SYSTEM_INVALID')
expect(targetCodes).toContain('OUTSIDE_TARGET_AREA')
expect(targetCodes).toContain('RECOMMENDATION_INVALID')
})
it('accepts both controlled recommendation sources and rejects unknown sources', () => {
const pocDataset = cloneDataset()
pocDataset.pois[0].recommendationSource = 'poc_default'
const pocResult = validatePoiDataset(pocDataset)
expect(pocResult.invalidPoiIds).not.toContain(pocDataset.pois[0].id)
expect(pocResult.issues.some(issue => issue.code === 'RECOMMENDATION_INVALID')).toBe(false)
const invalidDataset = cloneDataset()
const invalidPoi = invalidDataset.pois[0] as unknown as { id: string, recommendationSource: string }
invalidPoi.recommendationSource = 'amap_rating'
const invalidResult = validatePoiDataset(invalidDataset)
expect(invalidResult.invalidPoiIds).toContain(invalidPoi.id)
expect(invalidResult.issues).toEqual(expect.arrayContaining([
expect.objectContaining({
code: 'RECOMMENDATION_INVALID',
poiId: invalidPoi.id,
}),
]))
})
it('does not allow a sample package to claim authoritative full coverage', () => {
const invalid = cloneDataset()
invalid.meta.canClaimFullCoverage = true
const result = validatePoiDataset(invalid)
expect(result.isDatasetUsable).toBe(false)
expect(result.issues.some(issue => issue.path === 'meta.canClaimFullCoverage')).toBe(true)
})
it('warns about near-identical marker coordinates without blocking the dataset', () => {
const nearby = cloneDataset()
nearby.pois[1].longitude = nearby.pois[0].longitude
nearby.pois[1].latitude = nearby.pois[0].latitude
const result = validatePoiDataset(nearby)
expect(result.isDatasetUsable).toBe(true)
expect(result.invalidPoiIds).not.toContain(nearby.pois[1].id)
expect(result.issues).toEqual(expect.arrayContaining([
expect.objectContaining({
code: 'POTENTIAL_COORDINATE_OVERLAP',
severity: 'warning',
poiId: nearby.pois[1].id,
}),
]))
})
})
describe('poi selection and repository', () => {
it('filters by one category and an AND-combined set of controlled tags', () => {
const pastoral = filterPois(poiDataset.pois, { categoryCode: 'pastoral_leisure' })
const familyPhoto = filterPois(poiDataset.pois, { tagCodes: ['family_friendly', 'photo_spot'] })
expect(pastoral.map(poi => poi.id)).toEqual([
'poi_farm_grand_view',
'poi_happy_pastoral',
'poi_amap_b0ffkkmzsc',
])
expect(familyPhoto.map(poi => poi.id)).toEqual(['poi_hongqiao_park', 'poi_happy_pastoral'])
})
it('finds a POI only by its stable ID', () => {
expect(findPoiById(poiDataset.pois, 'poi_hongqiao_park')?.name).toBe('虹桥公园')
expect(findPoiById(poiDataset.pois, '虹桥公园')).toBeNull()
expect(findPoiById(poiDataset.pois, ' ')).toBeNull()
})
it('uses the mandated fallback for unknown opening hours', () => {
expect(formatOpeningHours({
status: 'unknown',
displayText: null,
note: null,
sourceUpdatedAt: null,
})).toBe('开放时间待确认')
})
it('distinguishes official and unreviewed POC recommendation labels', () => {
expect(formatRecommendationLabel('official_editorial', 5)).toBe('官方推荐指数 5/5')
expect(formatRecommendationLabel('poc_default', 4)).toBe('POC 推荐指数(待审核) 4/5')
})
it('returns resolved summaries and details from one dataset version', () => {
const repository = createPoiRepository()
const summaries = repository.getPoiSummaries({ categoryCode: 'urban_park' })
const detail = repository.getPoiById(summaries[0].id)
expect(repository.getDatasetMeta().datasetVersion).toBe(poiDataset.meta.datasetVersion)
expect(summaries.length).toBeGreaterThanOrEqual(10)
expect(detail?.id).toBe(summaries[0].id)
expect(detail?.category.code).toBe('urban_park')
expect(detail?.coverImage.id).toBe(detail?.coverImageId)
expect(detail?.openingHoursText).toBe('开放时间待确认')
expect(summaries[0].recommendationSource).toBe(detail?.recommendationSource)
expect(repository.getPoiById('missing')).toBeNull()
})
it('does not expose categories without published valid POIs', () => {
const dataset = cloneDataset()
dataset.categories.push({
code: 'empty_category',
name: '暂无点位',
markerIcon: '/static/logo.png',
selectedMarkerIcon: '/static/logo.png',
sortOrder: 999,
status: 'enabled',
})
const repository = createPoiRepository(dataset)
expect(repository.getCategories().some(category => category.code === 'empty_category')).toBe(false)
})
it('returns defensive copies so page state cannot mutate the source package', () => {
const repository = createPoiRepository()
const firstRead = repository.getPoiById('poi_hongqiao_park')
if (!firstRead)
throw new Error('test fixture missing')
firstRead.name = '被页面篡改'
firstRead.tags.splice(0)
const secondRead = repository.getPoiById('poi_hongqiao_park')
expect(secondRead?.name).toBe('虹桥公园')
expect(secondRead?.tags.length).toBeGreaterThan(0)
})
})
+12
View File
@@ -0,0 +1,12 @@
import { vi } from 'vitest'
import { unref } from 'vue'
const uniMock = {
getStorageSync: vi.fn(),
getSystemInfoSync: vi.fn(() => ({ uniPlatform: 'mp-weixin' })),
setStorage: vi.fn(),
showToast: vi.fn(),
}
vi.stubGlobal('uni', uniMock)
vi.stubGlobal('unref', unref)
+427
View File
@@ -0,0 +1,427 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getPoiRepository } from '../src/data/poi'
import {
adjustLocalPlan,
classifyDurationFit,
createLocalPlan,
hasSignificantDurationGap,
LEGACY_PLACE_ID_TO_POI_ID,
normalizePlanningRequest,
normalizePlanResponse,
normalizeTravelPreferences,
type PlanningRequest,
type PlanResponse,
resolveCanonicalPoiId,
type TravelPreferences,
} from '../src/domain/travel'
import {
createPlan,
loadPlan,
loadPreferences,
PLAN_STORAGE_KEY,
PREFERENCES_STORAGE_KEY,
savePlan,
savePreferences,
} from '../src/services/travel-assistant'
function preferences(overrides: Partial<TravelPreferences> = {}): TravelPreferences {
return {
destination: '深圳市光明区',
themes: ['亲子'],
duration: 'half_day',
pace: 'moderate',
interests: ['自然风光'],
adults: 2,
children: 1,
childAges: [6],
transport: 'public_transport',
budgetLevel: 'standard',
extraRequirements: '',
...overrides,
}
}
function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
function storageEnvelope(payload: unknown) {
return {
schemaVersion: 1,
datasetVersion: getPoiRepository().getDatasetMeta().datasetVersion,
savedAt: '2026-07-30T10:00:00.000Z',
payload,
}
}
Object.assign(uni, {
request: vi.fn(),
setStorageSync: vi.fn(),
})
describe('travel preferences', () => {
it('normalizes set-like fields in a deterministic domain order', () => {
const result = normalizeTravelPreferences(preferences({
themes: ['研学', '亲子', '研学'],
interests: ['摄影', '自然风光'],
extraRequirements: ' 慢一点 ',
}))
expect(result.themes).toEqual(['亲子', '研学'])
expect(result.interests).toEqual(['自然风光', '摄影'])
expect(result.extraRequirements).toBe('慢一点')
})
it('rejects invalid group and child-age combinations', () => {
expect(() => normalizeTravelPreferences(preferences({ adults: 0, children: 0, childAges: [] })))
.toThrow('出行总人数至少为 1')
expect(() => normalizeTravelPreferences(preferences({ children: 2, childAges: [6] })))
.toThrow('儿童年龄数量必须与儿童人数一致')
expect(() => normalizeTravelPreferences(preferences({ extraRequirements: 'x'.repeat(501) })))
.toThrow('不能超过 500 字')
})
})
describe('planned duration notice', () => {
it('detects a large gap without exposing the calculated difference', () => {
expect(hasSignificantDurationGap('half_day', 180)).toBe(true)
expect(hasSignificantDurationGap('full_day', 360)).toBe(true)
expect(hasSignificantDurationGap('half_day', 210)).toBe(false)
expect(hasSignificantDurationGap('full_day', 450)).toBe(false)
})
it('ignores invalid planned durations', () => {
expect(hasSignificantDurationGap('half_day', 0)).toBe(false)
expect(hasSignificantDurationGap('full_day', Number.NaN)).toBe(false)
})
it('classifies explicit minute budgets', () => {
expect(classifyDurationFit(240, 241)).toBe('overflow')
expect(classifyDurationFit(240, 180)).toBe('underfilled')
expect(classifyDurationFit(240, 210)).toBe('fits')
})
})
describe('planning requests', () => {
const repository = getPoiRepository()
const publishedIds = repository.getPublishedPois().map(poi => poi.id)
function request(overrides: Partial<PlanningRequest> = {}): PlanningRequest {
return {
mode: 'quick',
preferences: preferences(),
durationMinutes: 240,
selectedPoiIds: [],
...overrides,
}
}
it('enforces duration range and 30-minute steps', () => {
expect(() => normalizePlanningRequest(request({ durationMinutes: 119 }), repository)).toThrow('120 至 600')
expect(() => normalizePlanningRequest(request({ durationMinutes: 601 }), repository)).toThrow('120 至 600')
expect(() => normalizePlanningRequest(request({ durationMinutes: 125 }), repository)).toThrow('30 分钟')
expect(normalizePlanningRequest(request({ durationMinutes: 120 }), repository).durationMinutes).toBe(120)
expect(normalizePlanningRequest(request({ durationMinutes: 600 }), repository).durationMinutes).toBe(600)
})
it('validates quick and custom selections using only canonical repository IDs', () => {
expect(() => normalizePlanningRequest(request({ selectedPoiIds: [publishedIds[0]] }), repository))
.toThrow('快速规划不能预选点位')
expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: [publishedIds[0]] }), repository))
.toThrow('2 至 8')
expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: publishedIds.slice(0, 9) }), repository))
.toThrow('2 至 8')
expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: [publishedIds[0], publishedIds[0]] }), repository))
.toThrow('不能重复')
expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: ['guangming-hongqiao-park', publishedIds[0]] }), repository))
.toThrow('ID 无效')
expect(() => normalizePlanningRequest(request({ mode: 'custom', selectedPoiIds: ['unknown-poi', publishedIds[0]] }), repository))
.toThrow('ID 无效')
})
})
describe('canonical POI IDs', () => {
const repository = getPoiRepository()
it('keeps canonical IDs and explicitly maps only reviewed legacy IDs', () => {
expect(resolveCanonicalPoiId('poi_hongqiao_park', repository)).toBe('poi_hongqiao_park')
expect(resolveCanonicalPoiId('guangming-hongqiao-park', repository)).toBe('poi_hongqiao_park')
expect(resolveCanonicalPoiId('guangming-science-museum', repository)).toBeNull()
expect(resolveCanonicalPoiId('虹桥公园', repository)).toBeNull()
expect(resolveCanonicalPoiId(' ', repository)).toBeNull()
})
it('ensures every explicit legacy target exists in PoiRepository', () => {
Object.values(LEGACY_PLACE_ID_TO_POI_ID).forEach((poiId) => {
expect(repository.getPoiById(poiId), poiId).not.toBeNull()
})
})
it('canonicalizes legacy IDs and authoritative names in remote responses', () => {
const plan = createLocalPlan(preferences(), repository)
const remote = deepClone(plan) as PlanResponse
const otherItemIds = new Set(remote.itinerary.items.slice(1).map(item => item.placeId))
const [legacyId, canonicalId] = Object.entries(LEGACY_PLACE_ID_TO_POI_ID)
.find(([, poiId]) => !otherItemIds.has(poiId))!
remote.itinerary.items[0].placeId = legacyId
remote.itinerary.items[0].placeName = '模型编造的名称'
remote.itinerary.sources[0].placeId = legacyId
delete (remote as Partial<PlanResponse>).source
const normalized = normalizePlanResponse(remote, repository, 'remote_ai')
expect(normalized.itinerary.items[0].placeId).toBe(canonicalId)
expect(normalized.itinerary.items[0].placeName).toBe(repository.getPoiById(canonicalId)?.name)
expect(normalized.itinerary.sources[0].placeId).toBe(canonicalId)
expect(normalized.source).toBe('remote_ai')
})
it('safely rejects remote items without a reviewed mapping', () => {
const remote = deepClone(createLocalPlan(preferences(), repository))
remote.itinerary.items[0].placeId = 'guangming-science-museum'
expect(() => normalizePlanResponse(remote, repository, 'remote_ai'))
.toThrow('无法映射的地点 ID')
})
})
describe('deterministic local POC planner', () => {
const repository = getPoiRepository()
it('returns the same route and canonical IDs for equivalent input', () => {
const first = createLocalPlan(preferences({ themes: ['研学', '亲子'], interests: ['摄影', '自然风光'] }), repository)
const second = createLocalPlan(preferences({ themes: ['亲子', '研学'], interests: ['自然风光', '摄影'] }), repository)
expect(second).toEqual(first)
expect(first.source).toBe('local_poc')
first.itinerary.items.forEach((item) => {
expect(repository.getPoiById(item.placeId)).not.toBeNull()
expect(item.placeName).toBe(repository.getPoiById(item.placeId)?.name)
})
})
it('applies duration, pace and indoor requirements to the structure', () => {
const halfDay = createLocalPlan(preferences({ duration: 'half_day', pace: 'relaxed' }), repository)
const fullDay = createLocalPlan(preferences({ duration: 'full_day', pace: 'compact' }), repository)
const rainyDay = createLocalPlan(preferences({
duration: 'full_day',
pace: 'moderate',
interests: ['文化场馆'],
extraRequirements: '雨天,只安排室内',
}), repository)
expect(halfDay.itinerary.items.length).toBeGreaterThanOrEqual(1)
expect(halfDay.itinerary.items.length).toBeLessThanOrEqual(8)
expect(halfDay.itinerary.totalMinutes).toBeLessThanOrEqual(halfDay.itinerary.requestedMinutes)
expect(fullDay.itinerary.items.length).toBeGreaterThan(halfDay.itinerary.items.length)
expect(fullDay.itinerary.items.length).toBeLessThanOrEqual(8)
expect(fullDay.itinerary.totalMinutes).toBeLessThanOrEqual(fullDay.itinerary.requestedMinutes)
expect(rainyDay.itinerary.items.every(item => repository.getPoiById(item.placeId)?.tagCodes.includes('indoor_venue'))).toBe(true)
})
it('keeps times monotonic and includes estimated transfers in total minutes', () => {
const plan = createLocalPlan(preferences({ duration: 'full_day' }), repository)
const toMinutes = (clock: string) => {
const [hours, minutes] = clock.split(':').map(Number)
return hours * 60 + minutes
}
plan.itinerary.items.forEach((item, index) => {
expect(toMinutes(item.endTime)).toBeGreaterThan(toMinutes(item.startTime))
if (index > 0) {
expect(toMinutes(item.startTime)).toBeGreaterThanOrEqual(toMinutes(plan.itinerary.items[index - 1].endTime))
expect(item.transferFromPreviousMinutes).toBeGreaterThan(0)
}
})
expect(plan.itinerary.totalMinutes).toBe(toMinutes(plan.itinerary.items.at(-1)!.endTime) - 9 * 60)
})
it('includes origin-to-first transfer without serializing exact coordinates', () => {
const baseline = createLocalPlan(preferences({ duration: 'full_day' }), repository)
const target = repository.getPoiById(baseline.itinerary.items.at(-1)!.placeId)!
const located = createLocalPlan(
preferences({ duration: 'full_day' }),
repository,
undefined,
{ longitude: target.longitude, latitude: target.latitude, coordinateSystem: 'GCJ02' },
)
expect(located.itinerary.items[0].reason).toContain('距你当前位置较近')
expect(located.itinerary.items[0].transferFromPreviousMinutes).toBeGreaterThan(0)
expect(located.itinerary.totalMinutes).toBe(
located.itinerary.items[0].transferFromPreviousMinutes!
+ located.itinerary.items.reduce((total, item) => total
+ (Number(item.endTime.slice(0, 2)) * 60 + Number(item.endTime.slice(3)))
- (Number(item.startTime.slice(0, 2)) * 60 + Number(item.startTime.slice(3))), 0)
+ located.itinerary.items.slice(1).reduce((total, item) => total + (item.transferFromPreviousMinutes ?? 0), 0),
)
expect(located.itinerary.notes.join('')).toContain('精确坐标不会写入行程缓存')
expect(JSON.stringify(located)).not.toContain(String(target.longitude))
expect(JSON.stringify(located)).not.toContain(String(target.latitude))
})
it('ignores invalid planning origins and keeps the no-location route', () => {
const baseline = createLocalPlan(preferences(), repository)
const invalid = createLocalPlan(
preferences(),
repository,
undefined,
{ longitude: 999, latitude: 22.76, coordinateSystem: 'GCJ02' },
)
expect(invalid).toEqual(baseline)
})
it('supports deterministic text adjustments and avoids false positive negation', () => {
const initialPreferences = preferences({ duration: 'full_day', pace: 'compact' })
const initialPlan = createLocalPlan(initialPreferences, repository)
const slower = adjustLocalPlan(initialPlan, initialPreferences, '节奏慢一点', repository)
const repeated = adjustLocalPlan(slower.plan, slower.preferences, '节奏慢一点', repository)
const noIndoor = adjustLocalPlan(initialPlan, initialPreferences, '不要室内,安排户外公园', repository)
expect(slower.preferences.pace).toBe('relaxed')
expect(slower.plan.itinerary.items.length).toBeLessThan(initialPlan.itinerary.items.length)
expect(repeated.plan.itinerary.items.map(item => item.placeId))
.toEqual(slower.plan.itinerary.items.map(item => item.placeId))
expect(noIndoor.preferences.interests).not.toContain('文化场馆')
expect(noIndoor.plan.itinerary.items.every(item => !repository.getPoiById(item.placeId)?.tagCodes.includes('indoor_venue'))).toBe(true)
})
it('uses the local planner through the public service when no remote URL exists', async () => {
const request = vi.spyOn(uni, 'request')
const plan = await createPlan(preferences(), {
longitude: 113.94,
latitude: 22.76,
coordinateSystem: 'GCJ02',
})
expect(plan.source).toBe('local_poc')
expect(JSON.stringify(plan)).not.toContain('113.94')
expect(JSON.stringify(plan)).not.toContain('22.76')
expect(request).not.toHaveBeenCalled()
})
it('preserves all custom selections, reports overflow, and retains them during adjustment', () => {
const selectedPoiIds = repository.getPublishedPois().slice(0, 8).map(poi => poi.id)
const request: PlanningRequest = {
mode: 'custom',
preferences: preferences({ pace: 'relaxed' }),
durationMinutes: 120,
selectedPoiIds,
}
const plan = createLocalPlan(request, repository)
const adjusted = adjustLocalPlan(plan, request.preferences, '节奏慢一点', repository, undefined, request)
expect(plan.itinerary.items).toHaveLength(8)
expect(new Set(plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds))
expect(normalizePlanResponse(plan, repository).itinerary.items).toHaveLength(8)
expect(plan.itinerary.durationFitStatus).toBe('overflow')
expect(adjusted.plan.itinerary.planningMode).toBe('custom')
expect(adjusted.plan.itinerary.requestedMinutes).toBe(120)
expect(new Set(adjusted.plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds))
})
it('caps a large quick plan without overflowing the requested budget', () => {
const plan = createLocalPlan({
mode: 'quick',
preferences: preferences({ pace: 'compact' }),
durationMinutes: 600,
selectedPoiIds: [],
}, repository)
expect(plan.itinerary.items.length).toBeGreaterThanOrEqual(1)
expect(plan.itinerary.items.length).toBeLessThanOrEqual(8)
expect(plan.itinerary.totalMinutes).toBeLessThanOrEqual(plan.itinerary.requestedMinutes)
})
it('updates legacy duration metadata when adjustment explicitly changes day length', () => {
const initialPreferences = preferences({ duration: 'full_day' })
const initial = createLocalPlan(initialPreferences, repository)
const adjusted = adjustLocalPlan(initial, initialPreferences, '改成半日游', repository)
expect(adjusted.preferences.duration).toBe('half_day')
expect(adjusted.plan.itinerary.requestedMinutes).toBe(240)
})
it('accepts eight itinerary items and rejects nine during plan validation', () => {
const selectedPoiIds = repository.getPublishedPois().slice(0, 8).map(poi => poi.id)
const plan = createLocalPlan({
mode: 'custom',
preferences: preferences(),
durationMinutes: 600,
selectedPoiIds,
}, repository)
expect(normalizePlanResponse(plan, repository).itinerary.items).toHaveLength(8)
const invalid = deepClone(plan)
invalid.itinerary.items.push(deepClone(invalid.itinerary.items[0]))
expect(() => normalizePlanResponse(invalid, repository)).toThrow('1 至 8')
})
})
describe('travel storage', () => {
const values = new Map<string, unknown>()
beforeEach(() => {
values.clear()
vi.mocked(uni.getStorageSync).mockImplementation(key => values.get(key))
vi.mocked(uni.setStorageSync).mockImplementation((key, value) => {
values.set(key, value)
})
})
it('round-trips validated plans and preferences without sharing references', () => {
const sourcePreferences = preferences()
const sourcePlan = createLocalPlan(sourcePreferences, getPoiRepository())
savePreferences(sourcePreferences)
savePlan(sourcePlan)
const firstPreferences = loadPreferences()!
const firstPlan = loadPlan()!
firstPreferences.themes.push('研学')
firstPlan.itinerary.items[0].placeName = '被修改'
expect(loadPreferences()?.themes).toEqual(['亲子'])
expect(loadPlan()?.itinerary.items[0].placeName).not.toBe('被修改')
expect(values.has(PREFERENCES_STORAGE_KEY)).toBe(true)
expect(values.has(PLAN_STORAGE_KEY)).toBe(true)
})
it('never persists planning-origin coordinates with a plan', () => {
const sourcePlan = createLocalPlan(
preferences(),
getPoiRepository(),
undefined,
{ longitude: 113.940123, latitude: 22.760456, coordinateSystem: 'GCJ02' },
)
savePlan(sourcePlan)
const serialized = JSON.stringify(values.get(PLAN_STORAGE_KEY))
expect(serialized).not.toContain('113.940123')
expect(serialized).not.toContain('22.760456')
})
it('returns null for malformed, stale or dangling cached data', () => {
const plan = createLocalPlan(preferences(), getPoiRepository())
values.set(PREFERENCES_STORAGE_KEY, [])
expect(loadPreferences()).toBeNull()
values.set(PREFERENCES_STORAGE_KEY, storageEnvelope({ ...preferences(), themes: [], interests: [] }))
expect(loadPreferences()).toBeNull()
values.set(PLAN_STORAGE_KEY, { ...storageEnvelope(plan), datasetVersion: 'old-dataset' })
expect(loadPlan()).toBeNull()
const dangling = deepClone(plan)
dangling.itinerary.items[0].placeId = 'unknown-place'
values.set(PLAN_STORAGE_KEY, storageEnvelope(dangling))
expect(loadPlan()).toBeNull()
})
it('contains storage read failures and reports write failures', () => {
vi.mocked(uni.getStorageSync).mockImplementation(() => {
throw new Error('read failed')
})
expect(loadPlan()).toBeNull()
expect(loadPreferences()).toBeNull()
vi.mocked(uni.setStorageSync).mockImplementation(() => {
throw new Error('quota exceeded')
})
expect(() => savePreferences(preferences())).toThrow('无法保存本机行程数据:quota exceeded')
})
})
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { failValidate, validate } from '../src/utils'
describe('validate function', () => {
it('should pass validation for correct form', async () => {
const form = { name: 'testname', age: 20 }
const rules = {
name: [
{ required: true, min: 3, max: 10, message: 'Invalid name' },
],
age: [
{ required: true, min: 1, max: 100, message: 'Invalid age' },
],
}
const cb = (bool: boolean) => {
expect(bool).toBe(true)
}
validate(form, rules, cb)
})
it('should pass validation of error form', async () => {
const form = { name: 'n', age: 20 }
const rules = {
name: [
{ required: true, min: 3, max: 10, message: 'Invalid name' },
],
age: [
{ required: true, min: 1, max: 100, message: 'Invalid age' },
],
}
const cb = (bool: boolean) => {
expect(bool).toBe(false)
}
validate(form, rules, cb)
})
})
describe('failValidate function', () => {
it('should handle failed validation', async () => {
const cb = vi.fn()
const msg = 'test message'
failValidate(msg, cb)
expect(cb).toHaveBeenCalledWith(false, undefined)
})
})