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), "") { 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, "推荐 & 踩雷(我的结论)", "访客点赞") 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 }