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