How it works
A reusable report function should make its inputs, output shape, and enumeration boundary clear. BuildReport accepts values, excludes negatives, groups equal values, and returns a materialized array of report strings. Ordering makes the result deterministic, while ToArray ensures the caller receives completed output rather than a query that could later reread a mutable source. The function contains no console output; the caller controls presentation.
That separation makes it easy to compare actual report rows with expected rows. If a report needs later numeric calculations, return records containing numbers and postpone string formatting to an additional display step.
Call BuildReport twice with the same input and compare the arrays element by element. Add a zero and explain why it is included while the negative value is excluded.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
var report = BuildReport(new[] { 2, -1, 2, 5 });
Console.WriteLine(string.Join("|", report));
string[] BuildReport(IEnumerable<int> values) => values.Where(x => x >= 0).GroupBy(x => x).OrderBy(x => x.Key).Select(g => $"{g.Key}:{g.Count()}").ToArray();
Expected output
2:2|5:1
Materialize at a deliberate boundary and keep output outside the calculation.