forked from zhouruizhe/gmTouringMiniApp
312 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|