Files
rond-with-you/internal/web/taxonomy_test.go
T
tamakyi 693ee82ab8 修「填了显示名却说还没有中文名」,并给枚举拼错拦一道
用户报:把某行的导出值改成 MKPOICategoryCarRent、又手填了显示名「租车」,
保存成功、框里也回填了,页面却仍挂着「还没有中文名」。

两件事叠在一起:

1. 判断错了。Missing 只看内置表,没看有没有自定义覆盖 ——
   填过了还照样提示。改成 categoryLabelMissing(v) && 没有覆盖。
   补 TestCategoryLabelMissingRespectsOverride,并做变异验证(去掉那半个条件即报错)。

2. 值本身是拼错的。MKPOICategoryCarRent 不是真机的枚举,正确是
   MKPOICategoryCarRental(少了个 al)——站内看不出任何异常,但导回手机后
   那个地点显示不出类别。这正是加「显示名」时提醒过的风险,所以补一道防线:
   编辑页把内置枚举名渲染给脚本,往 poi_category 写值的两个入口
   (类别台账的「改名」、地点行内编辑的「自定义类别」)都过 okEnum(),
   不在已知枚举里就 confirm,还能猜「你是不是想写 MKPOICategoryCarRental」。
   中文值和已知枚举不打扰。

另外:这次用脚本往模板写 JS 时,\n 被多转义一层成了真换行,JS 单引号字符串
含裸换行 ⇒ 整块 <script> 语法错误、页面交互全哑,而模板解析测试照样通过。
新增 inline_script_test.go 扫所有内联脚本块(现在 31 个),字符串跨行就报错
并指出文件行号,已做变异验证。
2026-09-20 12:22:11 +08:00

