Files
warmbly/internal/pkg/warmlint/score_test.go
T

602 lines
23 KiB
Go

package warmlint
import (
"fmt"
"math/rand"
"strings"
"testing"
"unicode/utf8"
)
func hasIssue(res ScoreResult, code string) bool {
for _, i := range res.Issues {
if i.Code == code {
return true
}
}
return false
}
func TestScoreCleanCopyIsUnpenalized(t *testing.T) {
res := Score(
"Quick question about your hiring process",
"<p>Hi Ada, saw you are growing the support team. Worth a chat?</p>",
"Hi Ada, saw you are growing the support team. Worth a chat?",
)
if res.Score != 100 {
t.Errorf("clean copy scored %d with %+v, want 100", res.Score, res.Issues)
}
}
func TestScoreImageHeavyBody(t *testing.T) {
// Almost all image, no readable text: filters cannot read it, and that is
// treated as evasion rather than as a design choice.
res := Score("Hello", `<img src="a.png"><img src="b.png">`, "")
if !hasIssue(res, "image_heavy") {
t.Errorf("image-only body not flagged: %+v", res.Issues)
}
// Plenty of text, a few images: fine.
body := strings.Repeat("A real sentence about the recipient's work. ", 20)
res = Score("Hello", "<p>"+body+`</p><img src="a.png">`, body)
if hasIssue(res, "image_heavy") || hasIssue(res, "many_images") {
t.Errorf("ordinary copy with one image was flagged: %+v", res.Issues)
}
// Many images alongside text is a softer warning.
res = Score("Hello", "<p>"+body+`</p>`+strings.Repeat(`<img src="x.png">`, 6), body)
if !hasIssue(res, "many_images") {
t.Errorf("six images not flagged: %+v", res.Issues)
}
if hasIssue(res, "image_heavy") {
t.Errorf("text-rich body wrongly flagged as image-only: %+v", res.Issues)
}
}
func TestScoreWithAttachments(t *testing.T) {
subject, html, plain := "Following up", "<p>Hi Ada, following up on my note.</p>", "Hi Ada, following up on my note."
base := ScoreWithAttachments(subject, html, plain, 0)
if hasIssue(base, "has_attachments") {
t.Errorf("no attachments should not flag: %+v", base.Issues)
}
withAtt := ScoreWithAttachments(subject, html, plain, 2)
if !hasIssue(withAtt, "has_attachments") {
t.Errorf("attachments not flagged: %+v", withAtt.Issues)
}
if withAtt.Score >= base.Score {
t.Errorf("attachment score %d not below the clean %d", withAtt.Score, base.Score)
}
}
func TestScoreNeverGoesNegative(t *testing.T) {
// Every heuristic at once: the floor is 0, not a negative number the UI
// would have to special-case.
res := ScoreWithAttachments(
"FREE CASH PRIZE GUARANTEED!!!",
`<img src="a.png"><img src="b.png"><img src="c.png"><img src="d.png">`,
"",
5,
)
if res.Score < 0 {
t.Errorf("score = %d, want a floor of 0", res.Score)
}
if len(res.Issues) == 0 {
t.Error("obviously spammy copy produced no issues")
}
}
func TestScoreCountsLinksInHTMLAnchors(t *testing.T) {
// A normal HTML email carries its URLs in href attributes. Stripping tags
// throws those away, so counting the text alone reported zero links and the
// cap never fired for the case it exists for.
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
html := "<p>" + body + "</p><p>" +
strings.Repeat(`<a href="https://example.com/x">see this</a> `, 6) + "</p>"
res := Score("Quick question", html, "")
if !hasIssue(res, "too_many_links") {
t.Errorf("six anchors in an HTML body were not counted: %+v", res.Issues)
}
// The editor stores a plain-text body derived from the HTML, which also
// drops the hrefs. The score must not depend on which one is present.
res = Score("Quick question", html, body)
if !hasIssue(res, "too_many_links") {
t.Errorf("six anchors alongside a link-free plain body were not counted: %+v", res.Issues)
}
}
func TestScoreDoesNotDoubleCountSelfLinkingAnchors(t *testing.T) {
// A URL used as its own anchor text appears in both the href and the text.
// Counting both would flag three links as six.
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
html := "<p>" + body + "</p><p>" +
`<a href="https://a.com/1">https://a.com/1</a> ` +
`<a href="https://b.com/2">https://b.com/2</a> ` +
`<a href="https://c.com/3">https://c.com/3</a>` + "</p>"
plain := body + " https://a.com/1 https://b.com/2 https://c.com/3"
res := Score("Quick question", html, plain)
if hasIssue(res, "too_many_links") {
t.Errorf("three self-linking anchors were counted as more: %+v", res.Issues)
}
}
func TestScoreStillCountsPlainTextURLs(t *testing.T) {
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
plain := body + " https://a.com/1 https://b.com/2 https://c.com/3 https://d.com/4 https://e.com/5"
res := Score("Quick question", "", plain)
if !hasIssue(res, "too_many_links") {
t.Errorf("five bare URLs in a plain body were not counted: %+v", res.Issues)
}
}
func TestScoreCountsAnchorsAndBareURLsTogether(t *testing.T) {
// Labeled anchors and bare URLs are different destinations. Counting only
// the larger of the two sets let six distinct links score a clean 100.
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
html := "<p>" + body + "</p><p>" +
`<a href="https://a.com/1">one</a> <a href="https://b.com/2">two</a> <a href="https://c.com/3">three</a> ` +
"https://d.com/4 https://e.com/5 https://f.com/6</p>"
plain := body + " one two three https://d.com/4 https://e.com/5 https://f.com/6"
res := Score("Quick question", html, plain)
if !hasIssue(res, "too_many_links") {
t.Errorf("three anchors plus three bare URLs were not counted as six: %+v", res.Issues)
}
}
func TestScoreIgnoresTrailingPunctuationWhenMatchingAnchors(t *testing.T) {
// A URL that ends a sentence in the plain text is the same link as the
// anchor's destination, so it must not count a second time.
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
html := "<p>" + body + "</p><p>" +
`<a href="https://a.com/1">https://a.com/1</a>, <a href="https://b.com/2">https://b.com/2</a>.` + "</p>"
plain := body + " https://a.com/1, https://b.com/2."
res := Score("Quick question", html, plain)
if hasIssue(res, "too_many_links") {
t.Errorf("two self-linking anchors counted as more than two: %+v", res.Issues)
}
}
// The text renderer keeps a link's destination so the plain-text part stays
// usable, which put URL slugs in front of the trigger-term list: a CTA
// pointing at /free-trial cost eight points for a word no reader ever sees.
func TestScoreIgnoresWordsInsideALinkDestination(t *testing.T) {
plain := Score("Quick question", `<p>Hi Ana, worth a look?</p>`, "")
slug := Score("Quick question", `<p>Hi Ana, <a href="https://example.com/free-trial">worth a look?</a></p>`, "")
if slug.Score != plain.Score {
t.Errorf("a URL slug changed the content score: %d vs %d (%v)", slug.Score, plain.Score, slug.Issues)
}
}
// A stylesheet is markup machinery, never copy: a class named for a trigger
// term must not cost a designed email anything.
func TestScoreIgnoresAStylesheet(t *testing.T) {
clean := Score("Quick question", "<p>Hi Ana, ten minutes on Thursday?</p>", "")
styled := Score("Quick question", `<style>.free-trial-banner{color:red}</style><p>Hi Ana, ten minutes on Thursday?</p>`, "")
if styled.Score != clean.Score {
t.Errorf("a stylesheet changed the content score: %d vs %d (%v)", styled.Score, clean.Score, styled.Issues)
}
}
func issueByCode(res ScoreResult, code string) (Issue, bool) {
for _, i := range res.Issues {
if i.Code == code {
return i, true
}
}
return Issue{}, false
}
// A score with no location is advice nobody can act on: the writer is told
// three trigger terms exist and left to hunt for them.
func TestScoreLocatesTriggerTermsInTheSubject(t *testing.T) {
res := Score("Your FREE bonus inside", "<p>Hi Ada, worth a chat?</p>", "Hi Ada, worth a chat?")
issue, ok := issueByCode(res, "spam_trigger_terms")
if !ok {
t.Fatalf("trigger terms not flagged: %+v", res.Issues)
}
if issue.Field != FieldSubject {
t.Errorf("issue field = %q, want subject", issue.Field)
}
if len(issue.Spans) != 2 {
t.Fatalf("got %d spans, want one per term: %+v", len(issue.Spans), issue.Spans)
}
for _, s := range issue.Spans {
if s.Field != FieldSubject {
t.Errorf("span %q sits in %q, want subject", s.Text, s.Field)
}
if s.Excerpt != "Your FREE bonus inside" {
t.Errorf("span %q quoted %q, want the whole subject line", s.Text, s.Excerpt)
}
}
if issue.Spans[0].Text != "FREE" {
t.Errorf("first span = %q, want the term as the writer typed it", issue.Spans[0].Text)
}
if issue.Suggestion == "" {
t.Error("no suggestion on a trigger-term issue")
}
}
func TestScoreLocatesTriggerTermsInTheBodyByLine(t *testing.T) {
body := "Hi Ada,\n\nThis is a limited time offer.\n\nWorth a chat?"
res := Score("Quick question", "", body)
issue, ok := issueByCode(res, "spam_trigger_terms")
if !ok {
t.Fatalf("trigger phrase not flagged: %+v", res.Issues)
}
if issue.Field != FieldBody {
t.Errorf("issue field = %q, want body", issue.Field)
}
span := issue.Spans[0]
if span.Line != 3 {
t.Errorf("span line = %d, want the third line", span.Line)
}
if span.Excerpt != "This is a limited time offer." {
t.Errorf("excerpt = %q, want the sentence it sits in", span.Excerpt)
}
}
// A term in both halves counts once towards the score, as it always has, but is
// shown in both places. Deduplicating the spans too lost the body occurrence and
// then labelled the whole issue "subject", sending the writer to one of the two
// boxes the word is actually in.
func TestScoreCountsATermInBothHalvesOnceButShowsBoth(t *testing.T) {
res := Score("A free look", "", "Here is a free look at it.")
issue, ok := issueByCode(res, "spam_trigger_terms")
if !ok {
t.Fatalf("trigger term not flagged: %+v", res.Issues)
}
if res.Score != 92 {
t.Errorf("score = %d, want one term's deduction only", res.Score)
}
if !strings.HasPrefix(issue.Message, "1 spam-trigger term(s)") {
t.Errorf("message = %q, want it to count the term once", issue.Message)
}
if len(issue.Spans) != 2 {
t.Fatalf("got %d spans, want the word shown in both halves: %+v", len(issue.Spans), issue.Spans)
}
if issue.Spans[0].Field != FieldSubject || issue.Spans[1].Field != FieldBody {
t.Errorf("spans = %+v, want the subject one first", issue.Spans)
}
if issue.Field != "" {
t.Errorf("field = %q, want empty: the word is in both halves", issue.Field)
}
}
// The trigger pass reads a URL-stripped copy of the text; quoting that copy
// would show the writer a line with their own links blanked out.
func TestScoreExcerptKeepsTheLinkTheTriggerPassStripped(t *testing.T) {
body := "A free look: https://example.com/pricing"
res := Score("Quick question", "", body)
issue, ok := issueByCode(res, "spam_trigger_terms")
if !ok {
t.Fatalf("trigger term not flagged: %+v", res.Issues)
}
if issue.Spans[0].Excerpt != body {
t.Errorf("excerpt = %q, want the line as written", issue.Spans[0].Excerpt)
}
}
func TestScoreLocatesStackedPunctuationAndImages(t *testing.T) {
res := Score("Hurry!!", "<p>Ready?!</p>", "Ready?!")
issue, ok := issueByCode(res, "stacked_punctuation")
if !ok {
t.Fatalf("stacked punctuation not flagged: %+v", res.Issues)
}
if issue.Field != "" {
t.Errorf("field = %q, want empty when the issue straddles both halves", issue.Field)
}
if len(issue.Spans) != 2 {
t.Fatalf("got %d spans, want one per run: %+v", len(issue.Spans), issue.Spans)
}
if issue.Spans[0].Field != FieldSubject || issue.Spans[1].Field != FieldBody {
t.Errorf("spans = %+v, want the subject one first", issue.Spans)
}
}
func TestScoreLinkIssueNamesTheDestinations(t *testing.T) {
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
html := "<p>" + body + "</p><p>" +
`<a href="https://a.com/1">one</a> <a href="https://b.com/2">two</a> ` +
`<a href="https://c.com/3">three</a> <a href="https://d.com/4">four</a></p>`
res := Score("Quick question", html, body)
issue, ok := issueByCode(res, "too_many_links")
if !ok {
t.Fatalf("four links not flagged: %+v", res.Issues)
}
if len(issue.Spans) != 4 {
t.Fatalf("got %d spans, want one per link: %+v", len(issue.Spans), issue.Spans)
}
if issue.Spans[0].Text != "https://a.com/1" {
t.Errorf("first span = %q, want the destination", issue.Spans[0].Text)
}
}
// Trimming the displayed spans must never move the score, so the count that
// drives the deduction is taken before the cap.
func TestScoreCapsSpansWithoutMovingTheScore(t *testing.T) {
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
links := ""
for i := 0; i < 14; i++ {
links += fmt.Sprintf(`<a href="https://example.com/%d">link</a> `, i)
}
res := Score("Quick question", "<p>"+body+"</p><p>"+links+"</p>", body)
issue, ok := issueByCode(res, "too_many_links")
if !ok {
t.Fatalf("fourteen links not flagged: %+v", res.Issues)
}
if len(issue.Spans) != maxSpans {
t.Errorf("got %d spans, want them capped at %d", len(issue.Spans), maxSpans)
}
if !strings.HasPrefix(issue.Message, "14 links") {
t.Errorf("message = %q, want it to count all fourteen", issue.Message)
}
// 14 links is 11 over the allowance, well past the 20-point cap.
if res.Score != 80 {
t.Errorf("score = %d, want the capped 20-point deduction", res.Score)
}
}
func TestScoreFieldsTheIssuesWithNothingToQuote(t *testing.T) {
res := Score("", "", "")
for _, code := range []string{"empty_subject", "empty_body"} {
issue, ok := issueByCode(res, code)
if !ok {
t.Fatalf("%s not flagged: %+v", code, res.Issues)
}
if issue.Field == "" {
t.Errorf("%s has no field", code)
}
if issue.Suggestion == "" {
t.Errorf("%s has no suggestion", code)
}
}
}
// The preflight dialog and the campaign feed both print one line about the
// worst thing in a step's copy, and that line has to say which box to open.
func TestLeadIssueNamesTheField(t *testing.T) {
if got := LeadIssue(Score("FREE CASH PRIZE", "<p>Hi Ada, worth a chat?</p>", "Hi Ada, worth a chat?")); !strings.HasPrefix(got, "Subject: ") {
t.Errorf("lead issue = %q, want it to name the subject", got)
}
if got := LeadIssue(Score("Quick question", "", "Here is a limited time offer.")); !strings.HasPrefix(got, "Body: ") {
t.Errorf("lead issue = %q, want it to name the body", got)
}
if got := LeadIssue(Score("Quick question", "<p>Hi Ada, worth a chat?</p>", "Hi Ada, worth a chat?")); got != "" {
t.Errorf("lead issue = %q on clean copy, want nothing", got)
}
}
// A high-severity issue outranks a warning even when the warning came first.
func TestLeadIssuePrefersTheHighSeverityOne(t *testing.T) {
res := ScoreResult{Issues: []Issue{
{Severity: "warn", Code: "stacked_punctuation", Message: "Stacked punctuation."},
{Severity: "high", Code: "empty_body", Field: FieldBody, Message: "Body has no text content."},
}}
if got := LeadIssue(res); got != "Body: Body has no text content." {
t.Errorf("lead issue = %q, want the high-severity one", got)
}
}
// A rune can change byte length when it is lowercased (U+0130 is two bytes and
// folds to one, U+212A is three), so an offset found in the folded copy walks
// off the original past the first one. Slicing on it quoted bytes the writer
// never typed, and could cut a rune in half into invalid UTF-8.
func TestScoreQuotesTheRightBytesAcrossACaseFold(t *testing.T) {
for _, tc := range []struct {
subject string
want string
}{
{"İİİ free offer", "free"},
{"KKK cash prize", "cash"},
{"café free trial", "free"},
{"🎉 free bonus", "free"},
} {
issue, ok := issueByCode(Score(tc.subject, "", "Hi Ada, worth a chat?"), "spam_trigger_terms")
if !ok {
t.Fatalf("%q: trigger term not flagged", tc.subject)
}
span := issue.Spans[0]
if !utf8.ValidString(span.Text) {
t.Errorf("%q: span text is not valid UTF-8: %q", tc.subject, span.Text)
}
if span.Text != tc.want {
t.Errorf("%q: quoted %q, want %q", tc.subject, span.Text, tc.want)
}
if span.Excerpt != tc.subject {
t.Errorf("%q: excerpt = %q", tc.subject, span.Excerpt)
}
}
}
// The same offsets carry the line number, so a body with a fold-shifting rune
// above the trigger term must still point at the right line.
func TestScoreLinesSurviveACaseFold(t *testing.T) {
body := "Hi İIrem,\n\nStraße KKK notes.\n\nHere is a free look."
issue, ok := issueByCode(Score("Quick question", "", body), "spam_trigger_terms")
if !ok {
t.Fatalf("trigger term not flagged")
}
span := issue.Spans[0]
if span.Text != "free" {
t.Errorf("quoted %q, want %q", span.Text, "free")
}
if span.Line != 5 {
t.Errorf("line = %d, want 5", span.Line)
}
if span.Excerpt != "Here is a free look." {
t.Errorf("excerpt = %q", span.Excerpt)
}
}
// The field is read before the span list is trimmed. A subject-first list of
// more than maxSpans in one half would otherwise lose every body span to the
// cap and label a both-halves issue as a subject-only one.
func TestScoreFieldSurvivesTheSpanCap(t *testing.T) {
subject := "free cash prize bonus discount promo sale deal loan credit"
body := "This is a limited time offer."
issue, ok := issueByCode(Score(subject, "", body), "spam_trigger_terms")
if !ok {
t.Fatalf("trigger terms not flagged")
}
if len(issue.Spans) != maxSpans {
t.Fatalf("got %d spans, want them capped at %d", len(issue.Spans), maxSpans)
}
if issue.Field != "" {
t.Errorf("field = %q, want empty: the terms straddle both halves", issue.Field)
}
}
// The message names the terms and the field names the place, so the one-line
// summary does not read "Body: 3 spam-trigger term(s) found in subject/body".
func TestLeadIssueDoesNotRepeatTheLocation(t *testing.T) {
got := LeadIssue(Score("Quick question", "", "Here is a free look at it."))
if got != "Body: 1 spam-trigger term(s): free." {
t.Errorf("lead issue = %q", got)
}
}
// Terms in both halves have no single field, so the summary says both rather
// than dropping the location entirely.
func TestLeadIssueNamesBothHalvesWhenTheIssueStraddles(t *testing.T) {
got := LeadIssue(Score("A free look", "", "This is a limited time offer."))
if !strings.HasPrefix(got, "Subject and body: ") {
t.Errorf("lead issue = %q, want it to name both halves", got)
}
}
// The display cap keeps the first maxSpans, so a body carrying more anchors
// than that would drop the subject's own link and leave the issue claiming a
// half it no longer shows.
func TestScoreLinkSpansPutTheSubjectFirst(t *testing.T) {
body := strings.Repeat("A real sentence about the recipient's work. ", 10)
links := ""
for i := 0; i < 12; i++ {
links += fmt.Sprintf(`<a href="https://example.com/%d">link</a> `, i)
}
res := Score("Quick question https://sub.example.com/x", "<p>"+body+"</p><p>"+links+"</p>", body)
issue, ok := issueByCode(res, "too_many_links")
if !ok {
t.Fatalf("links not flagged: %+v", res.Issues)
}
if issue.Spans[0].Field != FieldSubject {
t.Errorf("first span is in %q, want the subject's link kept: %+v", issue.Spans[0].Field, issue.Spans)
}
if issue.Field != "" {
t.Errorf("field = %q, want empty: the links straddle both halves", issue.Field)
}
if !strings.HasPrefix(issue.Message, "13 links") {
t.Errorf("message = %q, want all thirteen counted", issue.Message)
}
}
// spanFragments are the pieces the fuzz below builds copy from: trigger words
// and phrases, punctuation, markup, links, and the runes whose byte length
// changes when they are lowercased.
var spanFragments = []string{
"free", "FREE", "Cash", "act now", "click here", "100% free", "limited time",
"Hi Ada,", "worth a chat?", "!!!", "?!", "\n", "\n\n", " ", "café", "İstanbul",
"KKK", "🎉", "https://a.com/free-trial", "https://b.co/x.", "risk-free",
"a real sentence about the recipient's work.", "GUARANTEED", "no cost",
"<p>hello</p>", `<a href="https://c.io/1">one</a>`, `<img src="x.png">`,
`<style>.free-banner{color:red}</style>`, "Straße", "fi", "%", "100%", "free cash",
}
func randCopy(r *rand.Rand, n int) string {
var b strings.Builder
for i := 0; i < n; i++ {
b.WriteString(spanFragments[r.Intn(len(spanFragments))])
}
return b.String()
}
// The panel highlights a span's text as the writer's own words, so every span
// has to be real bytes of the half it names. A quote assembled from a shifted
// offset is not, and neither is an empty one.
func TestEverySpanQuotesTheHalfItNames(t *testing.T) {
r := rand.New(rand.NewSource(20260910))
for i := 0; i < 20000; i++ {
subject := randCopy(r, r.Intn(6))
html := randCopy(r, r.Intn(8))
plain := ""
if r.Intn(3) > 0 {
plain = randCopy(r, r.Intn(8))
}
for _, issue := range Score(subject, html, plain).Issues {
for _, sp := range issue.Spans {
if sp.Text == "" {
t.Fatalf("case %d: empty span on %s", i, issue.Code)
}
if issue.Code == "too_many_links" {
continue // an href is quoted from the markup, not the copy
}
hay := plain
if sp.Field == FieldSubject {
hay = strings.TrimSpace(subject)
} else if strings.TrimSpace(plain) == "" {
hay = stripTags(html)
}
if !strings.Contains(hay, sp.Text) {
t.Fatalf("case %d: %s span %q is not in the %s\nsubject=%q\nhtml=%q\nplain=%q",
i, issue.Code, sp.Text, sp.Field, subject, html, plain)
}
if !utf8.ValidString(sp.Text) {
t.Fatalf("case %d: %s span is not valid UTF-8: %q", i, issue.Code, sp.Text)
}
}
}
}
}
// Pointing at one "free" out of three sends the writer back to hunt for the
// other two on the next re-check, which is the hunting this panel exists to
// stop. The term still counts once towards the score.
func TestScoreShowsEveryOccurrenceOfARepeatedTerm(t *testing.T) {
res := Score("Quick question", "", "A free look.\nAnother free thing.\nOne more free one.")
issue, ok := issueByCode(res, "spam_trigger_terms")
if !ok {
t.Fatalf("trigger term not flagged: %+v", res.Issues)
}
if res.Score != 92 {
t.Errorf("score = %d, want one term's deduction however often it is written", res.Score)
}
if !strings.HasPrefix(issue.Message, "1 spam-trigger term(s)") {
t.Errorf("message = %q, want the term counted once", issue.Message)
}
if len(issue.Spans) != 3 {
t.Fatalf("got %d spans, want one per occurrence: %+v", len(issue.Spans), issue.Spans)
}
for i, want := range []int{1, 2, 3} {
if issue.Spans[i].Line != want {
t.Errorf("span %d is on line %d, want %d", i, issue.Spans[i].Line, want)
}
}
}
// The span list is capped for display, so occurrences come out round by round:
// every term shows once before any term shows twice. In reading order a word
// written twenty times would fill the list and hide every other term that is
// also wrong.
func TestScoreSpansShowEveryTermBeforeAnyRepeats(t *testing.T) {
body := strings.Repeat("A free thing. ", 20) + "Act now, this is a bonus prize."
issue, ok := issueByCode(Score("Quick question", "", body), "spam_trigger_terms")
if !ok {
t.Fatalf("trigger terms not flagged")
}
seen := map[string]bool{}
for _, sp := range issue.Spans {
seen[sp.Text] = true
}
for _, want := range []string{"free", "Act now", "bonus", "prize"} {
if !seen[want] {
t.Errorf("%q never got a span; the repeated word filled the list: %+v", want, issue.Spans)
}
}
}