feat: report renderer (charts, summary table, per-app sections)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev c26bdf69e2aaa67270c3d989a1127458928ead58 parent 804811d
7 files changed, +512 −1
  • harness/cmd/bakeoff/main.go +31 −1
  • harness/report/charts.go +102 −0
  • harness/report/markdown.go +67 −0
  • harness/report/report.go +150 −0
  • harness/report/report.html.tmpl +112 −0
  • harness/report/report_test.go +49 −0
  • harness/results.go +1 −0
diff --git a/harness/cmd/bakeoff/main.go b/harness/cmd/bakeoff/main.go
index 96d1b6a..fa3ffa1 100644
--- a/harness/cmd/bakeoff/main.go
+++ b/harness/cmd/bakeoff/main.go
@@ -16,6 +16,7 @@ import (
"time"
b "bakeoff/harness"
+ "bakeoff/harness/report"
)
func main() {
@@ -37,7 +38,7 @@ func main() {
file := fs.String("file", "", "behaviour: Playwright JSON report")
replies := fs.String("replies", "", "readability: comma-separated files, one scorer reply each")
fs.Parse(os.Args[2:])
- if *slug == "" {
+ if *slug == "" && cmd != "report" {
usage()
}
b.ResultsDir = *results
@@ -65,6 +66,8 @@ func main() {
err = tokens(*slug, *logs, *phase)
case "behaviour":
err = behaviour(*slug, *file)
+ case "report":
+ err = renderReport(*file)
case "source":
err = source(*dir)
case "readability":
@@ -288,3 +291,30 @@ func readability(slug, replies string) error {
slug, r.Overall, r.Naming, r.Structure, r.Traceability, r.Errors, r.Comments)
return b.Update(slug, func(a *b.AppResult) { a.Readability = r })
}
+
+// renderReport writes the report page to stdout from every results file,
+// in the contestants' fixed order, with prose from the copy file.
+func renderReport(copyFile string) error {
+ if copyFile == "" {
+ copyFile = "harness/report/copy.md"
+ }
+ text, err := os.ReadFile(copyFile)
+ if err != nil {
+ return err
+ }
+ var rs []b.AppResult
+ for _, a := range b.Apps {
+ r, err := b.LoadIn(b.ResultsDir, a.Slug)
+ if err != nil {
+ return err
+ }
+ if r.Language == "" {
+ r.Language = a.Language
+ }
+ if r.Status == "" {
+ r.Status = "not built yet"
+ }
+ rs = append(rs, r)
+ }
+ return report.Render(rs, report.ParseCopy(string(text)), os.Stdout)
+}
diff --git a/harness/report/charts.go b/harness/report/charts.go
new file mode 100644
index 0000000..2940782
--- /dev/null
+++ b/harness/report/charts.go
@@ -0,0 +1,102 @@
+package report
+
+import (
+ "fmt"
+ "html"
+ "html/template"
+ "math"
+ "strings"
+)
+
+// Row is one app in a chart: a value, or a reason it has none.
+type Row struct {
+ Language string
+ Value float64
+ Parts []float64 // stacked charts only
+ Missing string
+}
+
+// Chart is one horizontal bar chart, apps in fixed report order.
+type Chart struct {
+ Title, Note string
+ Format func(float64) string
+ Legend []string // stacked charts only
+ Rows []Row
+}
+
+const (
+ chartW = 680
+ labelW = 150
+ valueW = 90
+ rowH = 30
+ barH = 16
+ gap = 2
+ topPad = 8
+ fullBarW = chartW - labelW - valueW
+)
+
+// SVG draws the chart. Bars grow from one baseline, 16px thick, with a
+// 4px rounded data end; stacked segments are separated by a 2px gap; the
+// value sits at the bar's tip in text ink; native <title> tooltips stand
+// in for a hover layer, since the page carries no JavaScript.
+func (c Chart) SVG() template.HTML {
+ max := 0.0
+ for _, r := range c.Rows {
+ if r.Missing == "" && r.Value > max {
+ max = r.Value
+ }
+ }
+ h := topPad*2 + rowH*len(c.Rows)
+ var s strings.Builder
+ fmt.Fprintf(&s, `<svg class="chart" viewBox="0 0 %d %d" role="img" aria-label="%s">`, chartW, h, html.EscapeString(c.Title))
+ fmt.Fprintf(&s, `<line class="axis" x1="%d" x2="%d" y1="%d" y2="%d"/>`, labelW, labelW, topPad, h-topPad)
+ for i, r := range c.Rows {
+ y := topPad + i*rowH
+ mid := y + rowH/2
+ fmt.Fprintf(&s, `<text class="label" x="%d" y="%d">%s</text>`, labelW-10, mid+5, html.EscapeString(r.Language))
+ if r.Missing != "" {
+ fmt.Fprintf(&s, `<text class="missing" x="%d" y="%d">%s</text>`, labelW+8, mid+5, html.EscapeString(r.Missing))
+ continue
+ }
+ w := 0.0
+ if max > 0 {
+ w = r.Value / max * fullBarW
+ }
+ by := mid - barH/2
+ tip := html.EscapeString(r.Language + ": " + c.Format(r.Value))
+ if c.HasParts() && len(r.Parts) > 0 {
+ x := float64(labelW)
+ for k, part := range r.Parts {
+ pw := part / max * fullBarW
+ if k > 0 {
+ x += gap
+ pw -= gap
+ }
+ if pw > 0 {
+ last := k == len(r.Parts)-1
+ fmt.Fprintf(&s, `<path class="s%d" d="%s"><title>%s</title></path>`, k+1, barPath(x, float64(by), pw, last), html.EscapeString(fmt.Sprintf("%s: %s %s", r.Language, c.Legend[k], c.Format(part))))
+ }
+ x += math.Max(pw, 0)
+ }
+ } else if w > 0 {
+ fmt.Fprintf(&s, `<path class="s1" d="%s"><title>%s</title></path>`, barPath(labelW, float64(by), w, true), tip)
+ }
+ fmt.Fprintf(&s, `<text class="value" x="%.1f" y="%d">%s</text>`, float64(labelW)+w+6, mid+5, html.EscapeString(c.Format(r.Value)))
+ }
+ s.WriteString(`</svg>`)
+ return template.HTML(s.String())
+}
+
+// barPath is a bar from (x,y) of width w: square at the baseline, and a
+// 4px rounded data end when roundEnd is set.
+func barPath(x, y, w float64, roundEnd bool) string {
+ r := math.Min(4, w/2)
+ if !roundEnd {
+ r = 0
+ }
+ return fmt.Sprintf("M%.1f %.1fH%.1fQ%.1f %.1f %.1f %.1fV%.1fQ%.1f %.1f %.1f %.1fH%.1fZ",
+ x, y, x+w-r, x+w, y, x+w, y+r, y+barH-r, x+w, y+barH, x+w-r, y+barH, x)
+}
+
+func (c Chart) HasParts() bool { return len(c.Legend) > 0 }
+func (c Chart) Parts() []string { return c.Legend }
diff --git a/harness/report/markdown.go b/harness/report/markdown.go
new file mode 100644
index 0000000..88d1693
--- /dev/null
+++ b/harness/report/markdown.go
@@ -0,0 +1,67 @@
+package report
+
+import (
+ "html"
+ "html/template"
+ "regexp"
+ "strings"
+)
+
+// Copy is the report's prose, keyed by section. ParseCopy reads it from a
+// Markdown file whose "## key" headings name the sections.
+type Copy map[string]string
+
+// ParseCopy splits text on lines of the form "## key".
+func ParseCopy(text string) Copy {
+ c := Copy{}
+ key := ""
+ for _, line := range strings.Split(text, "\n") {
+ if k, ok := strings.CutPrefix(line, "## "); ok {
+ key = strings.TrimSpace(k)
+ continue
+ }
+ if key != "" {
+ c[key] += line + "\n"
+ }
+ }
+ for k, v := range c {
+ c[k] = strings.TrimSpace(v)
+ }
+ return c
+}
+
+var (
+ mdLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)
+ mdCode = regexp.MustCompile("`([^`]+)`")
+ mdBold = regexp.MustCompile(`\*\*([^*]+)\*\*`)
+)
+
+func inline(s string) string {
+ s = html.EscapeString(s)
+ s = mdCode.ReplaceAllString(s, "<code>$1</code>")
+ s = mdBold.ReplaceAllString(s, "<strong>$1</strong>")
+ return mdLink.ReplaceAllString(s, `<a href="$2">$1</a>`)
+}
+
+// markdown renders the small subset the copy uses: paragraphs, "- " lists,
+// links, inline code and bold.
+func markdown(src string) template.HTML {
+ var out strings.Builder
+ for _, block := range strings.Split(strings.TrimSpace(src), "\n\n") {
+ lines := strings.Split(strings.TrimSpace(block), "\n")
+ if strings.HasPrefix(lines[0], "- ") {
+ out.WriteString("<ul>")
+ for _, l := range lines {
+ if item, ok := strings.CutPrefix(l, "- "); ok {
+ out.WriteString("<li>" + inline(item))
+ } else {
+ out.WriteString(" " + inline(strings.TrimSpace(l)))
+ }
+ }
+ out.WriteString("</ul>\n")
+ continue
+ }
+ out.WriteString("<p>" + inline(strings.Join(lines, " ")) + "</p>\n")
+ }
+ return template.HTML(out.String())
+}
diff --git a/harness/report/report.go b/harness/report/report.go
new file mode 100644
index 0000000..7809619
--- /dev/null
+++ b/harness/report/report.go
@@ -0,0 +1,150 @@
+// Package report renders the bakeoff's comparison page from results.
+package report
+
+import (
+ _ "embed"
+ "fmt"
+ "html/template"
+ "io"
+ "sort"
+ "strings"
+
+ b "bakeoff/harness"
+)
+
+//go:embed report.html.tmpl
+var pageSrc string
+
+var page = template.Must(template.New("page").Funcs(template.FuncMap{
+ "md": func(c Copy, key string) template.HTML { return markdown(c[key]) },
+ "ms": ms,
+ "mb": mb,
+ "num": num,
+ "usd": usd,
+ "secs": secs,
+ "live": live,
+ "total": func(r b.AppResult) b.Tokens { return r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity) },
+ "wake": func(r b.AppResult) float64 { return medianOf(r.Wake) },
+ "read64": func(r b.AppResult) b.LoadResult { return load(r, "read", 64) },
+ "loc": func(c b.CodeStats) int { return c.Server + c.Templates + c.JS },
+ "add3": func(x b.Behaviour) int { return x.Passed + x.Failed + x.Skipped },
+ "float": func(n int64) float64 { return float64(n) },
+ "inc": func(i int) int { return i + 1 },
+}).Parse(pageSrc))
+
+func live(r b.AppResult) bool { return strings.HasPrefix(r.Status, "LIVE") }
+
+func ms(v float64) string {
+ if v >= 100 {
+ return fmt.Sprintf("%.0f ms", v)
+ }
+ return fmt.Sprintf("%.1f ms", v)
+}
+func mb(v float64) string { return fmt.Sprintf("%.1f MB", v/1e6) }
+func usd(v float64) string { return fmt.Sprintf("$%.2f", v) }
+func secs(v float64) string { return fmt.Sprintf("%.1f s", v) }
+func num(v float64) string {
+ s := fmt.Sprintf("%.0f", v)
+ for i := len(s) - 3; i > 0 && s[i-1] != '-'; i -= 3 {
+ s = s[:i] + "," + s[i:]
+ }
+ return s
+}
+
+func medianOf(v []float64) float64 {
+ if len(v) == 0 {
+ return 0
+ }
+ c := append([]float64(nil), v...)
+ sort.Float64s(c)
+ return c[len(c)/2]
+}
+
+func load(r b.AppResult, scenario string, c int) b.LoadResult {
+ for _, l := range r.Load {
+ if l.Scenario == scenario && l.Concurrency == c {
+ return l
+ }
+ }
+ return b.LoadResult{}
+}
+
+// chart builds one chart over all results; get returns ok=false when the
+// app has no measurement, which is shown as "not measured" (or the reason
+// the app is blocked).
+func chart(rs []b.AppResult, title, note string, format func(float64) string, get func(b.AppResult) (float64, bool)) Chart {
+ c := Chart{Title: title, Note: note, Format: format}
+ for _, r := range rs {
+ row := Row{Language: r.Language}
+ if !live(r) {
+ row.Missing = "blocked"
+ } else if v, ok := get(r); ok {
+ row.Value = v
+ } else {
+ row.Missing = "not measured"
+ }
+ c.Rows = append(c.Rows, row)
+ }
+ return c
+}
+
+func charts(rs []b.AppResult) []Chart {
+ pos := func(v float64) (float64, bool) { return v, v > 0 }
+ loc := Chart{Title: "Lines of code", Note: "Non-blank lines of the app's own code, excluding tests.",
+ Format: func(v float64) string { return num(v) }, Legend: []string{"server", "templates", "JavaScript"}}
+ for _, r := range rs {
+ row := Row{Language: r.Language}
+ c := r.Code
+ if !live(r) {
+ row.Missing = "blocked"
+ } else if c.Server+c.Templates+c.JS == 0 {
+ row.Missing = "not measured"
+ } else {
+ row.Parts = []float64{float64(c.Server), float64(c.Templates), float64(c.JS)}
+ row.Value = float64(c.Server + c.Templates + c.JS)
+ }
+ loc.Rows = append(loc.Rows, row)
+ }
+ return []Chart{
+ chart(rs, "Time to first byte, empty list", "Median of 30 requests from docs-t1, each on a new connection.", ms,
+ func(r b.AppResult) (float64, bool) { return pos(r.TTFBEmpty.P50) }),
+ chart(rs, "Time to first byte, 50 items", "Median of 30 requests from docs-t1, each on a new connection.", ms,
+ func(r b.AppResult) (float64, bool) { return pos(r.TTFB50.P50) }),
+ chart(rs, "Wake from sleep", "Time to first byte on the first request after the instance went to sleep. Median of 3.", ms,
+ func(r b.AppResult) (float64, bool) { return pos(medianOf(r.Wake)) }),
+ chart(rs, "Reads per second, 64 at once", "GET / on a 50-item list for 30 s with 64 concurrent connections.", num,
+ func(r b.AppResult) (float64, bool) { return pos(load(r, "read", 64).RPS) }),
+ chart(rs, "Read latency (p99), 64 at once", "The slowest 1% of those reads took at least this long.", ms,
+ func(r b.AppResult) (float64, bool) { return pos(load(r, "read", 64).P99Ms) }),
+ chart(rs, "Toggles per second, 64 at once", "POST toggling one item for 30 s with 64 concurrent connections.", num,
+ func(r b.AppResult) (float64, bool) { return pos(load(r, "toggle", 64).RPS) }),
+ chart(rs, "Binary size", "The linux-arm64 binary that ships.", mb,
+ func(r b.AppResult) (float64, bool) { return pos(float64(r.Build.Bytes)) }),
+ chart(rs, "Cold build", "Every library compiled from already-downloaded sources.", secs,
+ func(r b.AppResult) (float64, bool) { return pos(r.Build.ColdSeconds) }),
+ chart(rs, "Warm build", "Libraries already compiled; the app rebuilt from clean.", secs,
+ func(r b.AppResult) (float64, bool) { return pos(r.Build.WarmSeconds) }),
+ chart(rs, "One-line change", "Rebuild after changing one string in the request-handling code.", secs,
+ func(r b.AppResult) (float64, bool) { return pos(r.Build.EditSeconds) }),
+ loc,
+ chart(rs, "Cost to build", "Opus 5.5 build plus fix rounds, at list price.", usd,
+ func(r b.AppResult) (float64, bool) {
+ return pos(r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity).CostUSD)
+ }),
+ chart(rs, "Output tokens to build", "Tokens Opus 5.5 wrote, build plus fix rounds.", num,
+ func(r b.AppResult) (float64, bool) {
+ return pos(float64(r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity).Output))
+ }),
+ chart(rs, "Readability", "Fable's blind score out of 10; median of three readings.", func(v float64) string { return fmt.Sprintf("%.0f / 10", v) },
+ func(r b.AppResult) (float64, bool) { return pos(r.Readability.Overall) }),
+ }
+}
+
+// Render writes the report page.
+func Render(rs []b.AppResult, copy Copy, w io.Writer) error {
+ return page.Execute(w, struct {
+ Results []b.AppResult
+ Charts []Chart
+ Copy Copy
+ }{rs, charts(rs), copy})
+}
diff --git a/harness/report/report.html.tmpl b/harness/report/report.html.tmpl
new file mode 100644
index 0000000..e96c5ac
--- /dev/null
+++ b/harness/report/report.html.tmpl
@@ -0,0 +1,112 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{index .Copy "title"}}</title>
+<style>
+:root {
+ color-scheme: light;
+ --plane: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
+ --grid: #e1e0d9; --axis: #c3c2b7; --ring: rgba(11,11,11,0.10);
+ --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ color-scheme: dark;
+ --plane: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
+ --grid: #2c2c2a; --axis: #383835; --ring: rgba(255,255,255,0.10);
+ --s1: #3987e5; --s2: #d95926; --s3: #199e70;
+ }
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--plane); color: var(--ink); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
+main { max-width: 760px; margin: 0 auto; padding: 48px 20px 80px; }
+h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 16px; }
+h2 { font-size: 1.35rem; margin: 56px 0 12px; }
+h3 { font-size: 1.05rem; margin: 0 0 4px; }
+p, li { color: var(--ink-2); }
+a { color: inherit; }
+code { font-size: .9em; }
+.card { background: var(--surface); border: 1px solid var(--ring); border-radius: 10px; padding: 18px 20px; margin: 16px 0; }
+.note { color: var(--muted); font-size: .9rem; margin: 2px 0 8px; }
+.chart { width: 100%; height: auto; display: block; }
+.chart .label { fill: var(--ink-2); font-size: 13px; text-anchor: end; }
+.chart .value { fill: var(--ink); font-size: 13px; font-variant-numeric: tabular-nums; }
+.chart .missing { fill: var(--muted); font-size: 13px; font-style: italic; }
+.chart .axis { stroke: var(--axis); stroke-width: 1; }
+.chart .s1 { fill: var(--s1); } .chart .s2 { fill: var(--s2); } .chart .s3 { fill: var(--s3); }
+.legend { display: flex; gap: 16px; font-size: .85rem; color: var(--ink-2); margin: 4px 0 0; padding: 0; list-style: none; }
+.legend li::before { content: ""; display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; background: var(--k); }
+.scroll { overflow-x: auto; }
+table { border-collapse: collapse; width: 100%; font-size: .85rem; font-variant-numeric: tabular-nums; }
+th, td { padding: 6px 8px; border-bottom: 1px solid var(--grid); text-align: right; white-space: nowrap; }
+th:first-child, td:first-child { text-align: left; }
+th { color: var(--ink-2); font-weight: 600; }
+.blocked td:not(:first-child) { color: var(--muted); text-align: left; white-space: normal; }
+dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 8px 0 0; font-size: .92rem; }
+dt { color: var(--muted); } dd { margin: 0; color: var(--ink-2); }
+</style>
+</head>
+<body>
+<main>
+<h1>{{index .Copy "title"}}</h1>
+{{md .Copy "intro"}}
+
+<h2>At a glance</h2>
+<div class="card scroll">
+<table>
+<thead><tr><th>Setup</th><th>Tests (JS on / off)</th><th>First byte</th><th>Wake</th><th>Reads/s @64</th><th>Binary</th><th>Cold / warm build</th><th>Lines</th><th>Deps</th><th>Cost</th><th>Readability</th></tr></thead>
+<tbody>
+{{- range .Results}}
+{{- if live .}}
+<tr><td>{{.Language}}</td>
+<td>{{.BehaviourJSOn.Passed}}/{{add3 .BehaviourJSOn}} · {{.BehaviourJSOff.Passed}}/{{add3 .BehaviourJSOff}}</td>
+<td>{{ms .TTFBEmpty.P50}}</td><td>{{ms (wake .)}}</td><td>{{num (read64 .).RPS}}</td>
+<td>{{mb (float .Build.Bytes)}}</td><td>{{secs .Build.ColdSeconds}} / {{secs .Build.WarmSeconds}}</td>
+<td>{{loc .Code}}</td><td>{{.Code.Deps}}</td><td>{{usd (total .).CostUSD}}</td><td>{{.Readability.Overall}}/10</td></tr>
+{{- else}}
+<tr class="blocked"><td>{{.Language}}</td><td colspan="10">{{.Status}}</td></tr>
+{{- end}}
+{{- end}}
+</tbody>
+</table>
+</div>
+
+{{range .Charts}}
+<div class="card">
+<h3>{{.Title}}</h3>
+<p class="note">{{.Note}}</p>
+{{.SVG}}
+{{- if .HasParts}}
+<ul class="legend">{{range $i, $p := .Parts}}<li style="--k: var(--s{{inc $i}})">{{$p}}</li>{{end}}</ul>
+<div class="scroll"><table><thead><tr><th>Setup</th>{{range .Parts}}<th>{{.}}</th>{{end}}</tr></thead><tbody>
+{{- range .Rows}}{{if .Parts}}<tr><td>{{.Language}}</td>{{range .Parts}}<td>{{num .}}</td>{{end}}</tr>{{end}}{{end}}
+</tbody></table></div>
+{{- end}}
+</div>
+{{end}}
+
+<h2>The apps</h2>
+{{range .Results}}
+<div class="card">
+<h3>{{.Language}}</h3>
+<dl>
+<dt>Status</dt><dd>{{.Status}}</dd>
+{{- if .URL}}<dt>Live app</dt><dd><a href="{{.URL}}">{{.URL}}</a></dd>{{end}}
+{{- if .Source}}<dt>Source</dt><dd><a href="{{.Source}}">{{.Source}}</a></dd>{{end}}
+{{- if .Stack}}<dt>Stack</dt><dd>{{.Stack}}</dd>{{end}}
+{{- if .Readability.Notes}}<dt>Readability</dt><dd>{{.Readability.Notes}}</dd>{{end}}
+{{- if .ReviewFindings}}<dt>Not fixed after review</dt><dd><ul>{{range .ReviewFindings}}<li>{{.}}</li>{{end}}</ul></dd>{{end}}
+{{- range .Notes}}<dt>Note</dt><dd>{{.}}</dd>{{end}}
+</dl>
+</div>
+{{end}}
+
+<h2>{{index .Copy "method-heading"}}</h2>
+{{md .Copy "method"}}
+<h2>{{index .Copy "caveats-heading"}}</h2>
+{{md .Copy "caveats"}}
+</main>
+</body>
+</html>
diff --git a/harness/report/report_test.go b/harness/report/report_test.go
new file mode 100644
index 0000000..81a0b58
--- /dev/null
+++ b/harness/report/report_test.go
@@ -0,0 +1,49 @@
+package report
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ b "bakeoff/harness"
+)
+
+func fixture() []b.AppResult {
+ live := b.AppResult{
+ Slug: "go", Language: "Go", Status: "LIVE", URL: "https://todo-go.bfj.oncarlos.com", Version: "abc1234",
+ Stack: "net/http, html/template, modernc.org/sqlite",
+ BehaviourJSOn: b.Behaviour{Passed: 35}, BehaviourJSOff: b.Behaviour{Passed: 33, Skipped: 2},
+ TTFBEmpty: b.Stats{N: 30, P50: 41.2}, TTFB50: b.Stats{N: 30, P50: 45.9},
+ Wake: []float64{120, 140, 130},
+ Load: []b.LoadResult{{Scenario: "read", Concurrency: 64, RPS: 2100, P50Ms: 30, P95Ms: 40, P99Ms: 55}},
+ Weight: b.Weight{HTMLBytes: 9000, JSBytes: 2400},
+ Code: b.CodeStats{Server: 300, Templates: 80, JS: 120, Tests: 200, Deps: 1},
+ Build: b.Build{ColdSeconds: 40, WarmSeconds: 2, EditSeconds: 1.1, Bytes: 9_400_000, Host: "docs-t1"},
+ TokensBuild: b.Tokens{Output: 50000, CostUSD: 4.2, Seconds: 1800}, TokensFix: b.Tokens{Output: 5000, CostUSD: 0.5},
+ Readability: b.Readability{Overall: 8, Naming: 4, Structure: 4, Traceability: 5, Errors: 4, Comments: 3, Notes: "Clear & tidy <ok>"},
+ }
+ blocked := b.AppResult{Slug: "mojo", Language: "Mojo", Status: "BLOCKED platform: single-file binaries only"}
+ return []b.AppResult{live, blocked}
+}
+
+func TestRenderAllApps(t *testing.T) {
+ var buf bytes.Buffer
+ copy := Copy{"intro": "Nine apps.\n\nOne brief.", "method": "- one\n- two [link](https://example.com)"}
+ if err := Render(fixture(), copy, &buf); err != nil {
+ t.Fatal(err)
+ }
+ out := buf.String()
+ for _, want := range []string{
+ "Go", "Mojo", "single-file binaries only", "https://todo-go.bfj.oncarlos.com", "<svg",
+ "<p>Nine apps.</p>", `<a href="https://example.com">link</a>`, "Clear &amp; tidy &lt;ok&gt;", "9.4 MB",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("output lacks %q", want)
+ }
+ }
+ for _, bad := range []string{"NaN", "<no value>", "Inf", "<script"} {
+ if strings.Contains(out, bad) {
+ t.Errorf("output contains %q", bad)
+ }
+ }
+}
diff --git a/harness/results.go b/harness/results.go
index 274b9fb..4ecb228 100644
--- a/harness/results.go
+++ b/harness/results.go
@@ -23,6 +23,7 @@ type Behaviour struct{ Passed, Failed, Skipped int }
// AppResult is everything the report shows for one app.
type AppResult struct {
Slug, Language, Status, URL, Version, Stack string
+ Source string
BehaviourJSOn, BehaviourJSOff Behaviour
TTFBEmpty, TTFB50 Stats
Wake []float64