321 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 (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// taxonomyPost 按页面 fetch 的方式调「活动与类别」那几个端点,返回解析后的 JSON。
func taxonomyPost(t *testing.T, f *movementFixture, path string, form url.Values) map[string]any {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
ctx := req.WithContext(f.ctx)
switch path {
case "/admin/edit/category/rename":
f.srv.editCategoryRename(rec, ctx)
case "/admin/edit/category/clear":
f.srv.editCategoryClear(rec, ctx)
case "/admin/edit/activity/merge":
f.srv.editActivityMerge(rec, ctx)
case "/admin/edit/category/label":
f.srv.editCategoryLabel(rec, ctx)
default:
t.Fatalf("未接线的端点 %s", path)
}
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("%s 返回的不是 JSON: %s", path, rec.Body.String())
}
return out
}
// 类别没有表,「改名」只能是按值批量改地点;填成另一个已有值就是合并。
func TestCategoryRenameMergesIntoExisting(t *testing.T) {
f := newMovementFixture(t)
mk := func(pk int, cat string) {
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
VALUES ($1,$2,'类别测试点',30,120,$3)`, f.dsID, pk, cat)
}
mk(690001, "测试类别甲")
mk(690002, "测试类别甲")
mk(690003, "测试类别乙")
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690001 AND 690003`, f.dsID)
})
got := taxonomyPost(t, f, "/admin/edit/category/rename", url.Values{
"from": {"测试类别甲"}, "to": {"测试类别乙"},
})
if got["places"] != float64(2) {
t.Fatalf("应报告 2 个地点被改,实际 %v", got["places"])
}
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别乙'
AND src_pk BETWEEN 690001 AND 690003`, f.dsID); n != 3 {
t.Fatalf("甲应整体并进乙(共 3 个),实际 %d 个", n)
}
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别甲'`, f.dsID); n != 0 {
t.Fatalf("甲应一个不剩,还剩 %d 个", n)
}
}
// 新类别填空不能当成「改名成空」把类别悄悄丢掉——那是「清空」的活,要另走一步。
func TestCategoryRenameRefusesEmptyTarget(t *testing.T) {
f := newMovementFixture(t)
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
VALUES ($1,690011,'类别测试点',30,120,'测试类别丙')`, f.dsID)
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=690011`, f.dsID)
})
got := taxonomyPost(t, f, "/admin/edit/category/rename", url.Values{"from": {"测试类别丙"}, "to": {" "}})
if got["error"] == nil {
t.Fatal("新类别为空时应报错")
}
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别丙'`, f.dsID); n != 1 {
t.Fatalf("报错就不该动数据,实际只剩 %d 个", n)
}
}
// 「清空」= 摘掉类别:那些地点退回未指定(NULL),去重列表里也就不再出现。
func TestCategoryClearDetachesPlaces(t *testing.T) {
f := newMovementFixture(t)
for _, pk := range []int{690021, 690022} {
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
VALUES ($1,$2,'类别测试点',30,120,'测试类别丁')`, f.dsID, pk)
}
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690021 AND 690022`, f.dsID)
})
got := taxonomyPost(t, f, "/admin/edit/category/clear", url.Values{"from": {"测试类别丁"}})
if got["places"] != float64(2) {
t.Fatalf("应报告 2 个地点被摘掉,实际 %v", got["places"])
}
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk BETWEEN 690021 AND 690022
AND poi_category IS NULL`, f.dsID); n != 2 {
t.Fatalf("应写 NULL 而不是空串,实际 %d 个", n)
}
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='测试类别丁'`, f.dsID); n != 0 {
t.Fatalf("类别应彻底消失,还剩 %d 个", n)
}
}
// 合并:源的到访整体转到目标,源活动被删,is_home/is_work 跟着目标走
// (那两个是从活动抄到到访上的冗余标记,不跟着改就会留下一个矛盾的到访)。
func TestActivityMergeMovesVisitsThenDeletesSource(t *testing.T) {
f := newMovementFixture(t)
placeID, _ := fillupPlace(t, f)
mkAct := func(pk int, name string, home bool) int64 {
var id int64
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO activities
(dataset_id, src_pk, name, color, is_home) VALUES ($1,$2,$3,'',$4) RETURNING id`,
f.dsID, pk, name, home).Scan(&id); err != nil {
t.Fatalf("建活动失败: %v", err)
}
t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE id=$1`, id) })
return id
}
from := mkAct(695001, "合并测试甲", false)
to := mkAct(695002, "合并测试乙", true)
f.mustExec(t, `UPDATE visits SET activity_id=$2, is_home=FALSE WHERE dataset_id=$1 AND place_id=$3`,
f.dsID, from, placeID)
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND activity_id=$2`, f.dsID, from); n == 0 {
t.Fatal("合并前应有到访挂在源活动上")
}
got := taxonomyPost(t, f, "/admin/edit/activity/merge", url.Values{
"from": {itoa(from)}, "to": {itoa(to)},
})
if got["error"] != nil {
t.Fatalf("合并不该失败: %v", got["error"])
}
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND activity_id=$2`, f.dsID, to); n == 0 {
t.Fatal("到访应转到目标活动名下")
}
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND is_home`, f.dsID); n == 0 {
t.Fatal("目标活动的 is_home 应抄到转过来的到访上")
}
if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND id=$2`, f.dsID, from); n != 0 {
t.Fatal("源活动应被删掉")
}
if n := f.scalar(t, `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZACTIVITY' AND src_pk=695001`,
f.dsID); n != 0 {
t.Fatal("源活动的原始行留档应一起清掉,否则导出还会带它")
}
}
// 合并到自己或选空都必须拒绝,别把活动连同它的到访一起清掉。
func TestActivityMergeRefusesDegenerateInput(t *testing.T) {
f := newMovementFixture(t)
var id int64
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO activities (dataset_id, src_pk, name, color)
VALUES ($1,695011,'合并测试丙','') RETURNING id`, f.dsID).Scan(&id); err != nil {
t.Fatalf("建活动失败: %v", err)
}
t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM activities WHERE id=$1`, id) })
for _, form := range []url.Values{
{"from": {itoa(id)}, "to": {itoa(id)}},
{"from": {itoa(id)}, "to": {"0"}},
{"from": {"0"}, "to": {itoa(id)}},
} {
if got := taxonomyPost(t, f, "/admin/edit/activity/merge", form); got["error"] == nil {
t.Fatalf("%v 应被拒绝", form)
}
}
if n := f.scalar(t, `SELECT count(*) FROM activities WHERE dataset_id=$1 AND id=$2`, f.dsID, id); n != 1 {
t.Fatal("被拒绝的合并不该动数据")
}
}
// 显示名的兜底顺序:站点自定义 > 内置表 > 去掉前缀的原值。
// 「还没有中文名」只该命中「是 MapKit 枚举、但内置表里没有」这一种——
// 中文值(站内自己写的)自带名字,不该被提示去补。
func TestCategoryLabelFallbacks(t *testing.T) {
if got := categoryLabel("MKPOICategoryBeach", nil); got != "海滩" {
t.Errorf("内置名丢了: %q", got)
}
if got := categoryLabel("MKPOICategoryBeach", map[string]string{"MKPOICategoryBeach": "海边"}); got != "海边" {
t.Errorf("自定义应盖过内置名,实际 %q", got)
}
if got := categoryLabel("MKPOICategoryNope", nil); got != "Nope" {
t.Errorf("内置表没有的枚举应去掉前缀,实际 %q", got)
}
if got := categoryLabel("一般道路", nil); got != "一般道路" {
t.Errorf("中文值应原样返回,实际 %q", got)
}
for v, want := range map[string]bool{
"MKPOICategoryNope": true, "MKPOICategoryBeach": false, "一般道路": false,
} {
if got := categoryLabelMissing(v); got != want {
t.Errorf("categoryLabelMissing(%q)=%v,期望 %v", v, got, want)
}
}
}
// 关键不变量:改「显示名」只影响显示,places.poi_category 一个字都不能动——
// 那才是导出回手机的类别值,中文名跟着写进去就把数据污染了。
func TestCategoryLabelNeverTouchesValue(t *testing.T) {
f := newMovementFixture(t)
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
VALUES ($1,692001,'显示名测试点',30,120,'MKPOICategoryBeach')`, f.dsID)
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=692001`, f.dsID)
f.pg.ExecContext(f.ctx, `DELETE FROM category_labels WHERE value='MKPOICategoryBeach'`)
})
if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{
"value": {"MKPOICategoryBeach"}, "label": {"海边"},
}); got["error"] != nil {
t.Fatalf("改显示名失败: %v", got["error"])
}
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk=692001
AND poi_category='MKPOICategoryBeach'`, f.dsID); n != 1 {
t.Fatal("改显示名不能碰 poi_category——那是要导出回手机的类别值")
}
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND poi_category='海边'`, f.dsID); n != 0 {
t.Fatal("中文名不该被写成类别值")
}
}
// 覆盖要贯穿到各处的中文名(补全页下拉、编辑页台账),清空则恢复内置默认名。
func TestCategoryLabelOverridesEverywhereThenResets(t *testing.T) {
f := newMovementFixture(t)
// 造一个地点让这个类别「在用」——台账里只会列出真在用的值
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
VALUES ($1,692011,'显示名测试点',30,120,'MKPOICategoryBeach')`, f.dsID)
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=692011`, f.dsID)
f.pg.ExecContext(f.ctx, `DELETE FROM category_labels WHERE value='MKPOICategoryBeach'`)
})
render := func(h http.HandlerFunc, path string) string {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest("GET", path, nil).WithContext(f.ctx))
if rec.Code != http.StatusOK {
t.Fatalf("%s 渲染失败: %d", path, rec.Code)
}
return rec.Body.String()
}
// 没覆盖时用内置名,并把它当成「留空时的默认值」露出来
if body := render(f.srv.editPage, "/admin/edit"); !strings.Contains(body, "留空=默认「海滩」") {
t.Fatal("未自定义时应把内置默认名显示出来")
}
if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{
"value": {"MKPOICategoryBeach"}, "label": {"海边"},
}); got["custom"] != true {
t.Fatalf("应报告已自定义,实际 %v", got)
}
edit := render(f.srv.editPage, "/admin/edit")
if !strings.Contains(edit, `value="海边"`) || !strings.Contains(edit, "已自定义") {
t.Error("编辑页台账应显示自定义的显示名")
}
fill := render(f.srv.adminFillupPage, "/admin/fillup")
if !strings.Contains(fill, "海边(在用 ") {
t.Error("补全页下拉的中文名也该跟着走(两套表已并成一套)")
}
// 传空 = 恢复内置默认名,覆盖行要删掉
if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{
"value": {"MKPOICategoryBeach"}, "label": {" "},
}); got["custom"] != false {
t.Fatalf("清空应报告已恢复默认,实际 %v", got)
}
if n := f.scalar(t, `SELECT count(*) FROM category_labels WHERE value='MKPOICategoryBeach'`); n != 0 {
t.Fatal("恢复默认时覆盖行应被删掉,而不是留一条空的")
}
if body := render(f.srv.adminFillupPage, "/admin/fillup"); strings.Contains(body, "海边") {
t.Error("恢复默认后不该再出现自定义名")
}
}
// 填过显示名之后就不该再标「还没有中文名」——判断得看覆盖,不能只看内置表。
// (2026-09-20 用户把「租车点」改成拼错的 MKPOICategoryCarRent、又手填了显示名,
// 页面却还挂着「还没有中文名」,就是这么来的。)
func TestCategoryLabelMissingRespectsOverride(t *testing.T) {
const val = "MKPOICategoryCarRent" // 真机没有这个枚举,内置表自然也没有
f := newMovementFixture(t)
f.mustExec(t, `INSERT INTO places (dataset_id, src_pk, name, lat, lon, poi_category)
VALUES ($1,692021,'显示名测试点',30,120,$2)`, f.dsID, val)
t.Cleanup(func() {
f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk=692021`, f.dsID)
f.pg.ExecContext(f.ctx, `DELETE FROM category_labels WHERE value=$1`, val)
})
body := func() string {
rec := httptest.NewRecorder()
f.srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(f.ctx))
if rec.Code != http.StatusOK {
t.Fatalf("渲染失败: %d", rec.Code)
}
return rec.Body.String()
}
if b := body(); !strings.Contains(b, "还没有中文名") || !strings.Contains(b, "填一个中文名") {
t.Fatal("既不在内置表、也没填过,应当提示去补一个中文名")
}
if got := taxonomyPost(t, f, "/admin/edit/category/label", url.Values{
"value": {val}, "label": {"租车"},
}); got["custom"] != true {
t.Fatalf("应报告已自定义,实际 %v", got)
}
b := body()
if strings.Contains(b, "还没有中文名") {
t.Error("已经填过显示名了,不该再说「还没有中文名」")
}
if !strings.Contains(b, "已自定义") {
t.Error("填过显示名之后应当标成「已自定义」")
}
if !strings.Contains(b, `value="租车"`) {
t.Error("显示名框应回填已保存的名字")
}
}