1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
|
// `fluxrec compact-labels` against a fixture file: overfull feeds are
// trimmed to the ceiling keeping their newest negatives, positives are
// untouched, a backup is written first, and a second run is a no-op.
package cmds
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
"fluxrec/core"
)
func compactFixture(t *testing.T, dir string) (string, time.Time) {
t.Helper()
now := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)
rows := []core.LabelRow{}
// 20 positives (feed 7) → target neg pool 400 → cap = 20/feed (5%).
for i := 0; i < 20; i++ {
rows = append(rows, core.LabelRow{
EntryID: int64(100 + i), URL: "https://pos.example/p", Title: "P",
FeedID: 7, FeedTitle: "Good Feed", Label: core.LabelPos,
CapturedAt: now.Add(time.Duration(i) * time.Hour),
})
}
// Firehose: 80 negatives, staggered times so "newest kept" is testable.
for i := 0; i < 80; i++ {
rows = append(rows, core.LabelRow{
EntryID: int64(5000 + i), URL: "https://fire.example/f", Title: "F",
FeedID: 8, FeedTitle: "Firehose", Label: core.LabelNeg,
CapturedAt: now.Add(time.Duration(i) * time.Hour),
})
}
// Quiet feed: 5 negatives, under the cap — must survive untouched.
for i := 0; i < 5; i++ {
rows = append(rows, core.LabelRow{
EntryID: int64(9000 + i), URL: "https://quiet.example/q", Title: "Q",
FeedID: 9, FeedTitle: "Quiet", Label: core.LabelNeg,
CapturedAt: now.Add(time.Duration(i) * time.Hour),
})
}
path := filepath.Join(dir, "labels.jsonl")
if err := core.AppendLabels(path, rows); err != nil {
t.Fatal(err)
}
return path, now
}
func TestCompactLabels(t *testing.T) {
dir := t.TempDir()
path, _ := compactFixture(t, dir)
// 20 positives → target neg pool 400 → 5% of 400 = 20 < floor 50 →
// the ceiling is 50/feed (the floor doing its job for a young pool).
cmd := &CompactLabelsCommand{Labels: path, NegRatio: 20, NegFeedCapPct: 5}
var out bytes.Buffer
if err := cmd.Run(nil, &out); err != nil {
t.Fatalf("compact-labels: %v", err)
}
if !strings.Contains(out.String(), "rows 105 → 75") {
t.Errorf("unexpected summary:\n%s", out.String())
}
rows, err := core.ReadLabels(path)
if err != nil {
t.Fatal(err)
}
pos, perFeedNeg := 0, map[int64]int{}
keptIDs := map[int64]bool{}
for _, row := range rows {
if row.Label == core.LabelPos {
pos++
} else {
perFeedNeg[row.FeedID]++
keptIDs[row.EntryID] = true
}
}
if pos != 20 {
t.Errorf("positives changed: %d, want 20", pos)
}
if perFeedNeg[8] != 50 {
t.Errorf("firehose negatives = %d, want 50 (floor cap)", perFeedNeg[8])
}
if perFeedNeg[9] != 5 {
t.Errorf("quiet feed negatives = %d, want 5 (untouched)", perFeedNeg[9])
}
// Newest 50 of the firehose's negatives are the ones kept (IDs 5030-5079).
if !keptIDs[5079] || keptIDs[5029] {
t.Errorf("kept set should be the newest 50 (5030-5079); 5079 kept=%v 5029 kept=%v",
keptIDs[5079], keptIDs[5029])
}
// Backup holds the original 105 rows.
backup, err := core.ReadLabels(path + ".bak")
if err != nil || len(backup) != 105 {
t.Errorf("backup should hold the original 105 rows (got %d, err %v)", len(backup), err)
}
// Second run: nothing over the ceiling → no-op, and no backup rewrite.
var out2 bytes.Buffer
if err := cmd.Run(nil, &out2); err != nil {
t.Fatalf("second run: %v", err)
}
if !strings.Contains(out2.String(), "unchanged") {
t.Errorf("second run should report no change:\n%s", out2.String())
}
}
func TestCompactLabelsDryRun(t *testing.T) {
dir := t.TempDir()
path, _ := compactFixture(t, dir)
cmd := &CompactLabelsCommand{Labels: path, NegRatio: 20, NegFeedCapPct: 5, DryRun: true}
var out bytes.Buffer
if err := cmd.Run(nil, &out); err != nil {
t.Fatalf("dry-run: %v", err)
}
if !strings.Contains(out.String(), "dry-run") {
t.Errorf("dry-run note missing:\n%s", out.String())
}
rows, err := core.ReadLabels(path)
if err != nil || len(rows) != 105 {
t.Errorf("dry-run must not modify the file (rows=%d, err=%v)", len(rows), err)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Error("dry-run must not write a backup")
}
}
|