Files
rond-with-you/internal/web/stats_votes_split_test.go
tamakyi ff46a9cbb8 统计页把「我的结论」与「访客点赞」拆成两块
用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?应该分开,用户的点赞点踩单独做一块」。
他看到的没错:那张卡的**列表全是站长结论**(verdictPlaces = place_votes),访客票只在副标题里
露了个总数(👍 N · 👎 M)——既看不出哪几个地点被顶上去,也容易被当成两者合并的榜。

- 新增 votes.TopVisitorVoted:按访客票数列地点榜。HAVING 只算**至少有一票的那个方向**,
  否则 0 票的地点会把榜塞满;同票数按 visit_count DESC, p.id 排,刷新之间顺序不跳。
  票按坐标键挂,所以 join 回当前数据集的地点才定位得到(与 Tallies/verdictPlaces 同一口径)。
- 「推荐 & 踩雷(我的结论)」只讲站长,副标题只留「推荐 N · 踩雷 M」,标题与列表都改成「我」的口吻。
- 新增「访客点赞」卡:副标题总票数,两列列 👍/👎 票最多的地点并带票数,各有空态。
- 加载拆成两个 pageJob(verdicts / visitorvotes)并行,两块互不依赖就不该串着等。
- 两块共用同一个可见性开关(IsAdmin || Settings.MarksPublic),与地点页投票区一致。

用例(stats_votes_split_test.go)按卡片标题切出两段 HTML,断言「我标的地点只出现在站长块、
访客顶的地点只出现在访客块」,并核对两边各自的计数——比单看总数更能证明没串台。

⚠️ 又一次同一个坑:两张票表都按坐标键读、与数据集无关,place_visitor_votes 还没有 user_id、
不随删用户清理 ⇒ 上一轮跑剩的票会被算进来、票数越堆越多。新增 isolateVoteState 按 place_key
清两张表(前置 + cleanup 各一次)。

顺带订正 README:那里还写着站长结论「按 src_pk 存」,其实早就是坐标键了。
全量验收 PASS=338 FAIL=0 SKIP=0。
2026-10-01 01:47:48 +08:00

