carlos / todomvc-bakeoff Public
feat: report renderer (charts, summary table, per-app sections)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+512
−1
harness/cmd/bakeoff/main.go+31 −1harness/report/charts.go+102 −0harness/report/markdown.go+67 −0harness/report/report.go+150 −0harness/report/report.html.tmpl+112 −0harness/report/report_test.go+49 −0harness/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 & tidy <ok>", "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 |