Files
gmTouringMiniApp/test/travel-assistant.test.ts
T

564 lines
24 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getPoiRepository } from '../src/data/poi'
import {
adjustLocalPlan,
classifyDurationFit,
createLocalPlan,
createRecommendedPlan,
hasSignificantDurationGap,
LEGACY_PLACE_ID_TO_POI_ID,
normalizePlanningRequest,
normalizePlanResponse,
normalizeTravelPreferences,
type Pace,
type PlanningRequest,
type PlanResponse,
resolveCanonicalPoiId,
type TravelPreferences,
} from '../src/domain/travel'
import {
createPlan,
getSessionPlanningOrigin,
loadPlan,
loadPlanningRequest,
loadPreferences,
PLAN_STORAGE_KEY,
PLANNING_REQUEST_STORAGE_KEY,
PREFERENCES_STORAGE_KEY,
savePlan,
savePlanningRequest,
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.each([
[120, 'relaxed', 1],
[120, 'moderate', 1],
[120, 'compact', 2],
[240, 'relaxed', 3],
[240, 'moderate', 3],
[240, 'compact', 3],
[480, 'relaxed', 4],
[480, 'moderate', 5],
[480, 'compact', 6],
[600, 'relaxed', 5],
[600, 'moderate', 6],
[600, 'compact', 7],
] as const)('packs %i-minute %s quick plans into %i POIs without overflow', (durationMinutes, pace, expectedCount) => {
const plan = createLocalPlan({
mode: 'quick',
preferences: preferences({ pace: pace as Pace }),
durationMinutes,
selectedPoiIds: [],
}, repository)
expect(plan.itinerary.items).toHaveLength(expectedCount)
expect(plan.itinerary.items.length).toBeGreaterThanOrEqual(1)
expect(plan.itinerary.items.length).toBeLessThanOrEqual(8)
expect(plan.itinerary.totalMinutes).toBeLessThanOrEqual(durationMinutes)
expect(plan.itinerary.durationFitStatus).not.toBe('overflow')
})
it('rejects a quick plan when even the first stop cannot fit the budget', () => {
expect(() => createLocalPlan({
mode: 'quick',
preferences: preferences({ pace: 'relaxed', transport: 'walking' }),
durationMinutes: 120,
selectedPoiIds: [],
origin: { longitude: 114.3, latitude: 22.3, coordinateSystem: 'GCJ02' },
}, repository)).toThrow('没有可用于规划的地点')
})
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('exposes the exact origin only from the current in-memory planning session', async () => {
const plan = await createPlan(preferences(), {
longitude: 113.94,
latitude: 22.76,
coordinateSystem: 'GCJ02',
})
const first = getSessionPlanningOrigin(plan.conversationId)
expect(first).toEqual({ longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' })
if (first)
first.longitude = 0
expect(getSessionPlanningOrigin(plan.conversationId)?.longitude).toBe(113.94)
expect(getSessionPlanningOrigin('unknown-session')).toBeNull()
expect(JSON.stringify(loadPlanningRequest())).not.toContain('113.94')
})
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('reviewed AI recommendations', () => {
const repository = getPoiRepository()
const selectedPoiIds = repository.getPublishedPois().slice(0, 3).map(poi => poi.id)
const request: PlanningRequest = {
mode: 'custom',
preferences: preferences(),
durationMinutes: 120,
selectedPoiIds,
origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' },
}
const recommendations = selectedPoiIds.map((poiId, index) => ({
poiId,
reason: `AI 白名单推荐理由 ${index + 1}`,
}))
it('keeps all custom POIs, AI reasons and local-only location data', () => {
const plan = createRecommendedPlan(request, recommendations, repository, '真实模型推荐完成', 'remote_ai')
expect(plan.source).toBe('remote_ai')
expect(new Set(plan.itinerary.items.map(item => item.placeId))).toEqual(new Set(selectedPoiIds))
expect(plan.itinerary.items.every(item => item.reason.includes('AI 白名单推荐理由'))).toBe(true)
expect(plan.itinerary.durationFitStatus).toBe('overflow')
expect(JSON.stringify(plan)).not.toContain('113.94')
expect(JSON.stringify(plan)).not.toContain('22.76')
})
it('uses current location to optimize a quick AI recommendation and still returns an overflow route', () => {
const quickRequest: PlanningRequest = {
...request,
mode: 'quick',
selectedPoiIds: [],
durationMinutes: 120,
origin: { longitude: 0, latitude: 0, coordinateSystem: 'GCJ02' },
}
const plan = createRecommendedPlan(quickRequest, recommendations, repository, '推荐完成', 'remote_ai')
expect(plan.itinerary.items).toHaveLength(1)
expect(plan.itinerary.durationFitStatus).toBe('overflow')
expect(plan.itinerary.startsFromCurrentLocation).toBe(true)
})
it('labels mock recommendations honestly and rejects an incomplete custom set', () => {
const mockPlan = createRecommendedPlan(request, recommendations, repository, '演示推荐完成', 'remote_mock')
expect(mockPlan.source).toBe('remote_mock')
expect(mockPlan.itinerary.title).not.toContain('AI')
expect(() => createRecommendedPlan(request, recommendations.slice(0, 2), repository, '', 'remote_ai'))
.toThrow('未完整保留')
})
})
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('round-trips custom planning inputs without persisting an exact origin', () => {
const selectedPoiIds = getPoiRepository().getPublishedPois().slice(0, 4).map(poi => poi.id)
const request: PlanningRequest = {
mode: 'custom',
preferences: preferences(),
durationMinutes: 330,
selectedPoiIds,
origin: { longitude: 113.94, latitude: 22.76, coordinateSystem: 'GCJ02' },
}
savePlanningRequest(request)
const loaded = loadPlanningRequest()!
expect(loaded).toEqual({ ...request, origin: undefined })
expect(loaded).not.toHaveProperty('origin')
expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('113.94')
expect(JSON.stringify(values.get(PLANNING_REQUEST_STORAGE_KEY))).not.toContain('22.76')
})
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('does not restore remote plans while AI planning is frozen', () => {
const plan = createLocalPlan(preferences(), getPoiRepository())
const remotePlan = { ...plan, source: 'remote_ai' as const }
values.set(PLAN_STORAGE_KEY, storageEnvelope(remotePlan))
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')
})
})