forked from zhouruizhe/gmTouringMiniApp
564 lines
24 KiB
TypeScript
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')
|
|
})
|
|
})
|