// `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") } }