这一页原来只列静态表(30 个 MapKit 枚举),库里真在用的值不在候选里—— 真机自己写的中文类别(一般道路 / 彩票店 / 租车点 / 美食街)只能手打, 一不小心就挑个名字相近的静态枚举,跟已有数据并存成两种,同一个意思分两处统计。 现在三段拼,同一个值只出现一次: 1. 数据集里在用的,按地点数降序,标「中文名(在用 N)」 2. 真机用过的 12 种(当前数据里还没有的),标「真机用过」 3. 其余常见 MapKit 类别 在用的那份信息更准,所以顶掉静态表里的说明——既在用又在静态表里的只出现一次。 真实数据实测:36 项 = 16 在用 + 18 静态 +(不动)+(其它「自己写」),零重复; 排在前面的是 餐厅(在用 51)/ 商店(在用 29)/ … / 一般道路(在用 5)。
379 lines
14 KiB
Go
379 lines
14 KiB
Go
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)
|
||
}
|
||
}
|