package upwatch import ( "context" "os" "path/filepath" "sync" "testing" "time" ) // The best ≥ 10-second rate, and a push only COUNTS when that window moved ≥ 10 MB. // RED-PROOF: compute the peak over 1-second windows → the 4 MB burst reads as 33 Mbit/s → fails. func TestLine_SummarizePeakAndCounts(t *testing.T) { series := func(perSecond []uint64) []Point { t0 := time.Date(2026, 10, 24, 2, 16, 0, 0, time.UTC) pts := []Point{{t0, 0}} for i, b := range perSecond { pts = append(pts, Point{t0.Add(time.Duration(i+1) * time.Second), pts[len(pts)-1].V + b}) } return pts } steady := make([]uint64, 60) for i := range steady { steady[i] = 50e6 / 8 } if r := Summarize(series(steady), time.Now()); r.Peak10Mbps < 49.9 || r.Peak10Mbps > 50.1 || !r.Counts || r.Seconds != 60 { t.Fatalf("steady 50 Mbit/s push: %+v", r) } burst := make([]uint64, 30) burst[3] = 4 << 20 if r := Summarize(series(burst), time.Now()); r.Counts || r.Peak10Mbps > 4 { t.Fatalf("a 4 MB one-second push must not set the line: %+v", r) } } // The sampler itself: readings from a live (fast) ticker reach the summary. func TestLine_PushSamplerCollects(t *testing.T) { var mu sync.Mutex var v uint64 p := &PushSampler{Every: time.Millisecond, Read: func() (uint64, error) { mu.Lock(); defer mu.Unlock(); v += 1000; return v, nil }} stop := p.Start() time.Sleep(30 * time.Millisecond) r := stop() if r.Bytes < 2000 { t.Fatalf("sampler collected %d bytes", r.Bytes) } } func TestLine_FromRecordsUsesOnlyCountingNightsAndKeepsFourteen(t *testing.T) { if _, ok := LineFromRecords(nil); ok { t.Fatal("no record: the line must be unknown") } recs := []LineRecord{{Peak10Mbps: 90, Counts: false}, {Peak10Mbps: 40, Counts: true}, {Peak10Mbps: 55, Counts: true}} if l, ok := LineFromRecords(recs); !ok || l != 55 { t.Fatalf("line = %v %v, want 55 (the 90 did not count)", l, ok) } var keep []LineRecord for i := 0; i < 20; i++ { keep = AddRecord(keep, LineRecord{Peak10Mbps: float64(i), Counts: true}) } if len(keep) != KeepNights || keep[0].Peak10Mbps != 6 { t.Fatalf("kept %d, first %v", len(keep), keep[0].Peak10Mbps) } } func TestLine_TxFromNetDevSkipsLoopback(t *testing.T) { p := filepath.Join(t.TempDir(), "dev") os.WriteFile(p, []byte(`Inter-| Receive | Transmit face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed lo: 9000 10 0 0 0 0 0 0 9000 10 0 0 0 0 0 0 eth0: 100 1 0 0 0 0 0 0 774708 5 0 0 0 0 0 0 eth1: 1 1 0 0 0 0 0 0 1000 1 0 0 0 0 0 0 `), 0o644) v, err := txFromNetDev(p) if err != nil || v != 775708 { t.Fatalf("tx = %d %v, want 775708 (eth0+eth1, not lo)", v, err) } } // ── qBittorrent's default limit ── type fakeQB struct { cur int64 sets []int64 } func (f *fakeQB) UploadLimit(context.Context, map[string]string) (int64, error) { return f.cur, nil } func (f *fakeQB) SetUploadLimit(_ context.Context, b int64) (int64, error) { f.sets = append(f.sets, b) f.cur = b / 1024 * 1024 // qBittorrent keeps KiB/s return f.cur, nil } // RED-PROOF per row: drop the household check → the „household" rows set a limit → fail; compare against the // requested value instead of the stored one → the box's own rounding reads as a household change → the re-set row fails. func TestQBLimit_TheHouseholdAlwaysWins(t *testing.T) { now := time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC) ctx := context.Background() // 1. Unlimited, never set, line 100 Mbit/s, share 50 % → 6 250 000 B/s requested, stored rounded. q := &fakeQB{} st, act, err := ApplyQBLimit(ctx, q, QBLimitState{}, 100, true, 0.5, now) if err != nil || act != "set" || len(q.sets) != 1 || q.sets[0] != 6250000 || st.BoxSetBytes != 6249472 { t.Fatalf("first set: %s %v %+v sets=%v", act, err, st, q.sets) } // 2. Same line, qBittorrent still holds the box's (rounded) value → kept, no write. st2, act, _ := ApplyQBLimit(ctx, q, st, 105, true, 0.5, now) if act != "kept" || len(q.sets) != 1 { t.Fatalf("unchanged: %s sets=%v", act, q.sets) } // 3. The line moved > 20 % → re-set. st3, act, _ := ApplyQBLimit(ctx, q, st2, 200, true, 0.5, now) if act != "set" || len(q.sets) != 2 || st3.LineMbps != 200 { t.Fatalf("line moved: %s %+v", act, st3) } // 4. The household changed it in qBittorrent → theirs, never touched again, even when the line moves. q.cur = 1000000 st4, act, _ := ApplyQBLimit(ctx, q, st3, 200, true, 0.5, now) if act != "household" || !st4.Household { t.Fatalf("household change: %s %+v", act, st4) } if _, act, _ := ApplyQBLimit(ctx, q, st4, 50, true, 0.5, now); act != "household" || len(q.sets) != 2 { t.Fatalf("after the household took it: %s sets=%v", act, q.sets) } // 5. The household REMOVED the box's limit (0) → theirs too. q2 := &fakeQB{} s, _, _ := ApplyQBLimit(ctx, q2, QBLimitState{}, 100, true, 0.5, now) q2.cur = 0 if s2, act, _ := ApplyQBLimit(ctx, q2, s, 100, true, 0.5, now); act != "household" || !s2.Household { t.Fatalf("household removed the limit: %s", act) } // 6. A limit already there before the box ever looked → the household's. q3 := &fakeQB{cur: 500000} if s3, act, _ := ApplyQBLimit(ctx, q3, QBLimitState{}, 100, true, 0.5, now); act != "household" || !s3.Household || len(q3.sets) != 0 { t.Fatalf("pre-existing limit: %s", act) } // 6b. That household later REMOVES its own limit (unlimited again) → still theirs: the box does not step in. q3.cur = 0 if _, act, _ := ApplyQBLimit(ctx, q3, QBLimitState{Household: true}, 100, true, 0.5, now); act != "household" || len(q3.sets) != 0 { t.Fatalf("household set unlimited after its own limit: %s sets=%v — the box overrode their choice", act, q3.sets) } // 7. No line measured → nothing set. q4 := &fakeQB{} if _, act, _ := ApplyQBLimit(ctx, q4, QBLimitState{}, 0, false, 0.5, now); act != "no-line" || len(q4.sets) != 0 { t.Fatalf("no line: %s", act) } }