summaryrefslogtreecommitdiff
path: root/app/src/test/java/org/terst/feedreader/data/ItemTitlesTest.kt
blob: 63606ff9f48187a2470d6fc2f7828557d56a25a1 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
package org.terst.feedreader.data

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * A title for an item that doesn't have one.
 *
 * RSS only requires an item to carry a title *or* a description, and Mastodon's tag feeds
 * exercise that: every one of the 20 items in `mastodon.social/tags/giftlink.rss` has a
 * `<description>` and no `<title>` at all. Subscribing to one produced a list of blank rows.
 */
class ItemTitlesTest {

    /** Verbatim from the #giftlink feed, 2026-10-22, as the XML parser hands it over. */
    private val mastodonPost =
        """<p>This is a nice story, with lovely photos. <a href="https://infosec.exchange/tags/giftlink" """ +
            """class="mention hashtag" rel="nofollow noopener" target="_blank">#<span>giftlink</span></a> </p>""" +
            """<p><a href="https://www.nytimes.com/2026/10/20/us/catherine-breed.html?unlocked_article_code=1.KlE" """ +
            """rel="nofollow noopener" target="_blank"><span class="invisible">https://www.</span>""" +
            """<span class="ellipsis">nytimes.com/2026/10/20/us/cath</span></a></p>"""

    @Test
    fun `a real title is left exactly as it is`() {
        assertEquals(
            "The Flipbook of the Rings",
            ItemTitles.titleFor("The Flipbook of the Rings", "<p>anything</p>", "https://kottke.org/x")
        )
    }

    @Test
    fun `a title of nothing but whitespace counts as absent`() {
        assertEquals("Hello there.", ItemTitles.titleFor("   \n ", "<p>Hello there.</p>", "https://x/y"))
    }

    @Test
    fun `a titleless post is named by its own first sentence`() {
        assertEquals(
            "This is a nice story, with lovely photos.",
            ItemTitles.titleFor("", mastodonPost, "https://infosec.exchange/@cremevax/117483063928862987")
        )
    }

    @Test
    fun `markup never reaches the title`() {
        val title = ItemTitles.titleFor("", mastodonPost, "https://x/y")

        assertTrue("got: $title", !title.contains("<") && !title.contains(">"))
    }

    @Test
    fun `html entities are resolved, since a title is shown as plain text`() {
        assertEquals(
            "Smith & Co's \"deal\" — done",
            ItemTitles.titleFor("", "<p>Smith &amp; Co&#39;s &quot;deal&quot; &mdash; done</p>", "https://x/y")
        )
    }

    @Test
    fun `a non-breaking space is a space, not a stray glyph`() {
        assertEquals("One two three", ItemTitles.titleFor("", "<p>One&nbsp;two&nbsp;three</p>", "https://x/y"))
    }

    @Test
    fun `runs of whitespace collapse, so a title stays one line`() {
        assertEquals("One two", ItemTitles.titleFor("", "<p>One</p>\n\n   <p>two</p>", "https://x/y"))
    }

    @Test
    fun `a long post is cut at a word boundary and marked as cut`() {
        val long = "<p>" + "banana ".repeat(60) + "</p>"

        val title = ItemTitles.titleFor("", long, "https://x/y")

        assertTrue("should be cut: ${title.length}", title.length <= ItemTitles.MAX_LENGTH + 1)
        assertTrue("should say it was cut: $title", title.endsWith("…"))
        assertTrue("should not cut mid-word: $title", title.startsWith("banana banana"))
        assertTrue("should not cut mid-word: $title", !title.contains("banan…"))
    }

    @Test
    fun `a very long first sentence is cut rather than used whole`() {
        val long = "<p>" + "word ".repeat(80) + ". Second sentence.</p>"

        assertTrue(ItemTitles.titleFor("", long, "https://x/y").length <= ItemTitles.MAX_LENGTH + 1)
    }

    @Test
    fun `a post with no words falls back to its link`() {
        val title = ItemTitles.titleFor("", """<p><img src="https://x/a.jpg"></p>""", "https://infosec.exchange/@a/1")

        assertEquals("infosec.exchange/@a/1", title)
    }

    @Test
    fun `no title, no content and no link still gives something to tap`() {
        assertEquals("Untitled", ItemTitles.titleFor("", null, ""))
    }

    @Test
    fun `null content falls back to the link`() {
        assertEquals("example.com/a", ItemTitles.titleFor("", null, "https://example.com/a"))
    }

    @Test
    fun `a trailing slash is not worth showing in a title`() {
        assertEquals("example.com/a", ItemTitles.titleFor("", null, "https://example.com/a/"))
    }

    @Test
    fun `a script or style block contributes no words`() {
        val html = "<style>p{color:red}</style><script>var x=1;</script><p>Actual words.</p>"

        assertEquals("Actual words.", ItemTitles.titleFor("", html, "https://x/y"))
    }

    /**
     * Mastodon splits a hashtag and a URL across inline spans -- `#<span>giftlink</span>` and
     * `<span class="invisible">https://</span><span class="ellipsis">wapo.st/x</span>`. Turning
     * every tag into a space broke both apart, which showed live as titles reading
     * "# HumaneRights # USPolitics" and "the loophole https:// wapo.".
     */
    @Test
    fun `an inline tag does not insert a space into a word`() {
        assertEquals(
            "Tagged #giftlink today.",
            ItemTitles.titleFor("", "<p>Tagged #<span>giftlink</span> today.</p>", "https://x/y")
        )
    }

    @Test
    fun `a url split across spans stays one url`() {
        val html = """<p>Read <a href="https://wapo.st/x"><span class="invisible">https://</span>""" +
            """<span class="ellipsis">wapo.st/x</span></a> now</p>"""

        assertEquals("Read https://wapo.st/x now", ItemTitles.titleFor("", html, "https://x/y"))
    }

    @Test
    fun `a block boundary still separates words`() {
        assertEquals("One two", ItemTitles.titleFor("", "<p>One</p><p>two</p>", "https://x/y"))
    }

    @Test
    fun `a line break separates words`() {
        assertEquals("One two", ItemTitles.titleFor("", "One<br>two", "https://x/y"))
    }

    @Test
    fun `a dot inside a domain does not end the sentence`() {
        assertEquals(
            "The $580 loophole https://wapo.st/abc is open.",
            ItemTitles.titleFor("", "<p>The $580 loophole https://wapo.st/abc is open. And more.</p>", "https://x/y")
        )
    }

    @Test
    fun `a sentence still ends at a full stop followed by a space`() {
        assertEquals(
            "The first sentence here.",
            ItemTitles.titleFor("", "<p>The first sentence here. Second one.</p>", "https://x/y")
        )
    }

    @Test
    fun `a two-word opening fragment is not a title on its own`() {
        // "Wow." says nothing. Keep reading rather than name the article after an interjection.
        assertEquals(
            "Wow. Here is what the article actually says.",
            ItemTitles.titleFor("", "<p>Wow. Here is what the article actually says.</p>", "https://x/y")
        )
    }

    @Test
    fun `a sentence ending the text needs no trailing space`() {
        assertEquals("Only sentence here.", ItemTitles.titleFor("", "<p>Only sentence here.</p>", "https://x/y"))
    }
}