Add check-in feature and update travel planner UI

This commit is contained in:
周瑞哲
2026-07-31 12:50:14 +08:00
parent ac4179086c
commit 3ef7266591
44 changed files with 2919 additions and 144 deletions
+311
View File
@@ -0,0 +1,311 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCheckInTask, getCheckInTasks } from '../src/data/check-in'
import { getPoiRepository } from '../src/data/poi'
import {
applyCheckIn,
CHECK_IN_BADGES,
CHECK_IN_MAX_ACCURACY_METERS,
CHECK_IN_RADIUS_METERS,
type CheckInLocationSnapshot,
createCheckInRecordId,
createEmptyCheckInProfile,
distanceBetweenMeters,
evaluateCheckInEligibility,
normalizeCheckInProfile,
} from '../src/domain/check-in'
import {
CHECK_IN_STORAGE_KEY,
checkInAtPoi,
clearCheckInProfile,
loadCheckInProfile,
saveCheckInProfile,
} from '../src/services/check-in'
const poi = {
id: 'poi_hongqiao_park',
name: '虹桥公园',
longitude: 113.944,
latitude: 22.755,
}
function location(overrides: Partial<CheckInLocationSnapshot> = {}): CheckInLocationSnapshot {
return {
longitude: poi.longitude,
latitude: poi.latitude,
accuracy: 20,
receivedAt: '2026-07-31T02:00:00.000Z',
coordinateSystem: 'GCJ02',
...overrides,
}
}
describe('check-in domain', () => {
it('exposes every published POI through the task catalogue used by detail pages', () => {
const publishedPoiIds = getPoiRepository().getPublishedPois().map(item => item.id)
const tasks = getCheckInTasks()
expect(tasks.map(task => task.poiId)).toEqual(publishedPoiIds)
expect(tasks).toHaveLength(publishedPoiIds.length)
expect(tasks.every(task => task.radiusMeters === CHECK_IN_RADIUS_METERS)).toBe(true)
expect(publishedPoiIds.every(poiId => getCheckInTask(poiId)?.poiId === poiId)).toBe(true)
expect(getCheckInTask('poi_not_published')).toBeNull()
})
it('computes short GCJ-02 distances in metres', () => {
expect(distanceBetweenMeters(poi, poi)).toBe(0)
expect(distanceBetweenMeters(poi, { ...poi, latitude: poi.latitude + 0.001 })).toBeCloseTo(111, 0)
})
it('requires an accurate location inside the check-in radius', () => {
expect(evaluateCheckInEligibility(location(), poi)).toMatchObject({ eligible: true, code: 'eligible' })
expect(evaluateCheckInEligibility(location({ latitude: poi.latitude + 0.003 }), poi)).toMatchObject({
eligible: false,
code: 'too_far',
allowedDistanceMeters: CHECK_IN_RADIUS_METERS,
})
expect(evaluateCheckInEligibility(location({ latitude: poi.latitude + 0.0017, accuracy: 20 }), poi).code)
.toBe('uncertain')
expect(evaluateCheckInEligibility(location({ accuracy: 0 }), poi).code).toBe('low_accuracy')
expect(evaluateCheckInEligibility(location({ accuracy: CHECK_IN_MAX_ACCURACY_METERS + 1 }), poi).code)
.toBe('low_accuracy')
})
it('applies conservative distance and accuracy boundary comparisons', () => {
const offsetLocation = location({ latitude: poi.latitude + 0.001, accuracy: 20 })
const distance = distanceBetweenMeters(offsetLocation, poi)
expect(evaluateCheckInEligibility(offsetLocation, poi, distance + 20).code).toBe('eligible')
expect(evaluateCheckInEligibility(offsetLocation, poi, distance + 20 - 0.001).code).toBe('uncertain')
expect(evaluateCheckInEligibility(offsetLocation, poi, distance - 20).code).toBe('uncertain')
expect(evaluateCheckInEligibility(offsetLocation, poi, distance - 20 - 0.001).code).toBe('too_far')
expect(evaluateCheckInEligibility(location({ accuracy: CHECK_IN_MAX_ACCURACY_METERS }), poi).code)
.toBe('eligible')
})
it('awards points and threshold badges without persisting exact coordinates', () => {
const now = Date.parse('2026-07-31T02:00:20.000Z')
const result = applyCheckIn(createEmptyCheckInProfile(), poi, location(), now)
expect(result.profile.points).toBe(10)
expect(result.profile.badges.map(badge => badge.code)).toEqual(['explorer'])
expect(result.record).toMatchObject({ poiId: poi.id, pointsAwarded: 10 })
expect(JSON.stringify(result.profile)).not.toContain(String(poi.longitude))
expect(() => applyCheckIn(result.profile, poi, location(), now + 1000)).toThrow('不会重复计分')
})
it('keeps the backwards-compatible 1/2/3 cumulative badge thresholds', () => {
expect(CHECK_IN_BADGES.map(badge => badge.requiredCheckIns)).toEqual([1, 2, 3])
let profile = createEmptyCheckInProfile()
for (let index = 0; index < 3; index += 1) {
const result = applyCheckIn(
profile,
{ ...poi, id: `poi_${index}`, name: `点位 ${index}` },
location(),
Date.parse('2026-07-31T02:00:20.000Z') + index,
)
profile = result.profile
}
expect(profile.badges.map(badge => badge.code)).toEqual(['explorer', 'traveller', 'check_in_master'])
})
it('rejects malformed profiles and inconsistent point totals', () => {
const result = applyCheckIn(
createEmptyCheckInProfile(),
poi,
location(),
Date.parse('2026-07-31T02:00:20.000Z'),
)
expect(normalizeCheckInProfile({ ...result.profile, points: 999 })).toBeNull()
expect(normalizeCheckInProfile({ ...result.profile, badges: [...result.profile.badges, result.profile.badges[0]] })).toBeNull()
expect(normalizeCheckInProfile({
...result.profile,
checkIns: [{ ...result.profile.checkIns[0], longitude: 113.94 }],
})).toBeNull()
expect(normalizeCheckInProfile({ ...result.profile, location: location() })).toBeNull()
})
})
describe('check-in 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))
vi.mocked(uni.removeStorageSync).mockImplementation(key => values.delete(key))
})
it('allows the service gate to check in at every published POI', () => {
const publishedPois = getPoiRepository().getPublishedPois()
publishedPois.forEach((publishedPoi, index) => {
values.clear()
const result = checkInAtPoi(
publishedPoi.id,
location({
longitude: publishedPoi.longitude,
latitude: publishedPoi.latitude,
}),
Date.parse('2026-07-31T02:00:20.000Z') + index,
)
expect(result.record.poiId).toBe(publishedPoi.id)
expect(loadCheckInProfile().checkIns[0].poiId).toBe(publishedPoi.id)
})
})
it('rejects an unknown or retired POI at the service gate', () => {
const publishedPoi = getPoiRepository().getPublishedPois()[0]
expect(() => checkInAtPoi(
'poi_not_published',
location({ longitude: publishedPoi.longitude, latitude: publishedPoi.latitude }),
)).toThrow('不存在或已下线')
expect(values.has(CHECK_IN_STORAGE_KEY)).toBe(false)
})
it('uses canonical repository coordinates at the service boundary', () => {
const publishedPoi = getPoiRepository().getPublishedPois()[0]
expect(() => checkInAtPoi(
publishedPoi.id,
location({ longitude: publishedPoi.longitude + 0.01, latitude: publishedPoi.latitude }),
)).toThrow('请到点位')
expect(values.has(CHECK_IN_STORAGE_KEY)).toBe(false)
})
it('round-trips only the dedicated anonymous profile key', () => {
const result = applyCheckIn(
createEmptyCheckInProfile(),
poi,
location(),
Date.parse('2026-07-31T02:00:20.000Z'),
)
saveCheckInProfile(result.profile)
const loaded = loadCheckInProfile()
loaded.checkIns[0].poiName = 'changed'
expect(loadCheckInProfile().checkIns[0].poiName).toBe('虹桥公园')
expect([...values.keys()]).toEqual([CHECK_IN_STORAGE_KEY])
expect(JSON.stringify(values.get(CHECK_IN_STORAGE_KEY))).not.toContain(String(poi.longitude))
})
it('fails closed for malformed reads and clears only check-in data', () => {
values.set(CHECK_IN_STORAGE_KEY, { broken: true })
values.set('guangming:last-plan', { keep: true })
expect(() => loadCheckInProfile()).toThrow('本机打卡记录异常')
clearCheckInProfile()
expect(values.has(CHECK_IN_STORAGE_KEY)).toBe(false)
expect(values.has('guangming:last-plan')).toBe(true)
})
it('rejects unknown storage, envelope, and badge fields', () => {
const savedAt = '2026-07-31T02:00:20.000Z'
values.set(CHECK_IN_STORAGE_KEY, { schemaVersion: 99, savedAt, payload: createEmptyCheckInProfile() })
expect(() => loadCheckInProfile()).toThrow('本机打卡记录异常')
values.set(CHECK_IN_STORAGE_KEY, {
schemaVersion: 2,
savedAt,
payload: createEmptyCheckInProfile(),
longitude: 113.94,
})
expect(() => loadCheckInProfile()).toThrow('本机打卡记录异常')
const checkedInAt = savedAt
values.set(CHECK_IN_STORAGE_KEY, {
schemaVersion: 2,
savedAt,
payload: {
points: 10,
checkIns: [{
id: createCheckInRecordId(poi.id, checkedInAt),
poiId: poi.id,
poiName: poi.name,
checkedInAt,
pointsAwarded: 10,
}],
badges: [{ code: 'explorer', obtainedAt: checkedInAt, accuracy: 20 }],
},
})
expect(() => loadCheckInProfile()).toThrow('本机打卡记录异常')
})
it('preserves valid v2 history when a POI is no longer enabled', () => {
const checkedInAt = '2026-07-31T02:00:20.000Z'
const legacyPoiId = 'poi_legacy_hangzhou'
values.set(CHECK_IN_STORAGE_KEY, {
schemaVersion: 2,
savedAt: checkedInAt,
payload: {
points: 10,
checkIns: [{
id: createCheckInRecordId(legacyPoiId, checkedInAt),
poiId: legacyPoiId,
poiName: '旧点位',
checkedInAt,
pointsAwarded: 10,
}],
badges: [{ code: 'explorer', obtainedAt: checkedInAt }],
},
})
const profile = {
points: 10,
badges: [{ code: 'explorer', obtainedAt: checkedInAt }],
checkIns: [{
id: createCheckInRecordId(legacyPoiId, checkedInAt),
poiId: legacyPoiId,
poiName: '旧点位',
checkedInAt,
pointsAwarded: 10,
}],
} as const
expect(loadCheckInProfile()).toEqual(profile)
expect(() => saveCheckInProfile(profile)).not.toThrow()
})
it('migrates legacy data to the current published POIs and badge rules', () => {
const firstTime = '2026-07-31T02:00:20.000Z'
const secondTime = '2026-07-31T03:00:20.000Z'
values.set(CHECK_IN_STORAGE_KEY, {
schemaVersion: 1,
savedAt: secondTime,
payload: {
points: 30,
badges: [{ code: 'explorer', obtainedAt: firstTime }],
checkIns: [
{ poiId: 'poi_hongqiao_park', poiName: '虹桥公园', checkedInAt: firstTime },
{ poiId: 'poi_gm_culture_center', poiName: '光明文化艺术中心', checkedInAt: secondTime },
{ poiId: 'poi_legacy_hangzhou', poiName: '旧点位', checkedInAt: secondTime },
],
},
})
const migrated = loadCheckInProfile()
expect(migrated.points).toBe(20)
expect(migrated.checkIns.map(record => record.poiId)).toEqual([
'poi_gm_culture_center',
'poi_hongqiao_park',
])
expect(migrated.badges.map(badge => badge.code)).toEqual(['explorer', 'traveller'])
expect(values.get(CHECK_IN_STORAGE_KEY)).toMatchObject({ schemaVersion: 2, payload: migrated })
})
it('does not report success when the atomic profile write fails', () => {
const result = applyCheckIn(
createEmptyCheckInProfile(),
poi,
location(),
Date.parse('2026-07-31T02:00:20.000Z'),
)
vi.mocked(uni.setStorageSync).mockImplementation(() => {
throw new Error('quota exceeded')
})
expect(() => saveCheckInProfile(result.profile)).toThrow('无法保存本机打卡记录:quota exceeded')
expect(values.has(CHECK_IN_STORAGE_KEY)).toBe(false)
})
})
+76
View File
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCurrentGcj02Location, isLocationPermissionDenied } from '../src/services/location'
describe('current location adapter', () => {
beforeEach(() => {
vi.useRealTimers()
Object.assign(uni, {
canIUse: vi.fn(() => true),
getLocation: vi.fn(),
})
})
it('requests a new high-accuracy GCJ-02 sample', async () => {
vi.mocked(uni.getLocation).mockImplementation((options) => {
options.success?.({ longitude: 113.94, latitude: 22.76, accuracy: 18 } as UniNamespace.GetLocationSuccess)
return undefined as never
})
await expect(getCurrentGcj02Location()).resolves.toMatchObject({
longitude: 113.94,
latitude: 22.76,
accuracy: 18,
receivedAt: expect.any(String),
coordinateSystem: 'GCJ02',
})
expect(uni.getLocation).toHaveBeenCalledWith(expect.objectContaining({
type: 'gcj02',
isHighAccuracy: true,
highAccuracyExpireTime: 8000,
}))
})
it('starts a separate native request for every location lookup', async () => {
vi.mocked(uni.getLocation).mockImplementation((options) => {
options.success?.({ longitude: 113.94, latitude: 22.76, accuracy: 18 } as UniNamespace.GetLocationSuccess)
return undefined as never
})
await getCurrentGcj02Location()
await getCurrentGcj02Location()
expect(uni.getLocation).toHaveBeenCalledTimes(2)
})
it('falls back to the basic object shape on older base libraries', async () => {
vi.mocked(uni.canIUse).mockReturnValue(false)
vi.mocked(uni.getLocation).mockImplementation((options) => {
options.success?.({ longitude: 113.94, latitude: 22.76, accuracy: 20 } as UniNamespace.GetLocationSuccess)
return undefined as never
})
await getCurrentGcj02Location()
expect(uni.getLocation).toHaveBeenCalledWith(expect.not.objectContaining({ isHighAccuracy: true }))
})
it('times out once and ignores a late callback', async () => {
vi.useFakeTimers()
let success: ((result: UniNamespace.GetLocationSuccess) => void) | undefined
vi.mocked(uni.getLocation).mockImplementation((options) => {
success = options.success
return undefined as never
})
const request = getCurrentGcj02Location({ timeoutMs: 3000 })
const rejection = expect(request).rejects.toThrow('定位超时')
await vi.advanceTimersByTimeAsync(3001)
await rejection
success?.({ longitude: 113.94, latitude: 22.76, accuracy: 20 } as UniNamespace.GetLocationSuccess)
await vi.runAllTimersAsync()
})
it('identifies WeChat permission denials', () => {
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail auth deny' })).toBe(true)
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail privacy permission is not authorized' })).toBe(false)
expect(isLocationPermissionDenied({ errMsg: 'getLocation:fail system error' })).toBe(false)
})
})
+9
View File
@@ -18,6 +18,15 @@ describe('map session store', () => {
expect(store.viewport).toEqual({ longitude: 113.94, latitude: 22.76, scale: 13 })
})
it('shows and clears the planned-route overlay explicitly', () => {
const store = useMapStore()
expect(store.plannedRouteVisible).toBe(false)
store.showPlannedRoute()
expect(store.plannedRouteVisible).toBe(true)
store.hidePlannedRoute()
expect(store.plannedRouteVisible).toBe(false)
})
it('ignores invalid viewport updates', () => {
const store = useMapStore()
store.updateViewport({ longitude: Number.NaN, latitude: 22.76, scale: 13 })
+4
View File
@@ -2,8 +2,12 @@ import { vi } from 'vitest'
import { unref } from 'vue'
const uniMock = {
canIUse: vi.fn(),
getLocation: vi.fn(),
getStorageSync: vi.fn(),
getSystemInfoSync: vi.fn(() => ({ uniPlatform: 'mp-weixin' })),
removeStorageSync: vi.fn(),
setStorageSync: vi.fn(),
setStorage: vi.fn(),
showToast: vi.fn(),
}
+64
View File
@@ -10,6 +10,7 @@ import {
normalizePlanningRequest,
normalizePlanResponse,
normalizeTravelPreferences,
type Pace,
type PlanningRequest,
type PlanResponse,
resolveCanonicalPoiId,
@@ -17,6 +18,7 @@ import {
} from '../src/domain/travel'
import {
createPlan,
getSessionPlanningOrigin,
loadPlan,
loadPlanningRequest,
loadPreferences,
@@ -191,6 +193,44 @@ describe('canonical POI IDs', () => {
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)
@@ -302,6 +342,22 @@ describe('deterministic local POC planner', () => {
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 = {
@@ -484,6 +540,14 @@ describe('travel storage', () => {
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')