Files
rond-with-you/internal/web/taxonomy_test.go
T
tamakyi da6fe54ab4 数据编辑页加「活动与类别」卡片:改名 / 删除 / 新增 / 合并
活动的管理能力其实后端早就有(create/update/delete 三个端点),但入口只藏在
「到访记录」卡片里——要先在下拉里选中某个活动,再点按钮弹 prompt,看不见
「哪些活动一条到访都没挂」(实测有 0 条的活动);类别则完全没有集中入口。

现在页首多了一张卡片(唯一默认展开的),两个表格都带用量:

- 活动:名称行内改、删除(有引用时二次确认并报条数)、新增,
  另有一条「把[A]并到[B]」——改名解决不了「两个名字指同一件事」,
  合并会把到访转过去、把 is_home/is_work 抄成目标的,再删源活动、清它的原始行留档。
- 类别:类别没有独立的表,值就长在 places.poi_category 上,所以「改名 / 清空」
  都是按值批量作用到地点;改成另一个已有值就是合并,不另做按钮。

新增端点:/admin/edit/category/rename、/admin/edit/category/clear、
/admin/edit/activity/merge。编辑页那条 DISTINCT poi_category 查询顺手改成
GROUP BY 带计数,一份数据两用。

顺带修一处会静默失效的写法:新表原来只写了 tb-stack,漏了配对的 tb,
而 CSS 全是 `.tw > table.tb.tb-stack …` ⇒ 一条堆叠规则都没命中,窄屏下表仍是
640px 底宽。横滑是在 .tw 里滚的、页面不溢出,肉眼很难发现,守卫测试也只查
「有没有 tb-stack」——已补上「有 tb-stack 必须有 tb」并做了变异验证。
2026-09-20 09:51:43 +08:00

176 lines
7.1 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)
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("被拒绝的合并不该动数据")
}
}