Skip to content

Commit 301f79e

Browse files
jamesarichCopilot
andcommitted
test(docs): add UI interaction and URL resolution tests
Add plain UI compose tests following state-driven testing patterns: - DocsSearchBarTest: clear button visibility, callback, text input - DocsBrowserScreenTest: loading/empty/results states, callbacks, sections - DocsPageRouteScreenTest: loading/not-found/content states, back nav - DocsLinkUriHandlerTest: URL resolution (relative, anchor, .html/.md) Make DocsLinkUriHandler internal for testability. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8f53d22 commit 301f79e

5 files changed

Lines changed: 440 additions & 2 deletions

File tree

feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreen.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
5050
import org.meshtastic.feature.docs.model.DocPageContent
5151

5252
/** Routes a page ID to the appropriate page renderer surface. */
53+
@Suppress("LongMethod")
5354
@OptIn(ExperimentalMaterial3Api::class)
5455
@Composable
5556
fun DocsPageRouteScreen(
@@ -112,7 +113,8 @@ fun DocsPageRouteScreen(
112113
content = markdownText,
113114
imageTransformer = ComposeResourceImageTransformer(),
114115
dimens = markdownDimens(tableCellWidth = 108.dp),
115-
components = markdownComponents(
116+
components =
117+
markdownComponents(
116118
table = {
117119
MarkdownTable(
118120
content = it.content,
@@ -156,7 +158,7 @@ fun DocsPageRouteScreen(
156158
* Relative links like `connections`, `../developer/architecture`, or anchor-only `#section` are resolved to a page ID
157159
* and dispatched via [onNavigateToPage]. External `http(s)://` URLs are forwarded to the [fallback] platform handler.
158160
*/
159-
private class DocsLinkUriHandler(private val onNavigateToPage: (String) -> Unit, private val fallback: UriHandler) :
161+
internal class DocsLinkUriHandler(private val onNavigateToPage: (String) -> Unit, private val fallback: UriHandler) :
160162
UriHandler {
161163
override fun openUri(uri: String) {
162164
if (uri.startsWith("http://") || uri.startsWith("https://")) {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.docs.ui
18+
19+
import androidx.compose.ui.test.ExperimentalTestApi
20+
import androidx.compose.ui.test.assertIsDisplayed
21+
import androidx.compose.ui.test.onNodeWithContentDescription
22+
import androidx.compose.ui.test.onNodeWithText
23+
import androidx.compose.ui.test.performClick
24+
import androidx.compose.ui.test.v2.runComposeUiTest
25+
import org.meshtastic.feature.docs.model.DocPage
26+
import org.meshtastic.feature.docs.model.DocSection
27+
import kotlin.test.Test
28+
import kotlin.test.assertEquals
29+
import kotlin.test.assertTrue
30+
31+
@OptIn(ExperimentalTestApi::class)
32+
class DocsBrowserScreenTest {
33+
34+
private fun samplePage(id: String = "connections", title: String = "Connections") = DocPage(
35+
id = id,
36+
title = title,
37+
section = DocSection.UserGuide,
38+
navOrder = 1,
39+
resourcePath = "user/$id.md",
40+
keywords = listOf("connect"),
41+
charCount = 1000,
42+
)
43+
44+
@Test
45+
fun loadingState_showsSpinner() = runComposeUiTest {
46+
setContent {
47+
DocsBrowserScreen(
48+
pages = emptyList(),
49+
isLoading = true,
50+
searchQuery = "",
51+
onSearchQueryChange = {},
52+
onSelectPage = {},
53+
onBack = {},
54+
)
55+
}
56+
onNodeWithText("Loading documentation...").assertIsDisplayed()
57+
}
58+
59+
@Test
60+
fun emptyPages_noSearchQuery_showsNoDocsMessage() = runComposeUiTest {
61+
setContent {
62+
DocsBrowserScreen(
63+
pages = emptyList(),
64+
isLoading = false,
65+
searchQuery = "",
66+
onSearchQueryChange = {},
67+
onSelectPage = {},
68+
onBack = {},
69+
)
70+
}
71+
onNodeWithText("No documentation available").assertIsDisplayed()
72+
}
73+
74+
@Test
75+
fun emptyPages_withSearchQuery_showsNoResultsMessage() = runComposeUiTest {
76+
setContent {
77+
DocsBrowserScreen(
78+
pages = emptyList(),
79+
isLoading = false,
80+
searchQuery = "xyzzy",
81+
onSearchQueryChange = {},
82+
onSelectPage = {},
83+
onBack = {},
84+
)
85+
}
86+
onNodeWithText("No results found").assertIsDisplayed()
87+
}
88+
89+
@Test
90+
fun pagesLoaded_showsTitles() = runComposeUiTest {
91+
setContent {
92+
DocsBrowserScreen(
93+
pages = listOf(samplePage(), samplePage(id = "mqtt", title = "MQTT")),
94+
isLoading = false,
95+
searchQuery = "",
96+
onSearchQueryChange = {},
97+
onSelectPage = {},
98+
onBack = {},
99+
)
100+
}
101+
onNodeWithText("Connections").assertIsDisplayed()
102+
onNodeWithText("MQTT").assertIsDisplayed()
103+
}
104+
105+
@Test
106+
fun pageItemClick_callsOnSelectPage() = runComposeUiTest {
107+
var selectedId: String? = null
108+
setContent {
109+
DocsBrowserScreen(
110+
pages = listOf(samplePage()),
111+
isLoading = false,
112+
searchQuery = "",
113+
onSearchQueryChange = {},
114+
onSelectPage = { selectedId = it },
115+
onBack = {},
116+
)
117+
}
118+
onNodeWithContentDescription("Open Connections").performClick()
119+
runOnIdle { assertEquals("connections", selectedId) }
120+
}
121+
122+
@Test
123+
fun backButton_callsOnBack() = runComposeUiTest {
124+
var backCalled = false
125+
setContent {
126+
DocsBrowserScreen(
127+
pages = listOf(samplePage()),
128+
isLoading = false,
129+
searchQuery = "",
130+
onSearchQueryChange = {},
131+
onSelectPage = {},
132+
onBack = { backCalled = true },
133+
)
134+
}
135+
onNodeWithContentDescription("Navigate back").performClick()
136+
runOnIdle { assertTrue(backCalled) }
137+
}
138+
139+
@Test
140+
fun userGuideSection_showsSectionHeader() = runComposeUiTest {
141+
setContent {
142+
DocsBrowserScreen(
143+
pages = listOf(samplePage()),
144+
isLoading = false,
145+
searchQuery = "",
146+
onSearchQueryChange = {},
147+
onSelectPage = {},
148+
onBack = {},
149+
)
150+
}
151+
onNodeWithText("User Guide").assertIsDisplayed()
152+
}
153+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.docs.ui
18+
19+
import androidx.compose.ui.platform.UriHandler
20+
import kotlin.test.Test
21+
import kotlin.test.assertEquals
22+
import kotlin.test.assertTrue
23+
24+
class DocsLinkUriHandlerTest {
25+
26+
private val navigatedPages = mutableListOf<String>()
27+
private val externalUris = mutableListOf<String>()
28+
29+
private val fallback =
30+
object : UriHandler {
31+
override fun openUri(uri: String) {
32+
externalUris += uri
33+
}
34+
}
35+
36+
private val handler = DocsLinkUriHandler(onNavigateToPage = { navigatedPages += it }, fallback = fallback)
37+
38+
@Test
39+
fun httpLink_delegatesToFallback() {
40+
handler.openUri("https://meshtastic.org/docs/faq")
41+
assertTrue(externalUris.contains("https://meshtastic.org/docs/faq"))
42+
assertTrue(navigatedPages.isEmpty())
43+
}
44+
45+
@Test
46+
fun httpLink_delegatesToFallbackForHttp() {
47+
handler.openUri("http://example.com")
48+
assertTrue(externalUris.contains("http://example.com"))
49+
assertTrue(navigatedPages.isEmpty())
50+
}
51+
52+
@Test
53+
fun anchorOnlyLink_isIgnored() {
54+
handler.openUri("#permissions")
55+
assertTrue(navigatedPages.isEmpty())
56+
assertTrue(externalUris.isEmpty())
57+
}
58+
59+
@Test
60+
fun simpleName_navigatesToPage() {
61+
handler.openUri("connections")
62+
assertEquals(listOf("connections"), navigatedPages)
63+
}
64+
65+
@Test
66+
fun relativePathWithParent_extractsFilename() {
67+
handler.openUri("../developer/architecture")
68+
assertEquals(listOf("architecture"), navigatedPages)
69+
}
70+
71+
@Test
72+
fun htmlExtension_isStripped() {
73+
handler.openUri("mqtt.html")
74+
assertEquals(listOf("mqtt"), navigatedPages)
75+
}
76+
77+
@Test
78+
fun mdExtension_isStripped() {
79+
handler.openUri("settings-radio-user.md")
80+
assertEquals(listOf("settings-radio-user"), navigatedPages)
81+
}
82+
83+
@Test
84+
fun linkWithAnchor_stripsAnchorAndNavigates() {
85+
handler.openUri("nodes#signal-quality")
86+
assertEquals(listOf("nodes"), navigatedPages)
87+
}
88+
89+
@Test
90+
fun relativePathWithHtmlAndAnchor_extractsCleanPageId() {
91+
handler.openUri("../user/mqtt.html#encryption")
92+
assertEquals(listOf("mqtt"), navigatedPages)
93+
}
94+
95+
@Test
96+
fun blankUri_isIgnored() {
97+
handler.openUri("")
98+
assertTrue(navigatedPages.isEmpty())
99+
assertTrue(externalUris.isEmpty())
100+
}
101+
102+
@Test
103+
fun anchorOnly_emptyAnchor_isIgnored() {
104+
handler.openUri("#")
105+
assertTrue(navigatedPages.isEmpty())
106+
}
107+
}

0 commit comments

Comments
 (0)