feat(01-03): implement exit summary and verbose window output formatting

- Add PrintSummary writing per-protocol packet counts with percentages and TOTAL to io.Writer
- Add PrintWindowLine writing single verbose window activity line in [window N] CLASS:count format
- Add AccumulateTotals merging WindowSnapshot counts into cumulative totals map
- Output sorted alphabetically for deterministic display
- Add 6 tests: PrintSummary, PrintSummaryEmpty, PrintSummarySorted, PrintWindowLine, PrintWindowLineEmpty, AccumulateTotals
This commit is contained in:
2026-03-25 12:17:28 +01:00
parent 9446dd5c27
commit f2bebaf554
2 changed files with 259 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
package aggregate
import (
"fmt"
"io"
"sort"
"github.com/netsynth/netsynth/classify"
)
// PrintSummary writes a per-protocol packet count summary to w (typically os.Stderr).
// Output format matches tcpdump conventions (CLAS-03): per-protocol counts with
// percentages and a TOTAL line, sorted alphabetically for stable output.
func PrintSummary(w io.Writer, totals map[classify.TrafficClass]int64) {
var totalPackets int64
for _, count := range totals {
totalPackets += count
}
fmt.Fprintln(w, "\n--- Protocol Summary ---")
// Sort classes for stable output.
classes := make([]string, 0, len(totals))
for c := range totals {
classes = append(classes, string(c))
}
sort.Strings(classes)
for _, className := range classes {
c := classify.TrafficClass(className)
count := totals[c]
pct := 0.0
if totalPackets > 0 {
pct = float64(count) / float64(totalPackets) * 100.0
}
fmt.Fprintf(w, " %-15s %8d packets (%5.1f%%)\n", c, count, pct)
}
fmt.Fprintf(w, " %-15s %8d packets\n", "TOTAL", totalPackets)
}
// PrintWindowLine writes a single verbose per-window activity line to w (CLAS-04).
// Format: [window N] CLASS:count CLASS:count ... (total: N)
// Classes with zero counts are omitted. Output is sorted for deterministic display.
func PrintWindowLine(w io.Writer, snap classify.WindowSnapshot) {
fmt.Fprintf(w, "[window %d]", snap.WindowIndex)
// Sort for stable output.
classes := make([]string, 0, len(snap.Counts))
for c := range snap.Counts {
classes = append(classes, string(c))
}
sort.Strings(classes)
for _, className := range classes {
c := classify.TrafficClass(className)
if snap.Counts[c] > 0 {
fmt.Fprintf(w, " %s:%d", c, snap.Counts[c])
}
}
fmt.Fprintf(w, " (total: %d)\n", snap.TotalPackets)
}
// AccumulateTotals merges a WindowSnapshot's counts into cumulative totals.
// This is used to build the final summary passed to PrintSummary.
func AccumulateTotals(totals map[classify.TrafficClass]int64, snap classify.WindowSnapshot) {
for class, count := range snap.Counts {
totals[class] += count
}
}
+190
View File
@@ -0,0 +1,190 @@
package aggregate
import (
"bytes"
"strings"
"testing"
"github.com/netsynth/netsynth/classify"
)
func TestPrintSummary(t *testing.T) {
totals := map[classify.TrafficClass]int64{
classify.ClassICMP: 10,
classify.ClassDNS: 50,
classify.ClassHTTPS: 100,
}
var buf bytes.Buffer
PrintSummary(&buf, totals)
output := buf.String()
// Must contain class names.
for _, class := range []string{"ICMP", "DNS", "HTTPS"} {
if !strings.Contains(output, class) {
t.Errorf("expected output to contain %q, got:\n%s", class, output)
}
}
// Must contain counts.
for _, count := range []string{"10", "50", "100"} {
if !strings.Contains(output, count) {
t.Errorf("expected output to contain count %q, got:\n%s", count, output)
}
}
// Must contain percentage symbols.
if !strings.Contains(output, "%") {
t.Errorf("expected output to contain percentages, got:\n%s", output)
}
// Must contain TOTAL line.
if !strings.Contains(output, "TOTAL") {
t.Errorf("expected output to contain TOTAL line, got:\n%s", output)
}
// TOTAL should be 160 packets.
if !strings.Contains(output, "160") {
t.Errorf("expected output to contain total 160, got:\n%s", output)
}
// Must contain the section header.
if !strings.Contains(output, "--- Protocol Summary ---") {
t.Errorf("expected output to contain '--- Protocol Summary ---', got:\n%s", output)
}
}
func TestPrintSummaryEmpty(t *testing.T) {
var buf bytes.Buffer
PrintSummary(&buf, map[classify.TrafficClass]int64{})
output := buf.String()
if !strings.Contains(output, "TOTAL") {
t.Errorf("expected empty summary to contain TOTAL line, got:\n%s", output)
}
if !strings.Contains(output, "0") {
t.Errorf("expected empty summary to contain 0 count, got:\n%s", output)
}
}
func TestPrintSummarySorted(t *testing.T) {
totals := map[classify.TrafficClass]int64{
classify.ClassHTTPS: 100,
classify.ClassDNS: 50,
classify.ClassICMP: 10,
classify.ClassSSH: 5,
classify.ClassOtherTCP: 2,
}
var buf bytes.Buffer
PrintSummary(&buf, totals)
output := buf.String()
// Extract lines containing class entries (not the header or TOTAL).
lines := strings.Split(output, "\n")
var classLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.Contains(trimmed, "---") || strings.Contains(trimmed, "TOTAL") {
continue
}
classLines = append(classLines, trimmed)
}
// Verify lines are in sorted order by comparing adjacent entries.
for i := 1; i < len(classLines); i++ {
// Each line starts with the class name. Extract first token.
prevClass := strings.Fields(classLines[i-1])[0]
curClass := strings.Fields(classLines[i])[0]
if prevClass > curClass {
t.Errorf("output is not sorted: %q appears before %q", prevClass, curClass)
}
}
}
func TestPrintWindowLine(t *testing.T) {
snap := classify.WindowSnapshot{
Counts: map[classify.TrafficClass]int64{
classify.ClassDNS: 5,
classify.ClassHTTPS: 10,
},
TotalPackets: 15,
WindowIndex: 0,
}
var buf bytes.Buffer
PrintWindowLine(&buf, snap)
output := buf.String()
if !strings.Contains(output, "[window 0]") {
t.Errorf("expected output to contain '[window 0]', got: %q", output)
}
if !strings.Contains(output, "DNS:5") {
t.Errorf("expected output to contain 'DNS:5', got: %q", output)
}
if !strings.Contains(output, "HTTPS:10") {
t.Errorf("expected output to contain 'HTTPS:10', got: %q", output)
}
if !strings.Contains(output, "total: 15") {
t.Errorf("expected output to contain 'total: 15', got: %q", output)
}
// Must be a single line ending with newline.
trimmed := strings.TrimRight(output, "\n")
if strings.Contains(trimmed, "\n") {
t.Errorf("expected single line output, got multiple lines: %q", output)
}
}
func TestPrintWindowLineEmpty(t *testing.T) {
snap := classify.WindowSnapshot{
Counts: map[classify.TrafficClass]int64{},
TotalPackets: 0,
WindowIndex: 3,
}
var buf bytes.Buffer
PrintWindowLine(&buf, snap)
output := buf.String()
if !strings.Contains(output, "[window 3]") {
t.Errorf("expected output to contain '[window 3]', got: %q", output)
}
if !strings.Contains(output, "total: 0") {
t.Errorf("expected output to contain 'total: 0', got: %q", output)
}
}
func TestAccumulateTotals(t *testing.T) {
totals := make(map[classify.TrafficClass]int64)
snap1 := classify.WindowSnapshot{
Counts: map[classify.TrafficClass]int64{
classify.ClassICMP: 3,
classify.ClassDNS: 7,
},
TotalPackets: 10,
WindowIndex: 0,
}
snap2 := classify.WindowSnapshot{
Counts: map[classify.TrafficClass]int64{
classify.ClassDNS: 2,
classify.ClassHTTPS: 5,
},
TotalPackets: 7,
WindowIndex: 1,
}
AccumulateTotals(totals, snap1)
AccumulateTotals(totals, snap2)
if totals[classify.ClassICMP] != 3 {
t.Errorf("expected ICMP=3, got %d", totals[classify.ClassICMP])
}
if totals[classify.ClassDNS] != 9 {
t.Errorf("expected DNS=9, got %d", totals[classify.ClassDNS])
}
if totals[classify.ClassHTTPS] != 5 {
t.Errorf("expected HTTPS=5, got %d", totals[classify.ClassHTTPS])
}
}