用户报:把某行的导出值改成 MKPOICategoryCarRent、又手填了显示名「租车」, 保存成功、框里也回填了,页面却仍挂着「还没有中文名」。 两件事叠在一起: 1. 判断错了。Missing 只看内置表,没看有没有自定义覆盖 —— 填过了还照样提示。改成 categoryLabelMissing(v) && 没有覆盖。 补 TestCategoryLabelMissingRespectsOverride,并做变异验证(去掉那半个条件即报错)。 2. 值本身是拼错的。MKPOICategoryCarRent 不是真机的枚举,正确是 MKPOICategoryCarRental(少了个 al)——站内看不出任何异常,但导回手机后 那个地点显示不出类别。这正是加「显示名」时提醒过的风险,所以补一道防线: 编辑页把内置枚举名渲染给脚本,往 poi_category 写值的两个入口 (类别台账的「改名」、地点行内编辑的「自定义类别」)都过 okEnum(), 不在已知枚举里就 confirm,还能猜「你是不是想写 MKPOICategoryCarRental」。 中文值和已知枚举不打扰。 另外:这次用脚本往模板写 JS 时,\n 被多转义一层成了真换行,JS 单引号字符串 含裸换行 ⇒ 整块 <script> 语法错误、页面交互全哑,而模板解析测试照样通过。 新增 inline_script_test.go 扫所有内联脚本块(现在 31 个),字符串跨行就报错 并指出文件行号,已做变异验证。
321 lines
14 KiB
Go
321 lines
14 KiB
Go
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("显示名框应回填已保存的名字")
|
||
}
|
||
}
|