From f0ee63e642f526def70e475d172b994ba7b99bee Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 5 Oct 2026 11:08:11 +0800 Subject: [PATCH] Cache home location and weather across page navigation --- pets-fe/pages/home/home.js | 82 +++++++++---------------- pets-fe/utils/environment.js | 85 ++++++++++++++++++++++++++ tests/miniprogram-environment.test.cjs | 84 +++++++++++++++++++++++++ tests/miniprogram-smoke.test.cjs | 3 + tests/miniprogram-startup.test.cjs | 8 ++- 5 files changed, 205 insertions(+), 57 deletions(-) create mode 100644 pets-fe/utils/environment.js create mode 100644 tests/miniprogram-environment.test.cjs diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js index d1b298c..2a9a98f 100644 --- a/pets-fe/pages/home/home.js +++ b/pets-fe/pages/home/home.js @@ -1,5 +1,6 @@ const { secure, toast } = require("../../utils/api"); const { localDate } = require("../../utils/date"); +const environment = require("../../utils/environment"); function dateLabel() { const now = new Date(); return `${now.getMonth() + 1}月${now.getDate()}日 周${"日一二三四五六"[now.getDay()]}`; @@ -19,7 +20,7 @@ Page({ error: "", }, onShow() { - this.updateEnvironmentLabel(); + this.applyEnvironment(environment.cached()); this.load(); }, onUnload() { @@ -31,62 +32,40 @@ Page({ dateLabel() + " · " + (this.weatherLabel || "轻触获取天气"), }); }, + applyEnvironment(value) { + if (this.disposed) return; + if (value) { + this.weatherLabel = value.weatherLabel; + this.setData({ locationName: value.locationName }); + } + this.updateEnvironmentLabel(); + }, async refreshEnvironment(event) { if (this.environmentBusy) return; this.environmentBusy = true; const tapped = !!event; - let positioned = false; - try { - const settings = await new Promise((resolve, reject) => - wx.getSetting({ success: resolve, fail: reject }), - ); - if (settings.authSetting["scope.userLocation"] === false) { - if (!tapped) throw new Error("location-denied"); - const enabled = await new Promise((resolve, reject) => - wx.openSetting({ success: resolve, fail: reject }), - ); - if (!enabled.authSetting["scope.userLocation"]) - throw new Error("location-denied"); - } + const cached = environment.cached(); + if (!cached) { this.weatherLabel = "正在获取天气"; this.updateEnvironmentLabel(); - const point = await new Promise((resolve, reject) => - wx.getLocation({ - type: "gcj02", - success: resolve, - fail: reject, - }), - ); - positioned = true; - const result = await secure("/environment", "GET", { - latitude: point.latitude, - longitude: point.longitude, - }); - this.setData({ locationName: result.location_name }); - this.weatherLabel = result.weather - ? `${result.weather.description} ${result.weather.temperature}°C` - : "天气暂不可用"; - this.environmentAt = result.weather ? Date.now() : 0; + } + try { + this.applyEnvironment(await environment.refresh(tapped)); } catch (error) { - const denied = /denied|deny|auth/.test( - error.errMsg || error.message || "", - ); - this.setData({ - locationName: positioned - ? "位置暂不可用" - : denied - ? "开启定位" - : "定位暂不可用", - }); - this.weatherLabel = denied ? "轻触开启定位" : "轻触重试"; - if (tapped && !denied) - wx.showToast({ - title: positioned ? "位置和天气获取失败,请重试" : "定位失败,请重试", - icon: "none", - }); + if (this.disposed) return; + if (cached) { + this.applyEnvironment(cached); + } else { + const denied = /denied|deny|auth/.test( + error.errMsg || error.message || "", + ); + this.setData({ locationName: denied ? "开启定位" : "定位暂不可用" }); + this.weatherLabel = denied ? "轻触开启定位" : "轻触重试"; + this.updateEnvironmentLabel(); + } + if (tapped) toast(error); } finally { this.environmentBusy = false; - this.updateEnvironmentLabel(); } }, async load() { @@ -129,12 +108,7 @@ Page({ phase: "ready", }); // Location permission and weather never hold up the first usable screen. - if ( - !this.environmentAt || - Date.now() - this.environmentAt > 15 * 60 * 1000 - ) { - this.refreshEnvironment(); - } + this.refreshEnvironment(); } catch (error) { if (this.disposed) return; this.setData({ diff --git a/pets-fe/utils/environment.js b/pets-fe/utils/environment.js new file mode 100644 index 0000000..7a6cedc --- /dev/null +++ b/pets-fe/utils/environment.js @@ -0,0 +1,85 @@ +const { secure } = require("./api"); + +const STORAGE_KEY = "pet_environment_v1"; +let value; +let attempted = false; +let pending; +let failure; + +function cached() { + if (value === undefined) { + const saved = wx.getStorageSync(STORAGE_KEY); + value = + saved && + typeof saved.locationName === "string" && + typeof saved.weatherLabel === "string" && + Number.isFinite(saved.updatedAt) + ? saved + : null; + } + return value; +} + +async function fetchEnvironment(tapped) { + const settings = await new Promise((resolve, reject) => + wx.getSetting({ success: resolve, fail: reject }), + ); + if (settings.authSetting["scope.userLocation"] === false) { + if (!tapped) throw new Error("location-denied"); + const enabled = await new Promise((resolve, reject) => + wx.openSetting({ success: resolve, fail: reject }), + ); + if (!enabled.authSetting["scope.userLocation"]) + throw new Error("location-denied"); + } + const point = await new Promise((resolve, reject) => + wx.getLocation({ type: "gcj02", success: resolve, fail: reject }), + ); + const result = await secure("/environment", "GET", { + latitude: point.latitude, + longitude: point.longitude, + }); + if (!result || !result.location_name) + throw new Error("位置和天气获取失败,请重试"); + const previous = cached(); + value = { + locationName: result.location_name, + weatherLabel: result.weather + ? `${result.weather.description} ${result.weather.temperature}°C` + : previous && previous.locationName === result.location_name + ? previous.weatherLabel + : "天气暂不可用", + updatedAt: Date.now(), + }; + // Store display data only; precise coordinates are not persisted here. + try { + wx.setStorageSync(STORAGE_KEY, value); + } catch (_) {} + return value; +} + +function refresh(tapped = false) { + if (pending) return pending; + // Module lifetime follows the app, including recreated pages and warm resumes. + // Failed automatic attempts also wait for a tap or the next cold start. + if (attempted && !tapped) + return failure ? Promise.reject(failure) : Promise.resolve(cached()); + attempted = true; + pending = fetchEnvironment(tapped) + .then( + (result) => { + failure = null; + return result; + }, + (error) => { + failure = error; + throw error; + }, + ) + .finally(() => { + pending = null; + }); + return pending; +} + +module.exports = { cached, refresh }; diff --git a/tests/miniprogram-environment.test.cjs b/tests/miniprogram-environment.test.cjs new file mode 100644 index 0000000..afd7ab8 --- /dev/null +++ b/tests/miniprogram-environment.test.cjs @@ -0,0 +1,84 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); + +function runtime(storage = new Map()) { + const counts = { location: 0, request: 0 }; + let fail = false; + let release; + const module = { exports: {} }; + vm.runInNewContext(fs.readFileSync("pets-fe/utils/environment.js", "utf8"), { + module, + wx: { + getStorageSync: (key) => storage.get(key), + setStorageSync: (key, value) => storage.set(key, value), + getSetting: (opts) => opts.success({ authSetting: {} }), + getLocation: (opts) => { + counts.location++; + opts.success({ latitude: 25, longitude: 102 }); + }, + }, + require: () => ({ + secure: async () => { + counts.request++; + if (fail) throw new Error("offline"); + if (release) await release; + return { + location_name: "昆明市 · 五华区", + weather: { description: "晴", temperature: "22" }, + }; + }, + }), + }); + return { + api: module.exports, + counts, + storage, + fail: () => { + fail = true; + }, + hold: (promise) => { + release = promise; + }, + }; +} + +test("页面重建与返回复用同一轮天气请求,点击可以刷新", async () => { + const r = runtime(); + let finish; + r.hold( + new Promise((resolve) => { + finish = resolve; + }), + ); + const first = r.api.refresh(); + assert.equal(r.api.refresh(), first); + finish(); + await first; + await r.api.refresh(); + await r.api.refresh(); + assert.equal(r.counts.location, 1); + assert.equal(r.counts.request, 1); + await r.api.refresh(true); + assert.equal(r.counts.request, 2); + assert.equal(r.api.cached().weatherLabel, "晴 22°C"); + const saved = r.storage.get("pet_environment_v1"); + assert.equal("latitude" in saved, false); + assert.equal("longitude" in saved, false); +}); + +test("冷启动先读取上次缓存,再请求一次;失败保留缓存且不会返回就重试", async () => { + const previous = runtime(); + await previous.api.refresh(); + const next = runtime(previous.storage); + assert.equal(next.api.cached().locationName, "昆明市 · 五华区"); + assert.equal(next.counts.request, 0); + next.fail(); + await assert.rejects(next.api.refresh(), /offline/); + await assert.rejects(next.api.refresh(), /offline/); + assert.equal(next.counts.request, 1); + assert.equal(next.api.cached().weatherLabel, "晴 22°C"); + await assert.rejects(next.api.refresh(true), /offline/); + assert.equal(next.counts.request, 2); +}); diff --git a/tests/miniprogram-smoke.test.cjs b/tests/miniprogram-smoke.test.cjs index 7d342a4..34bfec5 100644 --- a/tests/miniprogram-smoke.test.cjs +++ b/tests/miniprogram-smoke.test.cjs @@ -5,6 +5,9 @@ const path = require("node:path"); const apiPath = require.resolve("../pets-fe/utils/api"); function page(name, secure, wx = {}) { + // Each test represents a fresh app session; page navigation within a test + // still shares the environment module. + delete require.cache[require.resolve("../pets-fe/utils/environment")]; global.wx = { getStorageSync: () => [], setStorageSync: () => {}, diff --git a/tests/miniprogram-startup.test.cjs b/tests/miniprogram-startup.test.cjs index 03b7bb0..7edbd5a 100644 --- a/tests/miniprogram-startup.test.cjs +++ b/tests/miniprogram-startup.test.cjs @@ -99,9 +99,11 @@ function home(login, secure) { definition = value; }, require: (name) => - name.includes("date") - ? { localDate: () => "2026-10-05" } - : { secure, toast: () => {} }, + name.includes("environment") + ? { cached: () => null } + : name.includes("date") + ? { localDate: () => "2026-10-05" } + : { secure, toast: () => {} }, }); const page = { ...definition,