package org.terst.feedreader.readability import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith /** * Regression coverage for ReaderExtractor's WHT/Ars Technica-specific preprocessing (see * BACKLOG.md "Extraction test coverage for WHT / Ars Technica"). Each fix so far had been * diagnosed by hand against a single live article; these fixtures pin the specific markup * shapes down so a future change to the extraction pipeline can't silently regress them. * * Needs a real WebView to run Readability.js (see ReaderExtractor's own class doc), so this is * an instrumented test, not a plain JVM unit test. Fixtures are served over real http:// via * MockWebServer -- ReaderExtractor's WebView has allowFileAccess = false (deliberately), so a * file:// fixture URL wouldn't load; a debug-only network security config override permits * cleartext to 127.0.0.1 for exactly this (see app/src/debug/res/xml/network_security_config.xml). * * Fixtures are minimal, hand-authored markup reproducing the exact selectors/attributes * ReaderExtractor's preprocessing targets (grep the class for "paywall", "pswp-caption-content", * "data-src", "post-content-double") -- not scraped live pages, which rot and would make these * tests fail for reasons unrelated to the extraction logic. Real site markup drifts; these * selectors are what the code actually keys off, confirmed by reading ReaderExtractor.kt itself. */ @RunWith(AndroidJUnit4::class) class ReaderExtractorFixtureTest { private lateinit var server: MockWebServer @Before fun startServer() { server = MockWebServer() server.start() } @After fun stopServer() { server.shutdown() } private fun extract(html: String): ExtractedArticle? { server.enqueue(MockResponse().setBody(html).setResponseCode(200)) val url = server.url("/article").toString() val context = InstrumentationRegistry.getInstrumentation().targetContext return runBlocking { ReaderExtractor.extract(context, url) } } // West Hawaii Today's hidden paywall div is literally class="paywall paywall_" -- // ReaderExtractor only un-hides elements matching that (or premium-content/subscriber- // content/metered-content) class/id pattern, and only keeps the un-hidden result if it's // longer than the normal pass. This fixture's paywalled paragraph is the only place the // marker sentence appears, so its presence in the output proves the un-hide path ran and // actually won (not just that Readability found the visible teaser). private val paywallFixture = """ Storm knocks out power across Kona

Storm knocks out power across Kona

A powerful overnight storm swept through West Hawaii, leaving thousands of households without electricity as high winds toppled trees onto power lines across the district. Hawaii Electric Light crews were dispatched before dawn to begin assessing the damage.

County officials opened two emergency shelters at community centers in Kailua-Kona and Captain Cook for residents without power, and urged drivers to treat every intersection with a dark traffic signal as a four-way stop.

""".trimIndent() // West Hawaii Today (and many sites) lazy-load images: the real photo sits in data-src and // only gets copied into src by the site's own script, which never runs here (JS-off by // design elsewhere in the app; this WebView runs JS only to drive Readability itself). // ReaderExtractor resolves data-src into src before Readability ever sees the page. private val lazyImageFixture = """ New pier opens at Kailua Bay

New pier opens at Kailua Bay

The county unveiled a rebuilt public pier at Kailua Bay on Saturday, replacing a structure damaged in last year's storm season with a wider, ADA-accessible design intended to serve both fishing charters and the town's canoe clubs.

The new Kailua Bay pier

Canoe club members said the wider dock apron would make it far easier to launch during the busy early-morning practice hours, when as many as six crews share the same stretch of water before work.

""".trimIndent() // Ars Technica's lightbox (PhotoSwipe) keeps its own duplicate copy of a caption in a // ".pswp-caption-content" div sitting beside the real, normally-visible
-- // Readability's hidden-element skip doesn't catch it (it's not display:none), so without // ReaderExtractor's targeted removal it comes through as a genuine second copy of the text. private val duplicateCaptionFixture = """ Reaction wheel failures leave Swift mission spinning in orbit

Reaction wheel failures leave Swift mission spinning in orbit

Engineers are racing to diagnose a second reaction wheel failure aboard NASA's Swift observatory, a fault that has left the spacecraft unable to maintain the precise pointing its gamma-ray burst instruments depend on.

The Swift observatory
UNIQUE_CAPTION_TEXT The Swift observatory, imaged before launch in 2004.
UNIQUE_CAPTION_TEXT The Swift observatory, imaged before launch in 2004.

A second, independent wheel dropping out of alignment within the same year points engineers toward a shared root cause -- most likely accumulated wear in the same bearing assembly design used across all six of the spacecraft's wheels.

""".trimIndent() // Ars splits a longer article's body across multiple sibling // ".post-content.post-content-double" divs (a full ad rail sits between them in the real // page) instead of one continuous container. Readability scores each independently and // keeps only the highest-scoring one, silently dropping the rest -- ReaderExtractor merges // every such div's children into the first before Readability ever runs. private val splitContentFixture = """ A deep dive into the new orbital refueling demo

A deep dive into the new orbital refueling demo

FIRST_BLOCK_MARKER the mission's opening phase focused on a slow, careful approach between the two spacecraft, with controllers on the ground validating relative navigation sensors before allowing the vehicles to close to within a few meters of each other for the first time.

That rendezvous alone took nearly six hours, deliberately paced far more slowly than a typical crewed docking to leave wide margins for any sensor anomaly.

SECOND_BLOCK_MARKER once docked, the actual propellant transfer ran for just under two hours, moving a test quantity of cryogenic fluid across the connector while instruments on both craft monitored boil-off rates and line pressure for any sign of a leak at the coupling.

Engineers called the transfer itself the least eventful part of the whole demonstration, with the docking and undocking sequences drawing far more scrutiny in the post-mission review.

""".trimIndent() @Test fun paywalledContentIsUnhiddenAndExtracted() { val result = extract(paywallFixture) assertNotNull("extraction should succeed", result) assertNotNull("should have extracted content", result?.textContent) assertTrue( "paywalled paragraph should be present in extracted text", result?.textContent?.contains("PAYWALLED_MARKER_SENTENCE") == true ) } @Test fun lazyLoadedImageSrcIsResolved() { val result = extract(lazyImageFixture) assertNotNull("extraction should succeed", result) val content = result?.content assertNotNull("should have extracted HTML content", content) assertTrue( "extracted content should reference the real photo URL, not the placeholder", content?.contains("real-pier-photo.jpg") == true ) assertFalse( "extracted content should not still reference the lazy placeholder", content?.contains("placeholder-1x1.gif") == true ) } @Test fun duplicatePswpCaptionIsRemovedWhenRealFigcaptionExists() { val result = extract(duplicateCaptionFixture) assertNotNull("extraction should succeed", result) val text = result?.textContent.orEmpty() val occurrences = Regex("UNIQUE_CAPTION_TEXT").findAll(text).count() assertTrue("caption marker should appear at least once (the real figcaption)", occurrences >= 1) assertTrue( "caption marker should appear exactly once, not duplicated by the pswp-caption-content div " + "(found $occurrences occurrences)", occurrences == 1 ) } @Test fun splitPostContentDoubleBlocksAreMerged() { val result = extract(splitContentFixture) assertNotNull("extraction should succeed", result) val text = result?.textContent.orEmpty() assertTrue("first .post-content-double block should be present", text.contains("FIRST_BLOCK_MARKER")) assertTrue( "second .post-content-double block should also be present (not dropped by Readability " + "picking only the highest-scoring candidate)", text.contains("SECOND_BLOCK_MARKER") ) } }