Files
gmTouringMiniApp/test/check-in.test.ts
T

312 lines
12 KiB
TypeScript

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)
})
})