diff --git a/pets-be/internal/httpapi/admin.go b/pets-be/internal/httpapi/admin.go
index 84b90d9..bb6ae4e 100644
--- a/pets-be/internal/httpapi/admin.go
+++ b/pets-be/internal/httpapi/admin.go
@@ -360,7 +360,9 @@ func (a *API) adminTasks(c *gin.Context) {
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
for _, t := range rows {
ids = append(ids, t.UserID)
- petIDs = append(petIDs, t.PetID)
+ if t.PetID != nil {
+ petIDs = append(petIDs, *t.PetID)
+ }
taskIDs = append(taskIDs, t.ID)
}
names, err := a.userNames(ids)
@@ -388,7 +390,11 @@ func (a *API) adminTasks(c *gin.Context) {
}
items := make([]gin.H, 0, len(rows))
for _, t := range rows {
- items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "pet_name": petNames[t.PetID], "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
+ petName := "全部宠物"
+ if t.PetID != nil {
+ petName = petNames[*t.PetID]
+ }
+ items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "all_pets": t.PetID == nil, "pet_name": petName, "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
}
page.Items = items
ok(c, page)
diff --git a/pets-be/internal/httpapi/tasks.go b/pets-be/internal/httpapi/tasks.go
index 226483a..bddaea4 100644
--- a/pets-be/internal/httpapi/tasks.go
+++ b/pets-be/internal/httpapi/tasks.go
@@ -16,7 +16,8 @@ import (
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
type taskInput struct {
- PetID string `json:"pet_id" binding:"required"`
+ PetID string `json:"pet_id"`
+ AllPets bool `json:"all_pets"`
Title string `json:"title" binding:"required"`
Category string `json:"category"`
TimeOfDay string `json:"time_of_day"`
@@ -50,7 +51,7 @@ func (a *API) listTasks(c *gin.Context) {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
- query = query.Where("pet_id = ?", petID)
+ query = query.Where("(pet_id = ? OR pet_id IS NULL)", petID)
}
var tasks []model.CareTask
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
@@ -76,7 +77,7 @@ func (a *API) listTasks(c *gin.Context) {
}
func taskView(task model.CareTask, completed bool) gin.H {
- return gin.H{"id": task.ID, "pet_id": task.PetID, "title": task.Title,
+ return gin.H{"id": task.ID, "pet_id": task.PetID, "all_pets": task.PetID == nil, "title": task.Title,
"category": task.Category, "time_of_day": task.TimeOfDay,
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
"completed": completed}
@@ -84,9 +85,26 @@ func taskView(task model.CareTask, completed bool) gin.H {
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
task := model.CareTask{}
- if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
+ var petID *string
+ if in.AllPets {
+ if in.PetID != "" {
+ fail(c, http.StatusBadRequest, "全部宠物与单只宠物不能同时选择")
+ return task, false
+ }
+ var count int64
+ if err := a.db.Model(&model.Pet{}).Where("user_id = ? AND archived = false", actor(c)).Count(&count).Error; err != nil {
+ fail(c, http.StatusInternalServerError, "读取宠物失败")
+ return task, false
+ }
+ if count == 0 {
+ fail(c, http.StatusBadRequest, "请先添加宠物")
+ return task, false
+ }
+ } else if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return task, false
+ } else {
+ petID = &in.PetID
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" || len([]rune(in.Title)) > 128 {
@@ -110,7 +128,7 @@ func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
fail(c, http.StatusBadRequest, "重复规则无效")
return task, false
}
- task = model.CareTask{UserID: actor(c), PetID: in.PetID, Title: in.Title,
+ task = model.CareTask{UserID: actor(c), PetID: petID, Title: in.Title,
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
ReminderEnabled: in.ReminderEnabled}
return task, true
diff --git a/pets-be/internal/httpapi/tasks_test.go b/pets-be/internal/httpapi/tasks_test.go
index 6813d78..50f8881 100644
--- a/pets-be/internal/httpapi/tasks_test.go
+++ b/pets-be/internal/httpapi/tasks_test.go
@@ -47,6 +47,10 @@ func TestTaskCompletionPostgres(t *testing.T) {
t.Fatal(tx.Error)
}
defer tx.Rollback()
+ // Verify the nullable pet association migration without changing live schema.
+ if err := tx.AutoMigrate(&model.CareTask{}); err != nil {
+ t.Fatal(err)
+ }
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
if err := tx.Create(&user).Error; err != nil {
t.Fatal(err)
@@ -55,11 +59,54 @@ func TestTaskCompletionPostgres(t *testing.T) {
if err := tx.Create(&pet).Error; err != nil {
t.Fatal(err)
}
- tasks := []model.CareTask{{UserID: user.ID, PetID: pet.ID, Title: "喝水", Category: "water", TimeOfDay: "08:00", RepeatRule: "daily"}, {UserID: user.ID, PetID: pet.ID, Title: "喂食", Category: "food", TimeOfDay: "09:00", RepeatRule: "daily"}}
+ tasks := []model.CareTask{{UserID: user.ID, PetID: &pet.ID, Title: "喝水", Category: "water", TimeOfDay: "08:00", RepeatRule: "daily"}, {UserID: user.ID, PetID: &pet.ID, Title: "喂食", Category: "food", TimeOfDay: "09:00", RepeatRule: "daily"}}
if err := tx.Create(&tasks).Error; err != nil {
t.Fatal(err)
}
api := New(tx, nil, cfg)
+ request := func(method, route, id string, body gin.H, handler gin.HandlerFunc) *httptest.ResponseRecorder {
+ t.Helper()
+ encoded, _ := json.Marshal(body)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Set("actor_id", user.ID)
+ c.Params = gin.Params{{Key: "id", Value: id}}
+ c.Request = httptest.NewRequest(method, route, bytes.NewReader(encoded))
+ handler(c)
+ return rec
+ }
+ allBody := gin.H{"all_pets": true, "title": "全部换水", "category": "water", "time_of_day": "10:00", "repeat_rule": "daily"}
+ rec := request("POST", "/tasks", "", allBody, api.createTask)
+ if rec.Code != 201 {
+ t.Fatalf("all-pet creation: %d %s", rec.Code, rec.Body.String())
+ }
+ var allTask model.CareTask
+ if err := tx.Where("user_id = ? AND title = ?", user.ID, "全部换水").First(&allTask).Error; err != nil || allTask.PetID != nil {
+ t.Fatalf("all-pet association should be null: %v", err)
+ }
+ // Single-pet filters include shared tasks; other users' tasks remain isolated.
+ rec = request("GET", "/tasks?date=2026-10-05&pet_id="+pet.ID, "", nil, api.listTasks)
+ var listing struct {
+ Data struct {
+ Items []struct {
+ ID string `json:"id"`
+ AllPets bool `json:"all_pets"`
+ } `json:"items"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &listing); err != nil || rec.Code != 200 || len(listing.Data.Items) != 3 {
+ t.Fatalf("pet filter omitted shared task: %s", rec.Body.String())
+ }
+ if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, gin.H{"pet_id": pet.ID, "title": "单只换水"}, api.updateTask); rec.Code != 200 {
+ t.Fatalf("switch to single pet: %s", rec.Body.String())
+ }
+ if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, allBody, api.updateTask); rec.Code != 200 {
+ t.Fatalf("switch to all pets: %s", rec.Body.String())
+ }
+ conflicting := gin.H{"all_pets": true, "pet_id": pet.ID, "title": "冲突选择"}
+ if rec = request("POST", "/tasks", "", conflicting, api.createTask); rec.Code != 400 {
+ t.Fatalf("ambiguous selection accepted: %s", rec.Body.String())
+ }
call := func(ids []string) int {
t.Helper()
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
@@ -88,4 +135,11 @@ func TestTaskCompletionPostgres(t *testing.T) {
if count != 2 {
t.Fatalf("retry duplicated completions: %d", count)
}
+ if status := call([]string{allTask.ID}); status != 200 {
+ t.Fatalf("all-pet completion status %d", status)
+ }
+ tx.Model(&model.TaskCompletion{}).Where("task_id = ?", allTask.ID).Count(&count)
+ if count != 1 {
+ t.Fatalf("shared task should have one completion: %d", count)
+ }
}
diff --git a/pets-be/internal/model/models.go b/pets-be/internal/model/models.go
index 50318f9..dc5d8a4 100644
--- a/pets-be/internal/model/models.go
+++ b/pets-be/internal/model/models.go
@@ -117,13 +117,13 @@ type Pet struct {
type CareTask struct {
Base
- UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
- PetID string `gorm:"type:uuid;index;not null" json:"pet_id"`
- Title string `gorm:"size:128;not null" json:"title"`
- Category string `gorm:"size:32;not null" json:"category"`
- TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
- RepeatRule string `gorm:"size:16;not null;default:daily" json:"repeat_rule"`
- ReminderEnabled bool `gorm:"not null;default:false" json:"reminder_enabled"`
+ UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
+ PetID *string `gorm:"type:uuid;index" json:"pet_id"` // nil applies to all of the user's pets.
+ Title string `gorm:"size:128;not null" json:"title"`
+ Category string `gorm:"size:32;not null" json:"category"`
+ TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
+ RepeatRule string `gorm:"size:16;not null;default:daily" json:"repeat_rule"`
+ ReminderEnabled bool `gorm:"not null;default:false" json:"reminder_enabled"`
}
type TaskCompletion struct {
diff --git a/pets-fe/components/home-skeleton/index.js b/pets-fe/components/home-skeleton/index.js
deleted file mode 100644
index b79c512..0000000
--- a/pets-fe/components/home-skeleton/index.js
+++ /dev/null
@@ -1 +0,0 @@
-Component({});
diff --git a/pets-fe/components/home-skeleton/index.json b/pets-fe/components/home-skeleton/index.json
deleted file mode 100644
index 67f8e03..0000000
--- a/pets-fe/components/home-skeleton/index.json
+++ /dev/null
@@ -1 +0,0 @@
-{ "component": true }
diff --git a/pets-fe/components/home-skeleton/index.wxml b/pets-fe/components/home-skeleton/index.wxml
deleted file mode 100644
index fc514f1..0000000
--- a/pets-fe/components/home-skeleton/index.wxml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
- 正在准备今天的小日常…
-
diff --git a/pets-fe/components/home-skeleton/index.wxss b/pets-fe/components/home-skeleton/index.wxss
deleted file mode 100644
index c66e51b..0000000
--- a/pets-fe/components/home-skeleton/index.wxss
+++ /dev/null
@@ -1,73 +0,0 @@
-.bone {
- background: linear-gradient(100deg, #f3e7dc 20%, #fff8ef 45%, #f3e7dc 70%);
- background-size: 220% 100%;
- animation: shimmer 1.6s ease-in-out infinite;
- border-radius: 15rpx;
-}
-.hero-placeholder {
- position: relative;
- min-height: 330rpx;
- background: #fff0e2;
- border-radius: 38rpx;
- padding: 45rpx 35rpx;
- box-sizing: border-box;
-}
-.line {
- height: 22rpx;
- width: 65%;
- margin: 24rpx 0;
-}
-.short {
- width: 25%;
-}
-.title {
- height: 38rpx;
- width: 48%;
-}
-.art {
- position: absolute;
- right: 35rpx;
- top: 62rpx;
- width: 170rpx;
- height: 190rpx;
- border-radius: 40rpx;
-}
-.heading {
- width: 230rpx;
- height: 33rpx;
- margin: 48rpx 0 28rpx;
-}
-.card-placeholder {
- padding: 28rpx;
- background: white;
- border-radius: 35rpx;
-}
-.action {
- height: 80rpx;
- margin-top: 42rpx;
- border-radius: 24rpx;
-}
-.stats-placeholder {
- display: flex;
- gap: 14rpx;
- margin-top: 20rpx;
-}
-.stats-placeholder view {
- height: 140rpx;
- flex: 1;
- border-radius: 28rpx;
-}
-.loading-copy {
- text-align: center;
- color: #a48d7e;
- font-size: 22rpx;
- margin-top: 32rpx;
-}
-@keyframes shimmer {
- 0% {
- background-position: 100% 0;
- }
- 100% {
- background-position: -100% 0;
- }
-}
diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js
index 65b4190..e577953 100644
--- a/pets-fe/pages/home/home.js
+++ b/pets-fe/pages/home/home.js
@@ -6,6 +6,10 @@ function dateLabel() {
const now = new Date();
return `${now.getMonth() + 1}月${now.getDate()}日 周${"日一二三四五六"[now.getDay()]}`;
}
+function accountKey() {
+ const state = getApp().globalData || {};
+ return (state.user && state.user.id) || state.token || "";
+}
Page({
data: {
locationName: "当前位置",
@@ -23,13 +27,32 @@ Page({
phase: "auth",
error: "",
},
+ onLoad() {
+ const cached = getApp().homeSnapshot;
+ if (cached && cached.account === accountKey() && cached.date === localDate()) {
+ this.setData({ ...cached.data, ready: true, phase: "ready", loading: false });
+ }
+ },
onShow() {
this.applyEnvironment(environment.cached());
this.load();
},
+ onHide() {
+ this.rememberHome();
+ },
onUnload() {
+ this.rememberHome();
this.disposed = true;
},
+ rememberHome() {
+ if (!this.data.ready || !accountKey()) return;
+ const { pets, selected, tasks, undoneCount, walkCount, name } = this.data;
+ getApp().homeSnapshot = {
+ account: accountKey(),
+ date: localDate(),
+ data: { pets, selected, tasks, undoneCount, walkCount, name },
+ };
+ },
updateEnvironmentLabel() {
this.setData({
headerSubtitle:
@@ -112,6 +135,7 @@ Page({
ready: true,
phase: "ready",
});
+ this.rememberHome();
// Location permission and weather never hold up the first usable screen.
this.refreshEnvironment();
} catch (error) {
diff --git a/pets-fe/pages/home/home.json b/pets-fe/pages/home/home.json
index 07951d1..4f94cf7 100644
--- a/pets-fe/pages/home/home.json
+++ b/pets-fe/pages/home/home.json
@@ -3,7 +3,6 @@
"usingComponents": {
"tab-bar": "/components/tab-bar/index",
"pet-picker": "/components/pet-picker/index",
- "startup-state": "/components/startup-state/index",
- "home-skeleton": "/components/home-skeleton/index"
+ "startup-state": "/components/startup-state/index"
}
}
diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml
index c3cffff..771c45f 100644
--- a/pets-fe/pages/home/home.wxml
+++ b/pets-fe/pages/home/home.wxml
@@ -1,8 +1,7 @@
-
+
-
-
+
暂时没能准备好首页
{{error}}
@@ -10,7 +9,6 @@
更新失败,保留上次的日常记录重试
- 正在更新今天的小日常…
今日的主角
@@ -91,7 +89,7 @@
{{item.title}}
- {{item.time_of_day}} · {{item.completed?'已完成':taskBusyIds[0]===item.id?'正在记录…':'点击完成'}}
+ {{item.all_pets?'全部宠物 · ':''}}{{item.time_of_day}} · {{item.completed?'已完成':taskBusyIds[0]===item.id?'正在记录…':'点击完成'}}
diff --git a/pets-fe/pages/home/home.wxss b/pets-fe/pages/home/home.wxss
index 7b5a6a8..79d0093 100644
--- a/pets-fe/pages/home/home.wxss
+++ b/pets-fe/pages/home/home.wxss
@@ -189,12 +189,6 @@
border-radius: 22rpx;
margin-bottom: 20rpx;
}
-.refresh-hint {
- text-align: center;
- font-size: 22rpx;
- color: #a48d7e;
- margin-bottom: 20rpx;
-}
.first-pet {
padding-top: 30rpx;
}
diff --git a/pets-fe/pages/pet-edit/pet-edit.js b/pets-fe/pages/pet-edit/pet-edit.js
index 53d3c13..896776c 100644
--- a/pets-fe/pages/pet-edit/pet-edit.js
+++ b/pets-fe/pages/pet-edit/pet-edit.js
@@ -41,9 +41,18 @@ Page({
this.setData({ gender: e.detail.value });
},
async choose() {
+ let selection;
try {
- const r = await wx.chooseMedia({ count: 1, mediaType: ["image"] });
- const file = await upload(r.tempFiles[0].tempFilePath);
+ selection = await wx.chooseMedia({ count: 1, mediaType: ["image"] });
+ } catch (e) {
+ if (/^chooseMedia:fail cancel\b/i.test((e && e.errMsg) || "")) return;
+ toast(e);
+ return;
+ }
+ const photo = selection && selection.tempFiles && selection.tempFiles[0];
+ if (!photo || !photo.tempFilePath) return;
+ try {
+ const file = await upload(photo.tempFilePath);
this.setData({ avatar_file_id: file.id, avatar_url: file.url });
} catch (e) {
toast(e);
diff --git a/pets-fe/pages/tasks/tasks.js b/pets-fe/pages/tasks/tasks.js
index 568fe3f..dc994c4 100644
--- a/pets-fe/pages/tasks/tasks.js
+++ b/pets-fe/pages/tasks/tasks.js
@@ -4,6 +4,7 @@ const { localDate, shiftDate } = require("../../utils/date");
Page({
data: {
pets: [],
+ petOptions: [],
tasks: [],
taskBusyIds: [],
completingAll: false,
@@ -32,10 +33,11 @@ Page({
if (date !== this.data.date) return;
const tasks = result.items.map((t) => ({
...t,
- petName: (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
+ petName: t.all_pets ? "全部宠物" : (pets.find((p) => p.id === t.pet_id) || {}).name || "宠物",
}));
this.setData({
pets,
+ petOptions: [...pets, { id: "", name: "全部宠物" }],
tasks,
undoneCount: tasks.filter((t) => !t.completed).length,
done: tasks.filter((t) => t.completed).length,
@@ -78,7 +80,7 @@ Page({
showForm: true,
editingId: task.id,
title: task.title,
- petIndex: Math.max(petIndex, 0),
+ petIndex: task.all_pets ? this.data.pets.length : Math.max(petIndex, 0),
category: task.category,
time_of_day: task.time_of_day,
repeat_rule: task.repeat_rule,
@@ -125,7 +127,9 @@ Page({
this.data.editingId ? "/tasks/" + this.data.editingId : "/tasks",
this.data.editingId ? "PUT" : "POST",
{
- pet_id: this.data.pets[this.data.petIndex].id,
+ ...(this.data.petIndex === this.data.pets.length
+ ? { all_pets: true }
+ : { pet_id: this.data.pets[this.data.petIndex].id }),
title: this.data.title.trim(),
category: this.data.category,
time_of_day: this.data.time_of_day,
diff --git a/pets-fe/pages/tasks/tasks.wxml b/pets-fe/pages/tasks/tasks.wxml
index a5c4e47..f311d29 100644
--- a/pets-fe/pages/tasks/tasks.wxml
+++ b/pets-fe/pages/tasks/tasks.wxml
@@ -71,12 +71,12 @@
照顾谁
- {{pets[petIndex].name||'请先添加宠物'}}
+ {{petOptions[petIndex].name||'请先添加宠物'}}
diff --git a/tests/miniprogram-smoke.test.cjs b/tests/miniprogram-smoke.test.cjs
index 485424b..104ece0 100644
--- a/tests/miniprogram-smoke.test.cjs
+++ b/tests/miniprogram-smoke.test.cjs
@@ -57,6 +57,33 @@ test("编辑任务走 PUT,并保留选中的宠物", async () => {
});
});
+test("新建任务可选择全部宠物,编辑后保留范围", async () => {
+ const writes = [];
+ const pets = [{ id: "a", name: "奶盖" }, { id: "b", name: "布丁" }];
+ const task = { id: "task-all", all_pets: true, pet_id: null, title: "换水", category: "water", time_of_day: "09:00", repeat_rule: "daily" };
+ const screen = page("tasks", async (route, method, body) => {
+ if (method === "POST" || method === "PUT") writes.push({ route, method, body });
+ if (route === "/pets") return pets;
+ if (route.startsWith("/tasks?")) return { items: [task] };
+ return task;
+ });
+ await screen.load();
+ assert.equal(screen.data.petOptions[2].name, "全部宠物");
+ assert.equal(screen.data.tasks[0].petName, "全部宠物");
+ screen.open();
+ screen.petChange({ detail: { value: "2" } });
+ screen.setData({ title: "换水" });
+ await screen.save();
+ assert.equal(writes[0].method, "POST");
+ assert.equal(writes[0].body.all_pets, true);
+ assert.equal(writes[0].body.pet_id, undefined);
+ screen.edit({ currentTarget: { dataset: { index: 0 } } });
+ assert.equal(screen.data.petIndex, 2);
+ await screen.save();
+ assert.equal(writes[1].method, "PUT");
+ assert.equal(writes[1].body.all_pets, true);
+});
+
test("多宠物遛狗发送全部 ID,结束后可再次开始", async () => {
const calls = [];
const active = { id: "walk-1", status: "active", started_at: new Date().toISOString(), pet_ids: ["a", "b"], distance_meters: 0, points: [] };
diff --git a/tests/miniprogram-startup.test.cjs b/tests/miniprogram-startup.test.cjs
index 7edbd5a..241608d 100644
--- a/tests/miniprogram-startup.test.cjs
+++ b/tests/miniprogram-startup.test.cjs
@@ -88,13 +88,13 @@ test("缓存 token 过期时只重新登录一次,延迟的旧 401 不清除
assert.equal(r.logins.length, 1);
});
-function home(login, secure) {
+function home(login, secure, app = { login }) {
let definition;
const wx = { getStorageSync: () => [], navigateTo: () => {} };
vm.runInNewContext(fs.readFileSync("pets-fe/pages/home/home.js", "utf8"), {
wx,
Date,
- getApp: () => ({ login }),
+ getApp: () => app,
Page: (value) => {
definition = value;
},
@@ -193,3 +193,40 @@ test("首页请求失败不显示空数据状态,刷新失败保留已有记
assert.equal(page.data.pets[0].id, "existing");
assert.equal(page.data.error, "网络异常");
});
+
+test("从其他页面回到日常立即显示已有数据,后台刷新不清空内容", async () => {
+ const app = { login: async () => "token", globalData: { token: "token", user: { id: "user-1" } } };
+ const first = home(app.login, data, app);
+ await first.load();
+ first.setData({ pets: [{ id: "pet-1" }], tasks: [{ id: "task-1", completed: true }], undoneCount: 0 });
+ first.onUnload();
+ let finish;
+ const pending = new Promise((resolve) => { finish = resolve; });
+ const next = home(app.login, async (route) => { await pending; return data(route); }, app);
+ next.onLoad();
+ assert.equal(next.data.ready, true);
+ assert.equal(next.data.phase, "ready");
+ assert.equal(next.data.pets[0].id, "pet-1");
+ assert.equal(next.data.tasks[0].completed, true);
+ const refreshing = next.load();
+ await tick();
+ assert.equal(next.data.ready, true);
+ assert.equal(next.data.pets[0].id, "pet-1");
+ finish();
+ await refreshing;
+ assert.equal(next.data.loading, false);
+});
+
+test("日常缓存不跨账号、不显示上一天的任务", async () => {
+ const app = { login: async () => "token", globalData: { user: { id: "new-user" } }, homeSnapshot: {
+ account: "old-user", date: "2026-10-05", data: { pets: [{ id: "private" }] },
+ } };
+ const page = home(app.login, data, app);
+ page.onLoad();
+ assert.equal(page.data.ready, false);
+ assert.equal(page.data.pets.length, 0);
+ app.homeSnapshot.account = "new-user";
+ app.homeSnapshot.date = "2026-10-04";
+ page.onLoad();
+ assert.equal(page.data.ready, false);
+});
diff --git a/tests/pet-avatar.test.cjs b/tests/pet-avatar.test.cjs
new file mode 100644
index 0000000..153a67f
--- /dev/null
+++ b/tests/pet-avatar.test.cjs
@@ -0,0 +1,76 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const vm = require("node:vm");
+const path = require("node:path");
+
+function screen(chooseMedia, upload) {
+ let definition;
+ const errors = [];
+ vm.runInNewContext(
+ fs.readFileSync(path.join(__dirname, "../pets-fe/pages/pet-edit/pet-edit.js"), "utf8"),
+ {
+ Page: (value) => { definition = value; },
+ wx: { chooseMedia },
+ require: () => ({ upload, toast: (error) => errors.push(error) }),
+ },
+ );
+ return {
+ ...definition,
+ data: { ...definition.data, avatar_file_id: "original-id", avatar_url: "original-photo" },
+ errors,
+ setData(changes) { Object.assign(this.data, changes); },
+ };
+}
+
+test("取消选择宠物照片不上传、不提示错误,保留原头像", async () => {
+ let uploads = 0;
+ const page = screen(
+ async () => { throw { errMsg: "chooseMedia:fail cancel" }; },
+ async () => { uploads++; },
+ );
+ await page.choose();
+ assert.equal(uploads, 0);
+ assert.equal(page.errors.length, 0);
+ assert.equal(page.data.avatar_file_id, "original-id");
+ assert.equal(page.data.avatar_url, "original-photo");
+});
+
+test("空的照片选择结果不上传", async () => {
+ const page = screen(async () => ({ tempFiles: [] }), async () => assert.fail("不应上传"));
+ await page.choose();
+ assert.equal(page.errors.length, 0);
+ assert.equal(page.data.avatar_file_id, "original-id");
+});
+
+test("实际选择失败仍提示错误", async () => {
+ const error = { errMsg: "chooseMedia:fail permission denied" };
+ const page = screen(async () => { throw error; }, async () => assert.fail("不应上传"));
+ await page.choose();
+ assert.equal(page.errors[0], error);
+});
+
+test("实际上传失败仍提示错误并保留原头像", async () => {
+ const error = new Error("上传失败");
+ const page = screen(
+ async () => ({ tempFiles: [{ tempFilePath: "/tmp/photo.jpg" }] }),
+ async () => { throw error; },
+ );
+ await page.choose();
+ assert.equal(page.errors[0], error);
+ assert.equal(page.data.avatar_file_id, "original-id");
+});
+
+test("上传成功后更新宠物头像", async () => {
+ const page = screen(
+ async () => ({ tempFiles: [{ tempFilePath: "/tmp/photo.jpg" }] }),
+ async (filePath) => {
+ assert.equal(filePath, "/tmp/photo.jpg");
+ return { id: "new-id", url: "new-photo" };
+ },
+ );
+ await page.choose();
+ assert.equal(page.errors.length, 0);
+ assert.equal(page.data.avatar_file_id, "new-id");
+ assert.equal(page.data.avatar_url, "new-photo");
+});