forked from zhouruizhe/gmTouringMiniApp
Initial commit: gmTouringMiniApp project
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user