Files
rond-with-you/internal/web/fillup_test.go
T
tamakyi bf23fc62d7 补全页:活动改成和类别同款下拉,两列样式一致
活动原本是 input+datalist、类别是 select,同在一行列里长得不一样,看着别扭。
现在两者共用同一套写法:下拉 + 末尾「其它(自己写)…」+ 选中后露出的文本框
(后端 act_<id>==__custom__ 时读 act_<id>__custom,与类别同一套约定)。
CSS 类也合成一个 fill-sel,脚本只需遍历一次。

顺带:删掉不再需要的 datalist;采纳建议、草稿存取都改走 select.fill-sel。
草稿恢复时对下拉派发 change,所以「其它」的文本框也能跟着恢复显示。
2026-09-19 23:46:02 +08:00

304 lines
11 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)
}
}