package web import ( "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" ) // fillupPlace 在测试数据集里造一个「没类别、名下到访全都没活动」的地点。 func fillupPlace(t *testing.T, f *movementFixture) (placeID int64, placePK int) { t.Helper() const pk = 670001 if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) VALUES ($1,$2,'测试补全点',31.1,121.1) RETURNING id`, f.dsID, pk).Scan(&placeID); err != nil { t.Fatalf("建地点失败: %v", err) } for _, vpk := range []int{670002, 670003} { if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits (dataset_id, src_pk, place_id, arrival, departure) VALUES ($1,$2,$3,'2026-01-01 10:00:00+08','2026-01-01 11:00:00+08')`, f.dsID, vpk, placeID); err != nil { t.Fatalf("建到访失败: %v", err) } } t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM visits WHERE dataset_id=$1 AND place_id=$2`, f.dsID, placeID) f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE id=$1`, placeID) }) return placeID, pk } func applyFillup(t *testing.T, f *movementFixture, form url.Values) string { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest("POST", "/admin/fillup/apply", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") f.srv.adminFillupApply(rec, req.WithContext(f.ctx)) if rec.Code != http.StatusSeeOther { t.Fatalf("补全应跳回页面: code=%d body=%s", rec.Code, rec.Body.String()) } return rec.Header().Get("Location") } // 补活动是按**地点**批量做的:一次把它名下所有没活动的到访一起设,不是只改一条。 func TestFillupSetsActivityForWholePlace(t *testing.T) { f := newMovementFixture(t) placeID, _ := fillupPlace(t, f) acts, err := f.srv.activityOptions(f.ctx, f.dsID) if err != nil || len(acts) == 0 { t.Fatalf("取活动失败: %v", err) } target := acts[0].Name loc := applyFillup(t, f, url.Values{ "act_" + strconv.FormatInt(placeID, 10): {target}, }) if strings.Contains(loc, "err=") { t.Fatalf("补全失败: %s", loc) } if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id=$2 AND activity_id IS NULL`, f.dsID, placeID); n != 0 { t.Fatalf("该地点两条到访都该设上活动,还剩 %d 条没设", n) } var got string if err := f.pg.QueryRowContext(f.ctx, `SELECT a.name FROM visits v JOIN activities a ON a.id=v.activity_id WHERE v.dataset_id=$1 AND v.place_id=$2 LIMIT 1`, f.dsID, placeID).Scan(&got); err != nil { t.Fatalf("读活动失败: %v", err) } if got != target { t.Fatalf("活动应为 %q,实际 %q", target, got) } } // 「只填空」:已经有值的一律不动,哪怕表单里填了别的。 func TestFillupNeverOverwrites(t *testing.T) { f := newMovementFixture(t) placeID, _ := fillupPlace(t, f) // 先给一条到访设好活动,给地点设好类别 acts, _ := f.srv.activityOptions(f.ctx, f.dsID) first := acts[0].Name second := "补全测试活动" if len(acts) > 1 { second = acts[1].Name } if _, err := f.pg.ExecContext(f.ctx, `UPDATE visits SET activity_id=$1 WHERE dataset_id=$2 AND place_id=$3 AND src_pk=670002`, acts[0].ID, f.dsID, placeID); err != nil { t.Fatalf("预置活动失败: %v", err) } if _, err := f.pg.ExecContext(f.ctx, `UPDATE places SET poi_category='MKPOICategoryPark' WHERE id=$1`, placeID); err != nil { t.Fatalf("预置类别失败: %v", err) } applyFillup(t, f, url.Values{ "act_" + strconv.FormatInt(placeID, 10): {second}, "cat_" + strconv.FormatInt(placeID, 10): {"MKPOICategoryZoo"}, }) // 原来那条不能被改掉 var got string if err := f.pg.QueryRowContext(f.ctx, `SELECT a.name FROM visits v JOIN activities a ON a.id=v.activity_id WHERE v.dataset_id=$1 AND v.place_id=$2 AND v.src_pk=670002`, f.dsID, placeID).Scan(&got); err != nil { t.Fatalf("读活动失败: %v", err) } if got != first { t.Fatalf("已有活动的到访不该被改:期望 %q,实际 %q", first, got) } // 已经填过的类别也不能被覆盖 var cat string if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(poi_category,'') FROM places WHERE id=$1`, placeID).Scan(&cat); err != nil { t.Fatalf("读类别失败: %v", err) } if cat != "MKPOICategoryPark" { t.Fatalf("已有类别不该被覆盖,实际 %q", cat) } // 但另一条原本没活动的到访应当补上:这个地点不该再有无活动的到访 if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id=$2 AND activity_id IS NULL`, f.dsID, placeID); n != 0 { t.Fatalf("原本没活动的那条应当补上,还剩 %d 条没设", n) } } // 填一个现有列表里没有的名字,应当自动建成新的活动类型(用户要的「匹配不上就新增」)。 func TestFillupCreatesUnknownActivity(t *testing.T) { f := newMovementFixture(t) placeID, _ := fillupPlace(t, f) before := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1`, f.dsID) applyFillup(t, f, url.Values{"act_" + strconv.FormatInt(placeID, 10): {"洗碗机测试活动"}}) if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND name=$2`, f.dsID, "洗碗机测试活动"); n != 1 { t.Fatalf("该新建一个活动类型,实际 %d 个", n) } if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1`, f.dsID); n != before+1 { t.Fatalf("活动总数应 +1,实际 %d(原 %d)", n, before) } t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE dataset_id=$1 AND name=$2`, f.dsID, "洗碗机测试活动") }) } func itoa(v int64) string { return strings.TrimSpace(strings.ReplaceAll(strings.Trim(strings.Join([]string{}, ""), " "), " ", "")) + intToString(v) } func intToString(v int64) string { if v == 0 { return "0" } neg := v < 0 if neg { v = -v } var b []byte for v > 0 { b = append([]byte{byte('0' + v%10)}, b...) v /= 10 } if neg { return "-" + string(b) } return string(b) } func TestClampPage(t *testing.T) { cases := []struct { raw string count int want int }{ {"", 8, 1}, {"1", 8, 1}, {"8", 8, 8}, {"999", 8, 8}, // 超了就停在最后一页,别给空白页 {"0", 8, 1}, {"-3", 8, 1}, {"abc", 8, 1}, {"3", 0, 3}, // 没有数据时不夹(总会在最后回到 1) } for _, c := range cases { if got := clampPage(c.raw, c.count); got != c.want { t.Errorf("clampPage(%q, %d) = %d,期望 %d", c.raw, c.count, got, c.want) } } } // 每页只渲染 30 行;页码越界时夹回最后一页。 func TestFillupPagination(t *testing.T) { f := newMovementFixture(t) // 造 35 个「缺类别」的地点,凑出两页 for i := 0; i < 35; i++ { pk := 680000 + i if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) VALUES ($1,$2,$3,$4,$5)`, f.dsID, pk, "分页测试点", 30.0+float64(i)*0.001, 120.0); err != nil { t.Fatalf("造地点失败: %v", err) } } t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 680000 AND 680099`, f.dsID) }) countRows := func(url string) (rows, pageCount int) { t.Helper() rec := httptest.NewRecorder() req := httptest.NewRequest("GET", url, nil).WithContext(f.ctx) f.srv.adminFillupPage(rec, req) if rec.Code != http.StatusOK { t.Fatalf("%s 渲染失败: %d", url, rec.Code) } body := rec.Body.String() // 数类别下拉本身;旁边那个「其它(自己写)」文本框的 name 也以 cat_ 开头,得排掉 return strings.Count(body, `"); end > 0 { seg = seg[:end] } else { t.Fatal("没找到类别下拉") } if !strings.Contains(seg, `value="测试类别戊"`) { t.Fatal("库里在用的中文类别应出现在下拉里") } if !strings.Contains(seg, "测试类别戊(在用 2)") { t.Error("在用类别应标出它被几个地点用着") } // 既在用又躺在静态表里的,只能出现一次(在用那份信息更准) if n := strings.Count(seg, `value="MKPOICategoryRestaurant"`); n != 1 { t.Fatalf("既在用又在静态表的值应只出现一次,实际 %d 次", n) } // 静态表里的值一旦真在用,就该显示成「在用」而不是「真机用过」。 // 不钉具体数字:夹具导的是真实备份,那个类别本来就有几十个地点在用。 if !strings.Contains(seg, `value="MKPOICategoryRestaurant">餐厅(在用 `) { t.Error("静态表里的值一旦真在用,就该显示成「在用」而不是「真机用过」") } // 在用的排最前面:排在静态表里那些还没用过的值之前 if a, b := strings.Index(body, `value="测试类别戊"`), strings.Index(body, `value="MKPOICategoryCafe"`); a < 0 || b < 0 || a > b { t.Errorf("在用类别应排在静态表前面(在用=%d 静态=%d)", a, b) } }