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"); +});