Files
rond-with-you/internal/web/fillup_test.go
T
tamakyi cbb45b6d6b 补全页的类别候选取「库里在用的 ∪ 静态表」
这一页原来只列静态表(30 个 MapKit 枚举),库里真在用的值不在候选里——
真机自己写的中文类别(一般道路 / 彩票店 / 租车点 / 美食街)只能手打,
一不小心就挑个名字相近的静态枚举,跟已有数据并存成两种,同一个意思分两处统计。

现在三段拼,同一个值只出现一次:
  1. 数据集里在用的,按地点数降序,标「中文名(在用 N)」
  2. 真机用过的 12 种(当前数据里还没有的),标「真机用过」
  3. 其余常见 MapKit 类别
在用的那份信息更准,所以顶掉静态表里的说明——既在用又在静态表里的只出现一次。

真实数据实测:36 项 = 16 在用 + 18 静态 +(不动)+(其它「自己写」),零重复;
排在前面的是 餐厅(在用 51)/ 商店(在用 29)/ … / 一般道路(在用 5)。
2026-09-20 10:07:00 +08:00

379 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, `<select name="cat_`), strings.Count(body, `class="pager"`)
}
rows, pagers := countRows("/admin/fillup")
if rows != fillupPerPage {
t.Fatalf("第 1 页应有 %d 行,实际 %d", fillupPerPage, rows)
}
if pagers == 0 {
t.Error("多于 1 页时应出现分页控件")
}
if rows, _ := countRows("/admin/fillup?page=2"); rows == 0 || rows > fillupPerPage {
t.Fatalf("第 2 页行数异常: %d", rows)
}
// 越界页码要夹回最后一页,而不是给一页空白
if rows, _ := countRows("/admin/fillup?page=9999"); rows == 0 {
t.Error("越界页码应夹到最后一页,不该是空白页")
}
}
// 类别选「其它(自己写)」时,写进去的是旁边文本框里的内容。
func TestFillupCustomCategory(t *testing.T) {
f := newMovementFixture(t)
placeID, _ := fillupPlace(t, f)
key := "cat_" + strconv.FormatInt(placeID, 10)
applyFillup(t, f, url.Values{key: {"__custom__"}, key + "__custom": {"美食街"}})
var got string
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(poi_category,'') FROM places WHERE id=$1`, placeID).Scan(&got); err != nil {
t.Fatalf("读类别失败: %v", err)
}
if got != "美食街" {
t.Fatalf("应写入手工填的类别,实际 %q", got)
}
}
// 选了「其它」却什么也没写:不写库,别把空串当成类别。
func TestFillupCustomCategoryEmptyIsSkipped(t *testing.T) {
f := newMovementFixture(t)
placeID, _ := fillupPlace(t, f)
key := "cat_" + strconv.FormatInt(placeID, 10)
applyFillup(t, f, url.Values{key: {"__custom__"}, key + "__custom": {" "}})
var got string
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(poi_category,'') FROM places WHERE id=$1`, placeID).Scan(&got); err != nil {
t.Fatalf("读类别失败: %v", err)
}
if got != "" {
t.Fatalf("没填内容时不该写库,实际 %q", got)
}
}
// 活动下拉选「其它(自己写)」时,读的是旁边文本框里的名字——与类别同一套写法。
func TestFillupCustomActivity(t *testing.T) {
f := newMovementFixture(t)
placeID, _ := fillupPlace(t, f)
key := "act_" + strconv.FormatInt(placeID, 10)
applyFillup(t, f, url.Values{key: {"__custom__"}, key + "__custom": {"爬山"}})
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 != "爬山" {
t.Fatalf("应写入手工填的活动名,实际 %q", got)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE dataset_id=$1 AND name=$2`, f.dsID, "爬山")
})
}
// 选了「其它」却没写名字:什么都不做,别建一个空名活动。
func TestFillupCustomActivityEmptyIsSkipped(t *testing.T) {
f := newMovementFixture(t)
placeID, _ := fillupPlace(t, f)
key := "act_" + strconv.FormatInt(placeID, 10)
before := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1`, f.dsID)
applyFillup(t, f, url.Values{key: {"__custom__"}, key + "__custom": {" "}})
if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1`, f.dsID); n != before {
t.Fatalf("没填名字不该建类型,活动数 %d → %d", before, n)
}
}
// 「手工填过的值自动进候选」是纯前端行为,Go 测不到运行时,只能钉住它还在模板里。
// 这东西少一半都不会报错,只会悄悄退回「后面每行都得重新打一遍」——
// 所以把三个关键标记都钉住:存下来的键、注入选项的标记、显示用的前缀。
func TestFillupKeepsHandwrittenCandidates(t *testing.T) {
f := newMovementFixture(t)
fillupPlace(t, f)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/fillup", nil).WithContext(f.ctx)
f.srv.adminFillupPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("渲染失败: %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"rond-fillup-newopts", "data-local", "新填:"} {
if !strings.Contains(body, want) {
t.Errorf("模板里找不到 %q —— 「手工填过的值自动进候选」失效了", want)
}
}
}
// 补全页的类别候选要「数据里在用的 ∪ 静态表」:库里真在用的值(尤其是真机自己写的中文)
// 原来在这一页根本选不到,只能手打,跟静态枚举并存成两种,同一个意思分两处统计。
func TestFillupCategoriesIncludeUsedOnes(t *testing.T) {
f := newMovementFixture(t)
fillupPlace(t, f) // 补全页得有行才会渲染出下拉
for _, p := range []struct {
pk int
cat string
}{
{691001, "测试类别戊"}, {691002, "测试类别戊"}, {691003, "MKPOICategoryRestaurant"},
} {
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
VALUES ($1,$2,'类别候选测试点',30,120,$3)`, f.dsID, p.pk, p.cat)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 691001 AND 691003`, f.dsID)
})
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/fillup", nil).WithContext(f.ctx)
f.srv.adminFillupPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("渲染失败: %d", rec.Code)
}
body := rec.Body.String()
// 下拉每行渲染一份,要数重复就得只看其中一个
seg := body[strings.Index(body, `<select name="cat_`):]
if end := strings.Index(seg, "</select>"); 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)
}
}