fix(history): 访问计数按设备去重 + 后台返回全量

- Entry 增加 DeviceVisits:同一 URL 多次/多设备上报取各设备最大值再求和,
  不再平方级放大(30 次访问曾被计成 465 次)
- handleGet 不再截断 100 条,返回 {items,total},后台显示当前条数
- 清理死代码(hostVisits/hostOf/visitCount/isCodeHostVal/dashySection)
- 守门测试:重复上报、多设备求和、陈旧数据、持久化、统计、门户文案、第 101 条可见性
This commit is contained in:
JianFeeeee
2026-10-02 11:24:55 +08:00
parent b256a8d7f1
commit 292e77097d
5 changed files with 451 additions and 124 deletions

View File

@ -0,0 +1,77 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)
// 复现「访问次数被平方级放大」:扩展每 30s 上报一次该 URL 的
// chrome.history.search visitCount —— 那是**绝对累计值**(1,2,3,4,5…),
// 而 handleSync 用 `e.TotalVisits += item.VisitCount` 把它当增量累加。
// 结果 5 次真实访问被记成 15 次。
func TestSyncDoubleCountsAbsoluteVisitCount(t *testing.T) {
dir := t.TempDir()
oldDir, oldFile, oldTok := dataDir, historyFile, syncToken
dataDir = dir
historyFile = filepath.Join(dir, "history.json")
syncToken = "t"
defer func() { dataDir, historyFile, syncToken = oldDir, oldFile, oldTok }()
history = map[string]*Entry{}
// 模拟 5 轮同步:同一 URL 的绝对 visitCount 依次为 1..5
for round, vc := range []int{1, 2, 3, 4, 5} {
body, _ := json.Marshal(SyncRequest{Items: []SyncItem{{
URL: "https://ex.com/a", Title: "A", Device: "desktop",
Timestamp: float64(1700000000 + round*30), VisitCount: vc,
}}})
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer t")
w := httptest.NewRecorder()
handleSync(w, req)
if w.Code != http.StatusOK {
t.Fatalf("round %d: status = %d", round, w.Code)
}
}
e := history["https://ex.com/a"]
if e == nil {
t.Fatal("entry not created")
}
t.Logf("真实访问 5 次 → 落盘 TotalVisits = %d", e.TotalVisits)
if e.TotalVisits != 5 {
t.Errorf("TotalVisits = %d; 真实只访问 5 次,被平方级放大(期望 5)", e.TotalVisits)
}
}
// 上报 100 次同一 URL 会放大到什么程度 —— 量化后果。
func TestSyncInflationScalesQuadratically(t *testing.T) {
dir := t.TempDir()
oldDir, oldFile, oldTok := dataDir, historyFile, syncToken
dataDir = dir
historyFile = filepath.Join(dir, "history.json")
syncToken = "t"
defer func() { dataDir, historyFile, syncToken = oldDir, oldFile, oldTok }()
history = map[string]*Entry{}
// 真实访问 30 次,但扩展每 30s 同步一次 → 上报 30 次绝对值
for round := 1; round <= 30; round++ {
body, _ := json.Marshal(SyncRequest{Items: []SyncItem{{
URL: "https://ex.com/b", Device: "desktop",
Timestamp: float64(1700000000 + round*30), VisitCount: round,
}}})
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer t")
handleSync(httptest.NewRecorder(), req)
}
got := history["https://ex.com/b"].TotalVisits
want := 30
t.Logf("真实 30 次访问 → 落盘 %d 次(虚高 %.1f×)", got, float64(got)/float64(want))
if got != want {
t.Errorf("TotalVisits = %d; want %d", got, want)
}
}

View File

