Skip to content

Commit 415cf69

Browse files
committed
test(score): cover prepareArticleScoring/buildArticleContent/initialNodeScore branches
Add direct unit tests for the remaining uncovered branches in score.go: - initialNodeScoreWithOptions: every tag bucket + WeightClasses path (77.8% -> 100%) - prepareArticleScoring: skip-link removal, aria-modal dialog, unlikely role, main#content rename, duplicate-title h1 removal (84.6% -> 94.9%) - buildArticleContent: body top candidate, hr/short-p/long-p sibling inclusion, li-to-div rewrite, class-match bonus (82.1% -> 87.2%) - directionForCandidate: body + ancestor walk (-> 100%) - hasDataLoadPlaylistSibling: short-circuit on intervening p Overall package coverage 75.7% -> 76.2%. Vet/race/compat all green.
1 parent 56e5e26 commit 415cf69

1 file changed

Lines changed: 247 additions & 0 deletions

File tree

‎score_more_test.go‎

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package readability
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/PuerkitoBio/goquery"
8+
xhtml "golang.org/x/net/html"
9+
)
10+
11+
// TestInitialNodeScoreTagBuckets exercises every switch arm in
12+
// initialNodeScoreWithOptions, including the WeightClasses path.
13+
func TestInitialNodeScoreTagBuckets(t *testing.T) {
14+
cases := []struct {
15+
tag string
16+
want float64
17+
}{
18+
{"div", 5},
19+
{"pre", 3},
20+
{"td", 3},
21+
{"blockquote", 3},
22+
{"address", -3},
23+
{"ol", -3},
24+
{"ul", -3},
25+
{"dl", -3},
26+
{"dd", -3},
27+
{"dt", -3},
28+
{"li", -3},
29+
{"form", -3},
30+
{"h1", -5},
31+
{"h2", -5},
32+
{"h3", -5},
33+
{"h4", -5},
34+
{"h5", -5},
35+
{"h6", -5},
36+
{"th", -5},
37+
{"span", 0}, // default arm
38+
}
39+
for _, c := range cases {
40+
got := initialNodeScoreWithOptions(newElem(c.tag, ""), articleScoringOptions{})
41+
if got != c.want {
42+
t.Errorf("tag=%s: got %v, want %v", c.tag, got, c.want)
43+
}
44+
}
45+
}
46+
47+
func TestInitialNodeScoreWeightClasses(t *testing.T) {
48+
node := &xhtml.Node{Type: xhtml.ElementNode, Data: "div",
49+
Attr: []xhtml.Attribute{{Key: "class", Val: "article"}}}
50+
got := initialNodeScoreWithOptions(node, articleScoringOptions{WeightClasses: true})
51+
// div (+5) + positive class match (+25) = 30
52+
if got != 30 {
53+
t.Fatalf("WeightClasses score = %v, want 30", got)
54+
}
55+
}
56+
57+
// TestBuildArticleContentBodyTopCandidate hits the body branch where all
58+
// child nodes are appended directly into the wrapper.
59+
func TestBuildArticleContentBodyTopCandidate(t *testing.T) {
60+
body := newElem("body", "")
61+
body.AppendChild(newElem("p", ""))
62+
body.AppendChild(newElem("p", ""))
63+
64+
sel := buildArticleContent(body, map[*xhtml.Node]float64{}, 0)
65+
if sel.Length() == 0 {
66+
t.Fatal("expected non-empty selection")
67+
}
68+
if sel.Find("p").Length() != 2 {
69+
t.Fatalf("expected 2 paragraphs, got %d", sel.Find("p").Length())
70+
}
71+
}
72+
73+
// TestBuildArticleContentSiblingBranches exercises the hr/p/long-p inclusion
74+
// rules and the "rewrite to div" fallback.
75+
func TestBuildArticleContentSiblingBranches(t *testing.T) {
76+
body := newElem("body", "")
77+
parent := newElem("div", "")
78+
body.AppendChild(parent)
79+
80+
top := newElem("article", "")
81+
hr := newElem("hr", "")
82+
83+
longP := newElem("p", "")
84+
longText := strings.Repeat("paragraph text with content. ", 6) // > 80 chars, has period
85+
longP.AppendChild(&xhtml.Node{Type: xhtml.TextNode, Data: longText})
86+
87+
shortP := newElem("p", "")
88+
shortP.AppendChild(&xhtml.Node{Type: xhtml.TextNode, Data: "Short sentence."})
89+
90+
rejectedP := newElem("p", "")
91+
// Empty p — no text, density 0, contains no period — should be rejected.
92+
93+
li := newElem("li", "")
94+
// li scores high enough to pass the threshold but is not a valid article child.
95+
96+
for _, n := range []*xhtml.Node{top, hr, longP, shortP, rejectedP, li} {
97+
parent.AppendChild(n)
98+
}
99+
100+
scores := map[*xhtml.Node]float64{
101+
top: 50,
102+
li: 100, // forces threshold inclusion
103+
}
104+
105+
sel := buildArticleContent(top, scores, 50)
106+
if sel.Find("hr").Length() != 1 {
107+
t.Errorf("expected hr included, got %d", sel.Find("hr").Length())
108+
}
109+
if sel.Find("p").Length() < 2 {
110+
t.Errorf("expected long+short paragraphs included, got %d", sel.Find("p").Length())
111+
}
112+
// li should have been rewritten to div.
113+
if sel.Find("div").Length() == 0 {
114+
t.Errorf("expected li rewritten to div")
115+
}
116+
if sel.Find("li").Length() != 0 {
117+
t.Errorf("expected no remaining li elements")
118+
}
119+
}
120+
121+
func TestBuildArticleContentClassMatchBonus(t *testing.T) {
122+
body := newElem("body", "")
123+
parent := newElem("div", "")
124+
body.AppendChild(parent)
125+
126+
top := &xhtml.Node{Type: xhtml.ElementNode, Data: "article",
127+
Attr: []xhtml.Attribute{{Key: "class", Val: "story"}}}
128+
sib := &xhtml.Node{Type: xhtml.ElementNode, Data: "div",
129+
Attr: []xhtml.Attribute{{Key: "class", Val: "story"}}}
130+
parent.AppendChild(top)
131+
parent.AppendChild(sib)
132+
133+
// scores[sib]=11; threshold = max(10, 50*0.2) = 10. With a class-match
134+
// bonus of 50*0.2 = 10, the sibling sails over the threshold.
135+
scores := map[*xhtml.Node]float64{
136+
top: 50,
137+
sib: 11,
138+
}
139+
sel := buildArticleContent(top, scores, 50)
140+
// One class="story" article + one class="story" div should both appear.
141+
if sel.Find("[class='story']").Length() != 2 {
142+
t.Fatalf("expected class-match bonus to include sibling, got %d",
143+
sel.Find("[class='story']").Length())
144+
}
145+
}
146+
147+
func TestHasDataLoadPlaylistSiblingShortCircuit(t *testing.T) {
148+
parent := newElem("div", "")
149+
target := newElem("svg", "")
150+
playlist := &xhtml.Node{Type: xhtml.ElementNode, Data: "div",
151+
Attr: []xhtml.Attribute{{Key: "data-load-playlist", Val: "1"}}}
152+
parent.AppendChild(target)
153+
parent.AppendChild(playlist)
154+
if !hasDataLoadPlaylistSibling(target) {
155+
t.Fatal("expected playlist sibling to be detected")
156+
}
157+
158+
// p sibling between svg and playlist short-circuits the search.
159+
parent2 := newElem("div", "")
160+
target2 := newElem("svg", "")
161+
parent2.AppendChild(target2)
162+
parent2.AppendChild(newElem("p", ""))
163+
parent2.AppendChild(&xhtml.Node{Type: xhtml.ElementNode, Data: "div",
164+
Attr: []xhtml.Attribute{{Key: "data-load-playlist", Val: "1"}}})
165+
if hasDataLoadPlaylistSibling(target2) {
166+
t.Fatal("expected p sibling to short-circuit detection")
167+
}
168+
}
169+
170+
func TestDirectionForCandidateBodyAndAncestor(t *testing.T) {
171+
html := &xhtml.Node{Type: xhtml.ElementNode, Data: "html",
172+
Attr: []xhtml.Attribute{{Key: "dir", Val: "rtl"}}}
173+
body := newElem("body", "")
174+
html.AppendChild(body)
175+
if got := directionForCandidate(body); got != "rtl" {
176+
t.Errorf("body candidate dir = %q, want rtl", got)
177+
}
178+
179+
// nil → empty string
180+
if got := directionForCandidate(nil); got != "" {
181+
t.Errorf("nil dir = %q", got)
182+
}
183+
184+
// ancestor walk for non-body candidate
185+
root := &xhtml.Node{Type: xhtml.ElementNode, Data: "div",
186+
Attr: []xhtml.Attribute{{Key: "dir", Val: "ltr"}}}
187+
mid := newElem("div", "")
188+
leaf := newElem("article", "")
189+
root.AppendChild(mid)
190+
mid.AppendChild(leaf)
191+
if got := directionForCandidate(leaf); got != "ltr" {
192+
t.Errorf("ancestor dir = %q, want ltr", got)
193+
}
194+
}
195+
196+
// TestPrepareArticleScoringSkipAndDialog verifies prepareArticleScoring
197+
// removes skip-links, aria-modal dialogs, and unlikely-role elements while
198+
// keeping #comments / continuation markers intact.
199+
func TestPrepareArticleScoringSkipAndDialog(t *testing.T) {
200+
html := `<html><body>
201+
<a class="skip-link" href="#main">Skip to content</a>
202+
<div role="dialog" aria-modal="true">popup</div>
203+
<div role="navigation">menu</div>
204+
<div id="comments">comments stay</div>
205+
<main id="content"><p>The article body has enough useful text to be picked as the top candidate during scoring.</p></main>
206+
</body></html>`
207+
208+
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
209+
if err != nil {
210+
t.Fatal(err)
211+
}
212+
prepareArticleScoring(doc, "", articleScoringOptions{StripUnlikely: true})
213+
214+
if doc.Find(".skip-link").Length() != 0 {
215+
t.Error("skip-link should have been removed")
216+
}
217+
if doc.Find("[aria-modal='true']").Length() != 0 {
218+
t.Error("aria-modal dialog should have been removed")
219+
}
220+
if doc.Find("[role='navigation']").Length() != 0 {
221+
t.Error("unlikely-role navigation should have been removed")
222+
}
223+
if doc.Find("#comments").Length() != 1 {
224+
t.Error("comments wrapper should be retained")
225+
}
226+
if doc.Find("main#content").Length() != 0 {
227+
t.Error("main#content should have been renamed to div")
228+
}
229+
if doc.Find("div#content").Length() != 1 {
230+
t.Error("expected main rewritten to div with id=content")
231+
}
232+
}
233+
234+
func TestPrepareArticleScoringRemovesDuplicateHeader(t *testing.T) {
235+
html := `<html><body>
236+
<h1>The Quick Brown Fox</h1>
237+
<p>The article body has enough useful text to be picked as the top candidate during scoring.</p>
238+
</body></html>`
239+
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
240+
if err != nil {
241+
t.Fatal(err)
242+
}
243+
prepareArticleScoring(doc, "The Quick Brown Fox", articleScoringOptions{StripUnlikely: true})
244+
if doc.Find("h1").Length() != 0 {
245+
t.Fatal("expected duplicate-title h1 to be removed")
246+
}
247+
}

0 commit comments

Comments
 (0)