188 lines
6.5 KiB
Go
Raw Permalink 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"
"strings"
"testing"
"rond-with-you/internal/rond"
"rond-with-you/internal/votes"
)
// ---------- 统计页:站长结论 / 访客点赞 是两码事 ----------
//
// 早先两块挤在一张卡上:列表是**站长结论**,访客票只在副标题里露个总数。
// 既看不出哪几个地点被访客顶上去,也容易被当成「站长 + 访客一起算」的榜单。
// 现在拆成两张卡,这里钉住「两块各说各的、互不串台」。
type namedPlace struct {
srcPK int
name string
lat, lon float64
}
// pickTwoNamedPlaces 挑两个**有名字、有坐标**的不同地点(要按名字断言,没名字的渲染成「未命名」)。
func pickTwoNamedPlaces(t *testing.T, f *movementFixture) (namedPlace, namedPlace) {
t.Helper()
rows, err := f.pg.QueryContext(f.ctx, `SELECT src_pk, COALESCE(name,''), lat, lon FROM places
WHERE dataset_id=$1 AND lat IS NOT NULL AND lon IS NOT NULL AND (lat<>0 OR lon<>0)
AND COALESCE(name,'') <> ''
ORDER BY src_pk LIMIT 2`, f.dsID)
if err != nil {
t.Fatalf("取地点失败: %v", err)
}
defer rows.Close()
var out []namedPlace
for rows.Next() {
var p namedPlace
if err := rows.Scan(&p.srcPK, &p.name, &p.lat, &p.lon); err != nil {
t.Fatal(err)
}
out = append(out, p)
}
if len(out) < 2 {
t.Fatalf("这个数据集里带名字又带坐标的地点不足两个(只有 %d 个),用例前提不成立", len(out))
}
return out[0], out[1]
}
// isolateVoteState 清掉这几个地点上的历史表态。
//
// **两张票表都按坐标键读**(跟数据集无关),所以上一个用例、甚至上一轮跑剩下的行都会被算进来:
// `place_visitor_votes` 没有 user_id、也不随用户删除而清理,不清就会把票数越堆越多
// (实测图片那批用例就是这么被污染的:应当 2 张、实际 10 张)。
func isolateVoteState(t *testing.T, f *movementFixture, places ...namedPlace) {
t.Helper()
keys := make([]string, 0, len(places))
for _, p := range places {
keys = append(keys, rond.PlaceCoordKey(p.lat, p.lon))
}
clear := func() {
for _, k := range keys {
f.pg.ExecContext(f.ctx, `DELETE FROM place_visitor_votes WHERE place_key=$1`, k)
f.pg.ExecContext(f.ctx, `DELETE FROM place_votes WHERE place_key=$1`, k)
}
}
clear()
t.Cleanup(clear)
}
// htmlSection 取渲染结果里 from 到 to 之间的那一段(两端都必须出现,否则用例本身就不成立)。
func htmlSection(t *testing.T, body, from, to string) string {
t.Helper()
i := strings.Index(body, from)
if i < 0 {
t.Fatalf("页面里找不到 %q——那一块没渲染出来?", from)
}
rest := body[i:]
j := strings.Index(rest, to)
if j < 0 {
t.Fatalf("页面里 %q 之后找不到 %q", from, to)
}
return rest[:j]
}
func renderStatsPage(t *testing.T, f *movementFixture) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/stats", nil).WithContext(f.ctx)
f.srv.statsPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("统计页状态码 %d:%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
t.Fatalf("统计页没渲染到底(模板中途报错了?):…%s", body[len(body)-300:])
}
return body
}
func TestStatsSplitsOwnerVerdictFromVisitorVotes(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
uid := userFrom(ctx).ID
mine, their := pickTwoNamedPlaces(t, f)
isolateVoteState(t, f, mine, their)
// 站长结论只标 A「推荐」;B 那侧一个站长结论都不打
if err := srv.votes.SaveVerdict(ctx, uid, dsID, mine.srcPK, votes.VerdictRecommend); err != nil {
t.Fatalf("写站长结论失败: %v", err)
}
// 访客票只投 B:两个不同访客各点一次 👍(都点同一处,票数才看得出是「榜」)
for _, voter := range []string{"test-voter-1", "test-voter-2"} {
if _, _, err := srv.votes.Cast(ctx, dsID, their.srcPK, voter, 1); err != nil {
t.Fatalf("访客投票失败: %v", err)
}
}
body := renderStatsPage(t, f)
owner := htmlSection(t, body, "推荐 &amp; 踩雷(我的结论)", "访客点赞")
visitor := htmlSection(t, body, "访客点赞", "月度分布")
// ① 站长结论块:只应出现我标的那一处
if !strings.Contains(owner, mine.name) {
t.Errorf("站长结论块里没有「%s」(我给它标了推荐)", mine.name)
}
if strings.Contains(owner, their.name) {
t.Errorf("被访客点赞的「%s」串进了站长结论块", their.name)
}
if !strings.Contains(owner, "推荐 1 · 踩雷 0") {
t.Errorf("站长结论块的总数不对(应当只有 1 条推荐、0 条踩雷)")
}
// ② 访客块:只应出现访客投的那一处,并且要带票数
if !strings.Contains(visitor, their.name) {
t.Errorf("访客点赞块里没有「%s」(两个访客都点了赞)", their.name)
}
if strings.Contains(visitor, mine.name) {
t.Errorf("我标了推荐的「%s」串进了访客点赞块", mine.name)
}
if !strings.Contains(visitor, "👍 2") {
t.Errorf("访客点赞块里没显示票数(应能看出「👍 2」)")
}
if !strings.Contains(visitor, "👍 2 票 · 👎 0 票") {
t.Errorf("访客点赞块的总票数不对(应当是 2 赞 0 踩)")
}
}
// TestStatsVisitorRankCountsRealVotes 榜单排序与票数要跟实际投票一致(两票的排在一票前面)。
func TestStatsVisitorRankCountsRealVotes(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
first, second := pickTwoNamedPlaces(t, f)
isolateVoteState(t, f, first, second)
// first 收 2 票、second 收 1 票(都是 👍)
for _, voter := range []string{"test-voter-1", "test-voter-2"} {
if _, _, err := srv.votes.Cast(ctx, dsID, first.srcPK, voter, 1); err != nil {
t.Fatal(err)
}
}
if _, _, err := srv.votes.Cast(ctx, dsID, second.srcPK, "test-voter-3", 1); err != nil {
t.Fatal(err)
}
body := renderStatsPage(t, f)
visitor := htmlSection(t, body, "访客点赞", "月度分布")
i, j := strings.Index(visitor, first.name), strings.Index(visitor, second.name)
if i < 0 || j < 0 {
t.Fatalf("两处地点都该出现在访客榜里(%q:%d %q:%d)", first.name, i, second.name, j)
}
if i > j {
t.Errorf("票多的「%s」应当排在票少的「%s」前面", first.name, second.name)
}
// 票数分别标出来
seg := visitor[i:]
if !strings.Contains(seg, "👍 2") {
t.Errorf("「%s」的票数应显示 2:%s", first.name, seg[:min(len(seg), 200)])
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}