@ -0,0 +1,135 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gopkg.in/yaml.v3"
)
// 本文件是一组**守门测试**:把 2026-09-13 定位到的那个真实缺陷
// (visit_count 绝对值被当增量累加)钉死,防止以后再改回去。
// 守门 1:绝不出现平方级放大。
func TestNoQuadraticInflation(t *testing.T) {
withTempHistory(t)
// 真实访问 20 次;扩展每 30s 同步一次,30 秒内反复上报绝对值
for round := 1; round <= 20; round++ {
postSync(t, SyncItem{
URL: "https://ex.com/gate", Device: "laptop",
Timestamp: float64(1700000000 + round*30), VisitCount: round,
})
}
if got := history["https://ex.com/gate"].TotalVisits; got != 20 {
t.Fatalf("TotalVisits = %d,真实只访问 20 次;出现放大即回归", got)
}
}
// 守门 2:多设备各自的绝对值要相加(这是"取 max"的错误修法会失败的用例)。
func TestMultiDeviceStillSums(t *testing.T) {
withTempHistory(t)
postSync(t, SyncItem{URL: "https://ex.com/m", Device: "laptop", Timestamp: 1700000000, VisitCount: 8})
postSync(t, SyncItem{URL: "https://ex.com/m", Device: "phone", Timestamp: 1700000030, VisitCount: 3})
postSync(t, SyncItem{URL: "https://ex.com/m", Device: "tablet", Timestamp: 1700000060, VisitCount: 5})
if got := history["https://ex.com/m"].TotalVisits; got != 16 {
t.Errorf("TotalVisits = %d;期望 8+3+5=16(全局 max 会错成 8)", got)
}
}
// 守门 3:落盘后能读回,且计数不变(DeviceVisits 参与序列化)。
func TestCountsSurviveReload(t *testing.T) {
withTempHistory(t)
postSync(t, SyncItem{URL: "https://ex.com/r", Device: "laptop", Timestamp: 1700000000, VisitCount: 6})
postSync(t, SyncItem{URL: "https://ex.com/r", Device: "phone", Timestamp: 1700000030, VisitCount: 2})
data, err := os.ReadFile(filepath.Join(dataDir, "history.json"))
if err != nil {
t.Fatalf("未落盘: %v", err)
}
var entries []*Entry
if err := json.Unmarshal(data, &entries); err != nil {
t.Fatalf("history.json 解析失败: %v", err)
}
if len(entries) != 1 || entries[0].TotalVisits != 8 {
t.Fatalf("重载后 TotalVisits = %v,期望 8", entries)
}
if len(entries[0].DeviceVisits) != 2 {
t.Errorf("device_visits 未落盘: %v", entries[0].DeviceVisits)
}
}
// 守门 4:handleStats 的 total_visits 同样不得放大。
func TestStatsNotInflated(t *testing.T) {
withTempHistory(t)
for round := 1; round <= 10; round++ {
postSync(t, SyncItem{URL: "https://ex.com/s", Device: "laptop",
Timestamp: float64(1700000000 + round*30), VisitCount: round})
}
req := httptest.NewRequest(http.MethodGet, "/stats", nil)
w := httptest.NewRecorder()
handleStats(w, req)
var out struct {
Unique int `json:"unique_urls"`
Total int `json:"total_visits"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("stats 解析失败: %v", err)
}
if out.Total != 10 {
t.Errorf("total_visits = %d;真实 10 次访问,期望 10", out.Total)
}
}
// 守门 5:rebuildDashySections 产出的「N次访问」文案要反映真实计数。
func TestHistoryDescriptionUsesRealCount(t *testing.T) {
dir := t.TempDir()
oldDir := dataDir
dataDir = dir
defer func() { dataDir = oldDir }()
// conf.yml 需含「常用服务」与「历史访问」两个分组
conf := `sections:
- name: 常用服务
items: []
- name: 历史访问
items: []
`
if err := os.WriteFile(filepath.Join(dir, "conf.yml"), []byte(conf), 0o644); err != nil {
t.Fatal(err)
}
history = map[string]*Entry{
"https://ex.com/x": {URL: "https://ex.com/x", Title: "X", TotalVisits: 9},
}
rebuildDashySections()
data, err := os.ReadFile(filepath.Join(dir, "conf.yml"))
if err != nil {
t.Fatal(err)
}
var cfg struct {
Sections []struct {
Name string `yaml:"name"`
Items []struct {
Title string `yaml:"title"`
Description string `yaml:"description"`
} `yaml:"items"`
} `yaml:"sections"`
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
for _, s := range cfg.Sections {
if s.Name != histSectionZh {
continue
}
for _, it := range s.Items {
if it.Title == "X" && it.Description != "9次访问" {
t.Errorf("历史项文案 = %q,期望 %q", it.Description, "9次访问")
}
}
}
}

View File

@ -0,0 +1,67 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// 守门:读取侧不得再截断到 100 条。
// 此前 handleGet 硬砍 100 条,而门户「历史访问」已不限条数 —— 后台因此
// 看不见也删不掉第 101 条之后的记录。
func TestHandleGetReturnsAllEntries(t *testing.T) {
withTempHistory(t)
const n = 250
for i := 0; i < n; i++ {
history[fmt.Sprintf("https://ex.com/%03d", i)] = &Entry{
URL: fmt.Sprintf("https://ex.com/%03d", i),
TotalVisits: n - i, // 降序
}
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handleGet(w, req)
var out struct {
Items []Entry `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("解析失败: %v", err)
}
if len(out.Items) != n {
t.Errorf("返回 %d 条,期望 %d 条(后台必须能删到每一条)", len(out.Items), n)
}
if out.Total != n {
t.Errorf("total = %d,期望 %d", out.Total, n)
}
// 顺序:按访问次数降序
for i := 1; i < len(out.Items); i++ {
if out.Items[i-1].TotalVisits < out.Items[i].TotalVisits {
t.Fatalf("第 %d/%d 条顺序错乱:%d < %d", i, len(out.Items), out.Items[i-1].TotalVisits, out.Items[i].TotalVisits)
}
}
}
// 超过 100 条时,第 101 条仍可被逐条删除(后台删除按钮按 URL 定位)。
func TestEntryBeyondOldCapIsDeletable(t *testing.T) {
withTempHistory(t)
const n = 150
for i := 0; i < n; i++ {
history[fmt.Sprintf("https://ex.com/%03d", i)] = &Entry{
URL: fmt.Sprintf("https://ex.com/%03d", i), TotalVisits: 1,
}
}
target := "https://ex.com/149" // 旧截断下后台永远看不到这条
req := httptest.NewRequest(http.MethodDelete, "/item?url="+target, nil)
w := httptest.NewRecorder()
handleDeleteItem(w, req)
if w.Code == http.StatusNotFound {
t.Fatalf("第 150 条无法删除:%s", w.Body.String())
}
if _, ok := history[target]; ok {
t.Error("删除后仍存在于 history")
}
}

