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 { return { destination: '深圳市光明区', themes: ['亲子'], duration: 'half_day', pace: 'moderate', interests: ['自然风光'], adults: 2, children: 1, childAges: [6], transport: 'public_transport', budgetLevel: 'standard', extraRequirements: '', ...overrides, } } function deepClone(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 { 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).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() 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') }) })