View File

@ -25,11 +25,12 @@ var (
)
type Entry struct {
URL string `json:"url"`
Title string `json:"title"`
TotalVisits int `json:"total_visits"`
LastVisit float64 `json:"last_visit"`
Devices []string `json:"devices"`
URL string `json:"url"`
Title string `json:"title"`
TotalVisits int `json:"total_visits"`
LastVisit float64 `json:"last_visit"`
Devices []string `json:"devices"`
DeviceVisits map[string]int `json:"device_visits,omitempty"`
}
type SyncItem struct {
@ -137,7 +138,33 @@ func handleSync(w http.ResponseWriter, r *http.Request) {
item.Device = "unknown"
}
if e, ok := history[normalized]; ok {
e.TotalVisits += item.VisitCount
// visit_count 是**绝对累计值**,不是增量。
//
// 浏览器扩展每 30s 跑一次 `chrome.history.search`,其 `visitCount`
// 字段是「该 URL 有史以来被访问的总次数」,每轮都是一个更大的
// 绝对数(1,2,3,4,5…),而不是「这一轮新增几次」。早期这里写成
// `e.TotalVisits += item.VisitCount`,把绝对值当增量反复累加:
// n 次真实访问被记成 n(n+1)/2(实测 30 次 → 465 次,虚高 15.5×),
// 而「常用服务」正是按该字段排序的,等于把排名整个算错。
//
// 正确语义:**按设备分别取绝对值的最大值,再求和**。
// 同一设备重复上报被 max 吸收(不会重复累加),多设备各报各的
// 累计值,相加才是真实总数。
if e.DeviceVisits == nil {
e.DeviceVisits = map[string]int{}
// 兼容没有 DeviceVisits 的旧记录:把已有总量记在第一个设备上,
// 避免升级后凭空归零。
if len(e.Devices) > 0 && e.TotalVisits > 0 {
e.DeviceVisits[e.Devices[0]] = e.TotalVisits
}
}
if item.VisitCount > e.DeviceVisits[item.Device] {
e.DeviceVisits[item.Device] = item.VisitCount
}
e.TotalVisits = 0
for _, v := range e.DeviceVisits {
e.TotalVisits += v
}
if item.Timestamp > e.LastVisit {
e.LastVisit = item.Timestamp
}
@ -158,6 +185,7 @@ func handleSync(w http.ResponseWriter, r *http.Request) {
history[normalized] = &Entry{
URL: normalized, Title: item.Title, TotalVisits: item.VisitCount,
LastVisit: item.Timestamp, Devices: []string{item.Device},
DeviceVisits: map[string]int{item.Device: item.VisitCount},
}
}
}
@ -174,6 +202,15 @@ func handleSync(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "count": len(req.Items), "total": total})
}
// handleGet 返回全部历史(按访问次数降序)。
//
// 此前这里硬截断到 100 条,而门户的「历史访问」分组早已改为**不限条数**
// (用户 2026-09-13 要求)。两者不一致带来一个真实缺陷:管理后台只能
// 删除它能看到的 100 条,第 101 条之后既看不见也删不掉,只能「清空全部」。
// 既然展示侧已经不限制,读取侧就不该再截断;total 一并返回给后台显示总数。
//
// 体积可控:这里返回的是唯一 URL 列表(不是访问流水),条数等于历史里
// 不同站点的数量,与门户「历史访问」分组的条目数完全一致。
func handleGet(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
@ -185,12 +222,9 @@ func handleGet(w http.ResponseWriter, r *http.Request) {
sort.Slice(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
if len(entries) > 100 {
entries = entries[:100]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"items": entries})
json.NewEncoder(w).Encode(map[string]any{"items": entries, "total": len(entries)})
}
func handleClear(w http.ResponseWriter, r *http.Request) {
@ -269,13 +303,6 @@ type dashyNav struct {
title, desc, icon, url string
}
// dashySection 是 conf.yml 里的一个 section
type dashySection struct {
name, icon string
collapsed bool
items []dashyNav
}
// ── 导航项点击计数(独立统计,不与浏览器历史混用)──
//
// 用户要求:导航项自己的访问频率要单独统计,而不是拿浏览器历史反推
@ -334,19 +361,6 @@ func handleNavVisit(w http.ResponseWriter, r *http.Request) {
rebuildDashySections() // 让「常用服务」立即按新计数重排
}
// hostVisits 统计 history 中每个 host 的累计访问
func hostVisits() map[string]int {
mu.RLock()
defer mu.RUnlock()
out := map[string]int{}
for _, e := range history {
if u, err := url.Parse(e.URL); err == nil {
out[strings.ToLower(u.Hostname())] += e.TotalVisits
}
}
return out
}
// 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。
const (
favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务
@ -469,7 +483,7 @@ func rebuildDashySections() {
if isHistSection(si.name) {
continue
} // 所有导航项(含常用服务原本手动配置的)都进候选,
// 由访问量 + code-host 置顶决定最终 Top4
// 最终由访问量决定去留与顺序
candidates = append(candidates, cand{
nav: dashyNav{title: title, desc: desc, icon: icon, url: u},
host: host, visits: v,
@ -552,58 +566,56 @@ func rebuildDashySections() {
// 用户要求(2026-09-13):不再排除"已是导航项"的站点 —— 站内服务的真实访问记录
// 也要在这里出现(相当于"最近访问"列表)。此前排除是为了不与其它分组重复,
// 但结果是站内服务永远进不来,这一组几乎是空的。
if true {
mu.RLock()
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
mu.RLock()
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
mu.RUnlock()
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
// 用户要求:不限条数(原先截断到 20 条)。历史主机全部展示,
// 排序仍按访问次数降序;首条固定为插件安装入口。
histItems := make([]any, 0, len(entries)+1)
// 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留,
// 已装的浏览器由 navi.js 隐藏。
histItems = append(histItems, extensionInstallItem())
for _, e := range entries {
title := friendlyHistoryTitle(e.Title, e.URL)
icon := "fas fa-globe"
if hp := hostPortOf(e.URL); hp != "" {
if name := cachedFavicon(hp); name != "" {
icon = name // 与手工导航项同一套本地图标机制
} else {
queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml
}
}
mu.RUnlock()
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
histItems = append(histItems, map[string]any{
"title": title,
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
"icon": icon, "url": e.URL,
})
// 用户要求:不限条数(原先截断到 20 条)。历史主机全部展示,
// 排序仍按访问次数降序;首条固定为插件安装入口。
histItems := make([]any, 0, len(entries)+1)
// 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留,
// 已装的浏览器由 navi.js 隐藏。
histItems = append(histItems, extensionInstallItem())
for _, e := range entries {
title := friendlyHistoryTitle(e.Title, e.URL)
icon := "fas fa-globe"
if hp := hostPortOf(e.URL); hp != "" {
if name := cachedFavicon(hp); name != "" {
icon = name // 与手工导航项同一套本地图标机制
} else {
queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml
}
}
histItems = append(histItems, map[string]any{
"title": title,
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
"icon": icon, "url": e.URL,
})
}
foundHist := false
for _, si := range secs {
if isHistSection(si.name) {
si.raw["items"] = histItems
foundHist = true
}
foundHist := false
for _, si := range secs {
if isHistSection(si.name) {
si.raw["items"] = histItems
foundHist = true
}
}
// 配置里没有「历史访问」分组时补一个。该分组只能由这里创建:
// 管理后台明确禁止手工新建/改名/删除它(否则下次同步就被覆写),
// 所以若只靠用户手动建,功能就一直不会出现。
if !foundHist {
sectionsRaw = append(sectionsRaw, map[string]any{
"name": histSectionZh,
"icon": "fas fa-history",
"collapsed": false,
"items": histItems,
})
fmt.Println("[history] 已补建「历史访问」分组")
}
}
// 配置里没有「历史访问」分组时补一个。该分组只能由这里创建:
// 管理后台明确禁止手工新建/改名/删除它(否则下次同步就被覆写),
// 所以若只靠用户手动建,功能就一直不会出现。
if !foundHist {
sectionsRaw = append(sectionsRaw, map[string]any{
"name": histSectionZh,
"icon": "fas fa-history",
"collapsed": false,
"items": histItems,
})
fmt.Println("[history] 已补建「历史访问」分组")
}
cfg["sections"] = sectionsRaw
@ -638,29 +650,6 @@ func friendlyHistoryTitle(title, rawURL string) string {
return "历史站点"
}
func hostOf(rawURL string) string {
if u, err := url.Parse(rawURL); err == nil {
return strings.ToLower(u.Hostname())
}
return ""
}
func visitCount(rawURL string) int {
u, err := url.Parse(rawURL)
if err != nil {
return 0
}
u.Fragment = ""
u.Host = strings.ToLower(u.Host)
key := u.String()
mu.RLock()
defer mu.RUnlock()
if e, ok := history[key]; ok {
return e.TotalVisits
}
return 0
}
func main() {
os.MkdirAll(dataDir, 0755)
load()
@ -722,25 +711,3 @@ func main() {
log.Println("history-server listening on :3004")
log.Fatal(http.ListenAndServe(":3004", nil))
}
// isCodeHostVal 判断导航项是否代码托管:只认知名托管 host 与标题关键词,
// 避免 "git." 子串误匹配域名中段(如 your-dify.example)。
func isCodeHostVal(title, host, url string) bool {
lowT := strings.ToLower(title)
if strings.Contains(lowT, "gitea") || strings.Contains(lowT, "github") ||
strings.Contains(lowT, "gitlab") || strings.Contains(lowT, "代码") ||
strings.Contains(lowT, "仓库") || strings.Contains(lowT, "codeberg") {
return true
}
h := strings.ToLower(host)
if strings.HasPrefix(h, "git.") || strings.HasPrefix(h, "gitea.") ||
strings.HasPrefix(h, "gitlab.") || strings.HasPrefix(h, "codeberg.") ||
strings.HasPrefix(h, "github.") {
return true
}
if strings.Contains(h, "gitea") || strings.Contains(h, "github") ||
strings.Contains(h, "gitlab") || strings.Contains(h, "codeberg") {
return true
}
return false
}

View File

@ -0,0 +1,81 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)
// 辅助:切换到临时数据目录并重置全局 history。
func withTempHistory(t *testing.T) {
t.Helper()
oldDir, oldFile := dataDir, historyFile
dataDir = t.TempDir()
historyFile = filepath.Join(dataDir, "history.json")
history = map[string]*Entry{}
t.Cleanup(func() { dataDir, historyFile = oldDir, oldFile })
}
func postSync(t *testing.T, items ...SyncItem) {
t.Helper()
body, _ := json.Marshal(SyncRequest{Items: items})
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer t")
old := syncToken
syncToken = "t"
defer func() { syncToken = old }()
w := httptest.NewRecorder()
handleSync(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
}
// 多设备:每台设备各报自己的绝对累计值,应求和而非取全局 max。
func TestSyncSumsAcrossDevices(t *testing.T) {
withTempHistory(t)
// 笔记本上该 URL 累计 10 次;手机上是 4 次
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000000, VisitCount: 10})
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "phone", Timestamp: 1700000060, VisitCount: 4})
if got := history["https://ex.com/a"].TotalVisits; got != 14 {
t.Errorf("TotalVisits = %d; 两设备绝对值应求和 10+4=14", got)
}
// 两台设备各自继续增长,重复上报不重复累加
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000120, VisitCount: 12})
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "phone", Timestamp: 1700000180, VisitCount: 4})
if got := history["https://ex.com/a"].TotalVisits; got != 16 {
t.Errorf("TotalVisits = %d; 期望 12+4=16(phone 的 4 不应再加一次)", got)
}
}
// 上报值变小(用户清了浏览器历史)时,不得把已有计数拉低。
func TestSyncNeverDecreasesOnStaleReport(t *testing.T) {
withTempHistory(t)
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000000, VisitCount: 20})
postSync(t, SyncItem{URL: "https://ex.com/a", Device: "laptop", Timestamp: 1700000060, VisitCount: 3})
if got := history["https://ex.com/a"].TotalVisits; got != 20 {
t.Errorf("TotalVisits = %d; 迟到的陈旧上报不应把 20 拉低到 3", got)
}
}
// 旧版 history.json 没有 device_visits 字段,升级后不能归零。
func TestSyncLegacyEntryWithoutDeviceVisits(t *testing.T) {
withTempHistory(t)
// 模拟旧记录:只有 devices + total_visits
history["https://ex.com/old"] = &Entry{
URL: "https://ex.com/old", Title: "Old", TotalVisits: 8,
Devices: []string{"laptop"}, LastVisit: 1700000000,
}
postSync(t, SyncItem{URL: "https://ex.com/old", Device: "laptop", Timestamp: 1700000060, VisitCount: 9})
if got := history["https://ex.com/old"].TotalVisits; got != 9 {
t.Errorf("TotalVisits = %d; 旧记录的 8 次应在同一设备上被 9 接续,得到 9", got)
}
// 另一台设备加入 → 9 + 其自身绝对值
postSync(t, SyncItem{URL: "https://ex.com/old", Device: "phone", Timestamp: 1700000120, VisitCount: 2})
if got := history["https://ex.com/old"].TotalVisits; got != 11 {
t.Errorf("TotalVisits = %d; 期望 9+2=11", got)
}
}