The search mode within the Find tab: a search bar with mode chips (Combined/Title/Content/File/Tags), a tag filter row, and a debounced results list that feeds into the cross-mode context menu (pin, center on graph). The screen, its ViewModel, the SearchService query engine, and all tests are documented together since they form a closed vertical slice — the service drives the ViewModel, the ViewModel drives the screen, and the tests validate both layers.
Introduction
SearchScreen is the default mode in the Find tab. It provides full-text search across the indexed org-roam database with five search modes (SearchMode enum: COMBINED, TITLE, CONTENT, FILENAME, TAGS), a 300ms debounced query flow, tag-based filtering, and a scored results list. Each result renders as a compact Quiz-style card: title with match-source/pin badges, a highlighted FTS5 content snippet (query matches rendered bold in the primary color) when the body mentions the query, tag pills, and an Arcology publish footer (site/key) when it carries an ARCOLOGY_KEY — unpublished cards simply end with their tags. Long-press opens a context menu with Open, Center on Graph, and Pin/Unpin actions — wired by the parent FindScreen container.
SearchViewModel owns a reactive pipeline: four mutable state flows (query, searchMode, selectedTags, _refreshTrigger) combine via debounce(300) into a StateFlow<List<SearchResultItem>> that automatically re-queries on any state change. It also exposes availableTags loaded from the repository at init and on refresh.
The ViewModel also exports two cross-cutting methods: searchForTag(tag) sets the mode to TAGS with a single pre-selected tag (used by the editor's tag chips), and getNodesWithBacklinksFromFlashcards() provides the quiz screen's "explore backlinks" view.
Design Decisions
**Why debounce on the query but not on mode/tags?
Only _query is debounced (300ms). _searchMode and _selectedTags flow through the combine immediately. This is intentional: keyboard typing generates rapid-fire state changes that thrash the FTS index, so debouncing is necessary. Mode switches and tag toggles are single explicit user actions — a button tap — with no need for debounce. The combine triggers on any of the four flows changing, so tag toggles are instantaneous while typing is delayed.
**Why Eagerly sharing over Lazily?
searchResults is a stateIn(SharingStarted.Eagerly) flow. Eager sharing means the pipeline starts immediately when the ViewModel is created, populating the initial empty-query results (recent nodes + pinned nodes) without waiting for the first collector. This matters because SearchScreen needs to display content immediately when the Find tab is first shown — if it used Lazily, the screen would briefly flash empty before the initial load completes.
**Why runBlocking in getNodesWithBacklinksFromFlashcards?
QuizScreen calls getNodesWithBacklinksFromFlashcards() synchronously during composable composition (not from a coroutine). It needs immediate results to build the explore-backlinks view. While runBlocking is generally an anti-pattern in ViewModels, the alternative would require restructuring QuizScreen to launch a coroutine and manage its own loading state — a larger refactor. The method is called rarely (only when the user views the flashcard explore screen) and queries are fast (simple list of node IDs).
**Why FILENAME bypasses SearchService?
Filename search uses repository.searchNodesByFilePath() with SQL LIKE rather than FTS because file paths aren't indexed in the full-text search table. Path matching is purely structural (pattern matching against a column), not content-based, so LIKE is the right tool. The performSearch method has an early return for FILENAME mode before dispatching to SearchService.
**Why recent nodes intersperse pinned nodes at the top?
When query.isBlank(), the ViewModel loads "recent" content: pinned nodes first (with Double.MAX_VALUE rank to sort highest), then recent nodes from SearchService.getRecentNodes() filtered to exclude already-pinned IDs. This means pinned nodes always appear at the top of the empty-state view, giving users persistent quick-access bookmarks in the search tab.
SearchService — Query Engine
SearchService sits between the ViewModel and the repository, providing tiered full-text search with BM25 ranking. It owns the query pipeline: preparing FTS queries (escaping special characters), dispatching to title/metadata search (searchPrimary) or content search (searchContent), and a combined strategy (searchCombined) that runs title search first (limit 20) then fills remaining slots with content results for a total of 50.
Both FTS paths carry snippets: searchContentBM25 returns an FTS5 snippet() window directly from SQL, and searchPrimary runs its own content-table MATCH to attach body snippets to title-matched nodes — without that second query, COMBINED searches with many title matches would return zero snippets because the content search is skipped entirely (this was the bug that motivated the card redesign: the SQL and drivers were fine, the snippet() window only existed on content-fallback rows).
The service also provides getRecentNodes() for the empty-state recent-content view. SearchResult and SearchSource are defined here because they are the service's output types — separate from the UI-layer SearchResultItem that the ViewModel enriches with tags and publish keys.
package computer.whatthefuck.arcology.search
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.domain.FtsSearchResult
import computer.whatthefuck.arcology.domain.OrgNode
private const val TAG = "SearchService"/**
* High-level search service that provides tiered search functionality:
* 1. Fast title/metadata search for primary results
* 2. Content search for comprehensive results when needed
* 3. BM25 ranking for relevance scoring
*/
class SearchService(
private val repository: RoamRepository
) {
/**
* Primary search function - searches titles, tags, and aliases with BM25 ranking
*/
suspend fun searchPrimary(query: String, limit: Int = 50): List<SearchResult> {
if (query.isBlank()) return emptyList()
val ftsQuery = prepareFtsQuery(query)
println("[$TAG] query for $query: $ftsQuery")
val results = repository.searchNodesByTitlesBM25(ftsQuery, limit.toLong())
val len = results.count()
println("[$TAG] results.length $len")
// Title-matched nodes may also mention the query in their body — fetch
// content-table snippets for them so every card can show a highlighted
// window, not just pure content matches. One extra FTS MATCH on an
// indexed virtual table costs tens of milliseconds.
val contentSnippets = repository
.searchNodesByContentBM25(ftsQuery, limit.toLong())
.mapNotNull { result -> result.snippet?.let { result.nodeId to it } }
.toMap()
return results.map { ftsResult ->
val node = repository.getNodeById(ftsResult.nodeId)
SearchResult(
node = node!!,
rank = ftsResult.rank,
source = SearchSource.TITLE_METADATA,
snippet = contentSnippets[ftsResult.nodeId]
)
}
}
/**
* Extended search function - searches full content when title search insufficient
*/
suspend fun searchContent(query: String, excludeNodeIds: Set<String> = emptySet(), limit: Int = 50): List<SearchResult> {
if (query.isBlank()) return emptyList()
val ftsQuery = prepareFtsQuery(query)
val results = repository.searchNodesByContentBM25(ftsQuery, limit.toLong())
return results
.filter { it.nodeId !in excludeNodeIds }
.map { ftsResult ->
val node = repository.getNodeById(ftsResult.nodeId)
SearchResult(
node = node!!,
rank = ftsResult.rank,
source = SearchSource.CONTENT,
snippet = ftsResult.snippet
)
}
}
/**
* Combined search - primary search with content search fallback
*/
suspend fun searchCombined(query: String, primaryLimit: Int = 20, totalLimit: Int = 50): List<SearchResult> {
if (query.isBlank()) return emptyList()
// Get primary results from title/metadata search
val primaryResults = searchPrimary(query, primaryLimit)
// If we have enough results, return them
if (primaryResults.size >= totalLimit) {
return primaryResults.take(totalLimit)
}
// Get additional results from content search
val primaryNodeIds = primaryResults.map { it.node.id }.toSet()
val contentLimit = totalLimit - primaryResults.size
val contentResults = searchContent(query, primaryNodeIds, contentLimit)
// Combine and sort by rank (BM25 score - lower is better)
return (primaryResults + contentResults)
.sortedBy { it.rank }
.take(totalLimit)
}
/**
* Simple search using basic FTS without ranking (fallback)
*/
suspend fun searchSimple(query: String, searchContent: Boolean = false, limit: Int = 50): List<OrgNode> {
if (query.isBlank()) return emptyList()
val ftsQuery = prepareFtsQuery(query)
val nodeIds = if (searchContent) {
repository.searchNodesByContent(ftsQuery, limit.toLong())
} else {
repository.searchNodesByTitles(ftsQuery, limit.toLong())
}
return nodeIds.mapNotNull { nodeId ->
repository.getNodeById(nodeId)
}
}
/**
* Get recent nodes ordered by file modification time
*/
suspend fun getRecentNodes(limit: Int = 100): List<SearchResult> {
val nodes = repository.getRecentNodes(limit.toLong())
return nodes.map { node ->
SearchResult(
node = node,
rank = 0.0,
source = SearchSource.TITLE_METADATA
)
}
}
/**
* Prepare FTS query by escaping special characters and handling edge cases.
*/
private fun prepareFtsQuery(query: String): String {
if (query.isBlank()) return ""
return query.trim()
.replace("\"", "\"\"") // Escape quotes
.replace("-", " ") // FTS5 splits on hyphens; space-separate for AND matching
.let { if (it.isEmpty()) "\"\"" else it } // Handle empty queries
}
}
/**
* Search result with ranking and source information
*
* @param snippet FTS5 highlight window around query matches in the node body.
* Populated for content-table matches directly from SQL, and for title-matched
* nodes whose body also mentions the query (via searchPrimary's content-table
* side-query). Null when the node's body has no match — title-only matches
* whose body never mentions the query, and recent/filename/tag-list results.
*/
data class SearchResult(
val node: OrgNode,
val rank: Double, // BM25 rank (lower is better)
val source: SearchSource,
val snippet: String? = null
)
/**
* Source of search result for UI feedback
*/
enum class SearchSource {
TITLE_METADATA, // Found in title, tags, or aliases
CONTENT // Found in full content
}<<search-service-imports>>
<<search-service-class>>SearchMode Enum
SearchMode defines the five search strategies:
COMBINED— searches both title/metadata and content, prioritizing title matches (20 primary, 50 total)TITLE— searches title/metadata only viasearchService.searchPrimary()CONTENT— searches body content only viasearchService.searchContent()FILENAME— searches file paths via SQL LIKETAGS— filters by tag intersection
enum class SearchMode {
COMBINED, // Search both title and content
TITLE, // Search title/metadata only
CONTENT, // Search content only
FILENAME, // Search by file path
TAGS // Filter by tags
}SearchResultItem & TagWithCount
Two data classes that bridge the search pipeline to the UI:
SearchResultItem— what the screen renders per card: nodeId, title, filePath, rank, tags, optional matchSource (T/C badge), optional highlighted snippet, and optional publishKey (thesite/keyroute of an =ARCOLOGY_KEY=-bearing node, or null when private/unpublished)TagWithCount— what the filter chip row displays: tag name + frequency count from the database
data class SearchResultItem(
val nodeId: String,
val title: String,
val filePath: String,
val rank: Double = 0.0,
val tags: List<String> = emptyList(),
val matchSource: SearchSource? = null,
val snippet: String? = null,
val publishKey: String? = null
)
data class TagWithCount(
val tag: String,
val count: Long
)SearchViewModel State
The ViewModel manages seven state flows:
_query— the current search text (mutable, debounced at 300ms)_searchMode— active search mode (mutable)_selectedTags— set of active tag filters (mutable)_refreshTrigger— integer counter incremented to force recomputation_isSearching— loading flag set during query execution_availableTags— list of all tags with counts (loaded at init)searchResults— the derived reactive pipeline combining the above
The searchResults pipeline is the core: it ~combine~s _query.debounce(300), _searchMode, _selectedTags, and _refreshTrigger into a tuple, ~map~s to the appropriate query method, and stateIn with SharingStarted.Eagerly.
@OptIn(FlowPreview::class)
class SearchViewModel(
private val searchService: SearchService,
private val repository: RoamRepository,
private val quizRepository: QuizRepository
) : ViewModel() {
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query
private val _searchMode = MutableStateFlow(SearchMode.COMBINED)
val searchMode: StateFlow<SearchMode> = _searchMode
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching
private val _selectedTags = MutableStateFlow<Set<String>>(emptySet())
val selectedTags: StateFlow<Set<String>> = _selectedTags
private val _availableTags = MutableStateFlow<List<TagWithCount>>(emptyList())
val availableTags: StateFlow<List<TagWithCount>> = _availableTags
private val _refreshTrigger = MutableStateFlow(0)
val searchResults: StateFlow<List<SearchResultItem>> = combine(
_query.debounce(300),
_searchMode,
_selectedTags,
_refreshTrigger
) { queryText, mode, tags, _ -> Triple(queryText, mode, tags) }
.map { (queryText, mode, selectedTags) ->
_isSearching.value = true
try {
when {
// Tag mode: filter by selected tags
mode == SearchMode.TAGS && selectedTags.isNotEmpty() -> {
searchByTags(selectedTags, queryText)
}
// No query: show recent nodes
queryText.isBlank() -> {
loadRecentNodes()
}
// Search with mode
else -> {
performSearch(queryText, mode)
}
}
} finally {
_isSearching.value = false
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
init {
loadAvailableTags()
}Query & Mode Control
Public methods that update mutable state:
updateQuery()— sets the query text, which fires the debounce pipelinesetSearchMode()— switches between COMBINED/TITLE/CONTENT/FILENAME/TAGStoggleTag()— adds or removes a tag from the selected setclearSelectedTags()— resets the tag filtersearchForTag()— sets mode to TAGS and selects a single tag (external API for editor tag chips)refresh()— increments the trigger and reloads available tags
fun updateQuery(newQuery: String) {
_query.value = newQuery
}
fun setSearchMode(mode: SearchMode) {
_searchMode.value = mode
}
fun toggleTag(tag: String) {
val current = _selectedTags.value
_selectedTags.value = if (tag in current) {
current - tag
} else {
current + tag
}
}
fun clearSelectedTags() {
_selectedTags.value = emptySet()
}
/**
* Selects a single tag for searching, clearing any previously selected tags.
* Also sets the search mode to TAGS.
*/
fun searchForTag(tag: String) {
_searchMode.value = SearchMode.TAGS
_selectedTags.value = setOf(tag)
}
fun refresh() {
_refreshTrigger.value++
loadAvailableTags()
}
private fun loadAvailableTags() {
viewModelScope.launch {
try {
val tagsWithCount = quizRepository.getTagsForFlashcardNodesWithCount()
_availableTags.value = tagsWithCount.map { (tag, count) ->
TagWithCount(tag, count)
}
} catch (e: Exception) {
_availableTags.value = emptyList()
}
}
}Cross-cutting: Flashcard Backlinks
QuizScreen uses this method to build its "explore backlinks" view. It calls runBlocking because the quiz screen consumes it during composable composition rather than from a coroutine scope.
fun getNodesWithBacklinksFromFlashcards(): List<SearchResultItem> {
return runBlocking {
try {
val nodeIds = quizRepository.getNodesWithBacklinksFromFlashcards()
val nodes = nodeIds.mapNotNull { nodeId ->
repository.getNodeById(nodeId)
}
val publishKeys = fetchPublishKeys(nodes.map { it.id })
nodes.mapNotNull { node ->
buildItem(node = node, publishKeys = publishKeys)
}
} catch (e: Exception) {
emptyList()
}
}
}Result Enrichment
Every search pipeline branch (FTS, filename, tag-filter, recent, backlinks) builds =SearchResultItem=s the same way: fetch tags, resolve the Arcology publish key, wrap it up. Five near-identical construction blocks grew over time; buildItem() consolidates them so enrichment changes land in one place.
Publish-key resolution is deliberately two-tier:
fetchPublishKeys()batch-fetchesgetNodesByPropertyKey(ARCOLOGY_KEY)— this covers:ARCOLOGY_KEY:in heading:PROPERTIES:drawers, including file-level nodes that carry the preamble properties when the file has an:ID:.Nodes not covered get a per-*distinct-file*
getFileProperties()fallback — files whose#+ARCOLOGY_KEY:preamble exists but which have no:ID:never produce a file-level node, so their key lives only infile_properties. Results repeat files heavily, so the fallback dedupes on file path.
Heading keys win over file keys when both exist. ARCOLOGY_EXPIRE display is deliberately not implemented here — "is it expired yet?" is the server's semantics; the card shows just the site/key route.
/**
* Fetch publish keys for a batch of nodes: heading-drawer properties first,
* then file-property fallback for files without a file-level node.
*/
private suspend fun fetchPublishKeys(nodeIds: Collection<String>): Map<String, String> {
if (nodeIds.isEmpty()) return emptyMap()
return try {
val keysByNode = mutableMapOf<String, String>()
try {
repository.getNodesByPropertyKey(PUBLISH_KEY_PROPERTY).forEach { (nodeId, value) ->
if (!value.isNullOrBlank() && nodeId in nodeIds) {
keysByNode[nodeId] = value.trim()
}
}
} catch (_: Exception) {
}
val missing = nodeIds.filter { it !in keysByNode }
val filesByNode = mutableMapOf<String, String>()
missing.forEach { nodeId ->
try {
val node = repository.getNodeById(nodeId) ?: return@forEach
filesByNode[nodeId] = node.file
} catch (_: Exception) {
}
}
for ((nodeId, file) in filesByNode) {
try {
val value = repository.getFileProperties(file)[PUBLISH_KEY_PROPERTY]
if (!value.isNullOrBlank()) keysByNode[nodeId] = value.trim()
} catch (_: Exception) {
}
}
keysByNode
} catch (_: Exception) {
emptyMap()
}
}
/**
* Single construction point for search result items: enriches a node with
* tags and its Arcology publish key (when published).
*/
private suspend fun buildItem(
node: OrgNode,
rank: Double = 0.0,
matchSource: SearchSource? = null,
snippet: String? = null,
publishKeys: Map<String, String>? = null
): SearchResultItem {
val tags = try {
repository.getTagsByNode(node.id)
} catch (_: Exception) {
emptyList()
}
val publishKey = publishKeys?.get(node.id)
return SearchResultItem(
nodeId = node.id,
title = node.title ?: "Untitled",
filePath = node.file,
rank = rank,
tags = tags,
matchSource = matchSource,
snippet = snippet,
publishKey = publishKey
)
}Search Implementation
The private search methods dispatch to SearchService for FTS queries or RoamRepository for structural queries:
performSearch()— dispatches to the appropriate FTS method based on mode, then enriches results with tagssearchByFilename()— usesrepository.searchNodesByFilePath()with LIKE matchingsearchByTags()— intersects node ID sets for all selected tags, optionally filtering by title queryloadRecentNodes()— loads pinned nodes (ranked highest) then recent nodes fromSearchService
private suspend fun performSearch(query: String, mode: SearchMode): List<SearchResultItem> {
return try {
// Handle filename search separately (uses LIKE, not FTS)
if (mode == SearchMode.FILENAME) {
return searchByFilename(query)
}
val results: List<SearchResult> = when (mode) {
SearchMode.TITLE -> searchService.searchPrimary(query, 50)
SearchMode.CONTENT -> searchService.searchContent(query, limit = 50)
SearchMode.COMBINED -> searchService.searchCombined(query, primaryLimit = 20, totalLimit = 50)
SearchMode.FILENAME -> emptyList() // Handled above
SearchMode.TAGS -> emptyList() // Handled separately
}
val publishKeys = fetchPublishKeys(results.map { it.node.id })
results.map { searchResult ->
buildItem(
node = searchResult.node,
rank = searchResult.rank,
matchSource = searchResult.source,
// Raw FTS5 snippet with <hl> markers; the row composable
// parses markers into highlighted spans. Title-table matches
// carry no snippet — a highlight there would just repeat the title.
snippet = searchResult.snippet,
publishKeys = publishKeys
)
}
} catch (e: Exception) {
emptyList()
}
}
private suspend fun searchByFilename(query: String): List<SearchResultItem> {
return try {
val nodes = repository.searchNodesByFilePath(query)
val publishKeys = fetchPublishKeys(nodes.map { it.id })
nodes.map { node ->
buildItem(node = node, publishKeys = publishKeys)
}
} catch (e: Exception) {
emptyList()
}
}
private suspend fun searchByTags(tags: Set<String>, query: String): List<SearchResultItem> {
return try {
// Get all node IDs that have ALL selected tags
val nodeIdSets = tags.map { tag ->
repository.getNodesByTag(tag).toSet()
}
val matchingNodeIds = if (nodeIdSets.isEmpty()) {
emptySet()
} else {
nodeIdSets.reduce { acc, set -> acc.intersect(set) }
}
// Get node details and optionally filter by query
val nodes = matchingNodeIds.mapNotNull { nodeId ->
val node = repository.getNodeById(nodeId) ?: return@mapNotNull null
// If there's a query, filter by title match
if (query.isNotBlank() &&
!(node.title?.contains(query, ignoreCase = true) ?: false)) {
null
} else {
node
}
}
val publishKeys = fetchPublishKeys(nodes.map { it.id })
nodes.map { node ->
buildItem(node = node, publishKeys = publishKeys)
}.sortedBy { it.title.lowercase() }
} catch (e: Exception) {
emptyList()
}
}
private suspend fun loadRecentNodes(): List<SearchResultItem> {
return try {
// First, get pinned nodes
val pinnedNodeIds = repository.getNodesByPropertyKey(PINNED_PROPERTY_KEY)
.filter { it.second?.lowercase() == "t" || it.second?.lowercase() == "true" }
.map { it.first }
.toSet()
val pinnedNodes = pinnedNodeIds.mapNotNull { nodeId ->
repository.getNodeById(nodeId)
}.sortedBy { it.title?.lowercase() }
// Then get recent nodes, excluding pinned ones
val recentNodes = searchService.getRecentNodes(limit = 100)
.filter { it.node.id !in pinnedNodeIds }
.map { it.node }
val publishKeys = fetchPublishKeys((pinnedNodes + recentNodes).map { it.id })
val pinnedResults = pinnedNodes.map { node ->
buildItem(
node = node,
rank = Double.MAX_VALUE, // Highest rank to sort first
publishKeys = publishKeys
)
}
val recentResults = recentNodes.map { node ->
buildItem(node = node, rank = 0.0, publishKeys = publishKeys)
}
// Combine: pinned first, then recent
pinnedResults + recentResults
} catch (e: Exception) {
emptyList()
}
}
}SearchScreen Composable
SearchScreen renders the full search interface: search input bar with clear button, mode filter chips row (with result count), tag filter row when in TAGS mode, loading indicator, empty states (no directory, no results, no tags), and a LazyColumn of SearchResultCard items with long-press context menus.
It receives an optional SearchResultActions bundle from FindScreen for the cross-mode context menu (Center on Graph, Pin/Unpin).
State is collected from SearchViewModel via collectAsState(), and directory presence is checked from AppPreferences.
SearchScreen Source Code
The complete Kotlin source file containing the SearchScreen composable and its private composables: NoDirectoryEmptyState, TagFilterRow, SearchResultCard, and snippet-title helpers.
searchscreen-preamble
package computer.whatthefuck.arcology.app.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import computer.whatthefuck.arcology.app.data.AppPreferences
import computer.whatthefuck.arcology.app.ui.components.stripLinksToSourceOnly
import computer.whatthefuck.arcology.app.viewmodel.SearchMode
import computer.whatthefuck.arcology.app.viewmodel.SearchResultItem
import computer.whatthefuck.arcology.app.viewmodel.SearchViewModel
import computer.whatthefuck.arcology.app.viewmodel.TagWithCount
import computer.whatthefuck.arcology.search.SearchSource
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInjectsearchscreen-searchresultactions
/**
* Context menu actions for search results.
*/
data class SearchResultActions(
val onOpen: (nodeId: String) -> Unit,
val onCenterOnGraph: ((nodeId: String) -> Unit)? = null,
val onPin: ((nodeId: String) -> Unit)? = null,
val onUnpin: ((nodeId: String) -> Unit)? = null,
val isPinned: ((nodeId: String) -> Boolean)? = null
)searchscreen-composable
@Composable
fun SearchScreen(
viewModel: SearchViewModel = koinViewModel(),
onNodeSelected: (nodeId: String) -> Unit,
onNavigateToIndexing: (() -> Unit)? = null,
resultActions: SearchResultActions? = null
) {
val appPreferences: AppPreferences = koinInject()
val query by viewModel.query.collectAsState()
val results by viewModel.searchResults.collectAsState()
val isSearching by viewModel.isSearching.collectAsState()
val searchMode by viewModel.searchMode.collectAsState()
val selectedTags by viewModel.selectedTags.collectAsState()
val availableTags by viewModel.availableTags.collectAsState()
val hasDirectory = appPreferences.getSelectedDirectoryUri() != null
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
// Compact search bar with mode chips inline
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Search input
Surface(
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
BasicTextField(
value = query,
onValueChange = viewModel::updateQuery,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
textStyle = TextStyle(
fontSize = 16.sp,
color = MaterialTheme.colorScheme.onSurface
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true,
decorationBox = { innerTextField ->
Box {
if (query.isEmpty()) {
Text(
"Search...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp
)
}
innerTextField()
}
}
)
if (query.isNotEmpty()) {
IconButton(
onClick = { viewModel.updateQuery("") },
modifier = Modifier.size(20.dp)
) {
Icon(
Icons.Default.Clear,
contentDescription = "Clear",
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
// Mode chips - compact single row
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
SearchMode.entries.forEach { mode ->
FilterChip(
selected = searchMode == mode,
onClick = { viewModel.setSearchMode(mode) },
label = {
Text(
text = when (mode) {
SearchMode.COMBINED -> "All"
SearchMode.TITLE -> "Title"
SearchMode.CONTENT -> "Content"
SearchMode.FILENAME -> "File"
SearchMode.TAGS -> "Tags"
},
fontSize = 12.sp
)
},
modifier = Modifier.height(28.dp)
)
}
// Show result count inline
if (results.isNotEmpty()) {
Text(
text = "${results.size} results",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
// Tag filter row - only when in tag mode
if (searchMode == SearchMode.TAGS || selectedTags.isNotEmpty()) {
TagFilterRow(
availableTags = availableTags,
selectedTags = selectedTags,
onTagToggle = viewModel::toggleTag,
onClearAll = viewModel::clearSelectedTags
)
}
// Loading indicator
if (isSearching) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp)
)
}
// Empty state
if (results.isEmpty() && !isSearching) {
if (!hasDirectory && query.isEmpty() && selectedTags.isEmpty()) {
// No directory configured
NoDirectoryEmptyState(onNavigateToIndexing = onNavigateToIndexing)
} else {
Text(
text = when {
query.isEmpty() && selectedTags.isEmpty() -> "No notes indexed yet"
searchMode == SearchMode.TAGS && selectedTags.isEmpty() -> "Select tags"
else -> "No results"
},
modifier = Modifier.padding(top = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Build effective actions (merge with default open action)
val effectiveActions = remember(resultActions) {
resultActions ?: SearchResultActions(onOpen = onNodeSelected)
}
// Results list
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
items(results, key = { it.nodeId }) { result ->
SearchResultCard(
result = result,
actions = effectiveActions
)
}
}
}
}searchscreen-nodirectory
@Composable
private fun NoDirectoryEmptyState(
onNavigateToIndexing: (() -> Unit)?
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = Icons.Default.FolderOpen,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "No notes folder selected",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center
)
Text(
text = if (onNavigateToIndexing != null) {
"Select a folder containing your .org files to get started."
} else {
"Go to the Indexing tab to select a folder containing your .org files."
},
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (onNavigateToIndexing != null) {
Button(onClick = onNavigateToIndexing) {
Text("Set Up")
}
}
}
}searchscreen-tagfilter
@Composable
private fun TagFilterRow(
availableTags: List<TagWithCount>,
selectedTags: Set<String>,
onTagToggle: (String) -> Unit,
onClearAll: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (selectedTags.isNotEmpty()) {
TextButton(
onClick = onClearAll,
modifier = Modifier.height(28.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
) {
Text("Clear", fontSize = 12.sp)
}
}
availableTags.take(15).forEach { tagWithCount ->
FilterChip(
selected = tagWithCount.tag in selectedTags,
onClick = { onTagToggle(tagWithCount.tag) },
label = {
Text(
"${tagWithCount.tag} ${tagWithCount.count}",
fontSize = 11.sp
)
},
modifier = Modifier.height(26.dp)
)
}
}
}searchscreen-resultcard
/**
* Search result rendered as a compact Quiz-style card (16dp corners, low
* elevation). Layers, top to bottom: title + metadata badges, highlighted
* content snippet (when the body mentions the query), tag pills, and an
* Arcology publish key footer for published nodes. The file path is not
* shown — it's navigational noise on the card; FILENAME search still
* filters on it. Long-press opens the cross-mode context menu.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResultCard(
result: SearchResultItem,
actions: SearchResultActions
) {
var showContextMenu by remember { mutableStateOf(false) }
val isPinned = actions.isPinned?.invoke(result.nodeId) ?: false
Box {
Card(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = { actions.onOpen(result.nodeId) },
onLongClick = { showContextMenu = true }
),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
// Title with pin indicator and match-source badge
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = result.title.stripLinksToSourceOnly(),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (isPinned) {
Text(
text = "\u2605", // Star character
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(start = 4.dp)
)
}
result.matchSource?.let { source ->
Badge(
containerColor = when (source) {
SearchSource.TITLE_METADATA -> MaterialTheme.colorScheme.secondaryContainer
SearchSource.CONTENT -> MaterialTheme.colorScheme.tertiaryContainer
},
modifier = Modifier.padding(start = 6.dp)
) {
Text(
text = when (source) {
SearchSource.TITLE_METADATA -> "T"
SearchSource.CONTENT -> "C"
},
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
}
}
// Highlighted content snippet (FTS5 snippet window)
if (result.snippet != null) {
Text(
text = snippetAnnotatedString(result.snippet),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp)
)
}
// Tag pills (display-only)
if (result.tags.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
result.tags.take(3).forEach { tag ->
TagPill(tag)
}
if (result.tags.size > 3) {
Text(
text = "+${result.tags.size - 3}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Footer: Arcology publish state (or plain filename-derived
// nothing — unpublished cards end here, footer row collapses)
result.publishKey?.let { key ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\u26ac $key", // Globe marker + site/key route
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
// Context menu
DropdownMenu(
expanded = showContextMenu,
onDismissRequest = { showContextMenu = false },
offset = DpOffset(16.dp, 0.dp)
) {
DropdownMenuItem(
text = { Text("Open") },
onClick = {
showContextMenu = false
actions.onOpen(result.nodeId)
}
)
actions.onCenterOnGraph?.let { onCenter ->
DropdownMenuItem(
text = { Text("Center on Graph") },
onClick = {
showContextMenu = false
onCenter(result.nodeId)
}
)
}
if (isPinned) {
actions.onUnpin?.let { onUnpin ->
DropdownMenuItem(
text = { Text("Unpin") },
onClick = {
showContextMenu = false
onUnpin(result.nodeId)
}
)
}
} else {
actions.onPin?.let { onPin ->
DropdownMenuItem(
text = { Text("Pin to Graph") },
onClick = {
showContextMenu = false
onPin(result.nodeId)
}
)
}
}
}
}
}
/**
* Small non-interactive tag label — a rounded surface pill rather than a
* FilterChip, since tapping is not wired (tag filtering happens through the
* row above the results list).
*/
@Composable
private fun TagPill(tag: String) {
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Text(
text = tag,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
private const val SNIPPET_HL_START = "<hl>"
private const val SNIPPET_HL_END = "</hl>"
/**
* Convert an FTS5 snippet() window into an AnnotatedString with matched terms
* rendered bold in the primary color. Newlines collapse to spaces; missing or
* unbalanced <hl> markers degrade to plain text with markers stripped so a
* format change in the SQL layer can never leak raw markup into the UI.
*/
@Composable
private fun snippetAnnotatedString(snippet: String): AnnotatedString {
val highlightColor = MaterialTheme.colorScheme.primary
return remember(snippet, highlightColor) {
buildAnnotatedString {
val plain = snippet.replace('\n', ' ')
var i = 0
while (i <= plain.length) {
val start = plain.indexOf(SNIPPET_HL_START, i)
if (start == -1) {
append(plain.substring(i).replace(SNIPPET_HL_END, ""))
break
}
append(plain.substring(i, start))
val end = plain.indexOf(SNIPPET_HL_END, start + SNIPPET_HL_START.length)
if (end == -1) {
// Unbalanced open marker: strip and render remainder plain
append(
plain.substring(start + SNIPPET_HL_START.length)
.replace(SNIPPET_HL_END, "")
)
break
}
pushStyle(
SpanStyle(fontWeight = FontWeight.Bold, color = highlightColor)
)
append(plain.substring(start + SNIPPET_HL_START.length, end))
pop()
i = end + SNIPPET_HL_END.length
}
}
}
}SearchViewModel Source Code
searchviewmodel-preamble
package computer.whatthefuck.arcology.app.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.database.QuizRepository
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.search.SearchService
import computer.whatthefuck.arcology.search.SearchResult
import computer.whatthefuck.arcology.search.SearchSource
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
/**
* Heading/file property whose presence marks a node as published to an
* Arcology site at the given site/key route.
*/
const val PUBLISH_KEY_PROPERTY = "ARCOLOGY_KEY"SearchViewModel Assembly
<<searchviewmodel-preamble>>
<<search-screen-searchmode>>
<<search-screen-models>>
<<search-screen-viewmodel-state>>
<<search-screen-viewmodel-control>>
<<search-screen-viewmodel-backlinks>>
<<search-screen-viewmodel-enrichment>>
<<search-screen-viewmodel-search>>SearchScreen Assembly
<<searchscreen-preamble>>
<<searchscreen-searchresultactions>>
<<searchscreen-composable>>
<<searchscreen-nodirectory>>
<<searchscreen-tagfilter>>
<<searchscreen-resultcard>>Tests
SearchViewModelTest is a JUnit 4 test class using a StandardTestDispatcher for coroutine control, MockK for mocking SearchService and RoamRepository, and Kotest matchers for assertions.
The test strategy is unit-level: validate state flows react correctly to user actions (updateQuery, setSearchMode, toggleTag, clearSelectedTags, searchForTag, refresh), verify the debounced search pipeline produces expected results, and confirm that empty queries load recent nodes.
Test Double Pattern
MockK relaxed mocks (mockk(relaxed = true)) are used for both SearchService and RoamRepository. The setup block configures default returns (empty lists) that individual tests override with specific results via coEvery.
SearchService tests
SearchServiceTest is an integration-style test using DescribeSpec with a real RoamRepository (via DatabaseTestUtils), TestFileSystem, and FlowFileIndexer. It indexes sample org files, then verifies that FTS queries return ranked results (including combined search with content fallback). The empty-query tests validate that blank and whitespace-only inputs return empty.
It uses the Shared Test Fixtures.
package computer.whatthefuck.arcology.search
import computer.whatthefuck.arcology.database.DatabaseTestUtils
import computer.whatthefuck.arcology.fixtures.SampleOrgFiles
import computer.whatthefuck.arcology.indexer.FlowFileIndexer
import computer.whatthefuck.arcology.indexer.TestFileSystem
import computer.whatthefuck.arcology.parser.OrgFileParser
import io.kotest.core.spec.style.DescribeSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
class SearchServiceTest : DescribeSpec({
describe("SearchService") {
it("should perform basic FTS search on indexed data") {
val repository = DatabaseTestUtils.createTestRepository()
val quizRepository = DatabaseTestUtils.createTestQuizRepository()
val fileSystem = TestFileSystem()
val indexer = FlowFileIndexer(repository, fileSystem = fileSystem)
val searchService = SearchService(repository)
// Add test files with searchable content
fileSystem.addFile("/test/sample1.org", SampleOrgFiles.SIMPLE_ORG_CONTENT)
fileSystem.addFile("/test/sample2.org", SampleOrgFiles.COMPLEX_ORG_CONTENT)
// Index the files
indexer.indexDirectory("/test")
// Perform search
val results = searchService.searchPrimary("Simple")
// Verify results
results shouldHaveSize 1
results.first().node.title shouldBe "Simple Heading"
results.first().rank shouldNotBe 0.0
results.first().source shouldBe SearchSource.TITLE_METADATA
// Clean up
DatabaseTestUtils.clearDatabase(repository)
}
it("should handle combined search with content fallback") {
val repository = DatabaseTestUtils.createTestRepository()
val quizRepository = DatabaseTestUtils.createTestQuizRepository()
val fileSystem = TestFileSystem()
val indexer = FlowFileIndexer(repository, fileSystem = fileSystem)
val searchService = SearchService(repository)
// Add test files
fileSystem.addFile("/test/sample1.org", SampleOrgFiles.SIMPLE_ORG_CONTENT)
fileSystem.addFile("/test/sample2.org", SampleOrgFiles.COMPLEX_ORG_CONTENT)
// Index the files
indexer.indexDirectory("/test")
// Perform combined search - search for "Project" which is in COMPLEX_ORG_CONTENT
val results = searchService.searchCombined("Project")
// Should find results
results.size shouldBe 1
results.first().node.title shouldNotBe ""
// Snippet regression: every content-source match must carry an FTS5
// snippet window (and title matches whose body mentions the query
// get one via searchPrimary's side-query)
if (results.first().source == SearchSource.CONTENT) {
results.first().snippet shouldNotBe null
}
// Clean up
DatabaseTestUtils.clearDatabase(repository)
}
it("should attach content snippets to title-matched nodes") {
val repository = DatabaseTestUtils.createTestRepository()
val quizRepository = DatabaseTestUtils.createTestQuizRepository()
val fileSystem = TestFileSystem()
val indexer = FlowFileIndexer(repository, fileSystem = fileSystem)
val searchService = SearchService(repository)
// A node whose body mentions the query; the same word appears in the title
fileSystem.addFile(
"/test/snippets.org",
"""
#+TITLE: Snippet Test File
* Research Phase
:PROPERTIES:
:ID: research-snippet-id
:END:
The research project is about archeology and the arcology project planning.
""".trimIndent()
)
indexer.indexDirectory("/test")
// Title contains "Research"; body mentions "research" too
val results = searchService.searchPrimary("Research")
results.size shouldBe 1
results.first().source shouldBe SearchSource.TITLE_METADATA
// The content-table MATCH also hit this node, so the snippet is filled
results.first().snippet shouldNotBe null
results.first().snippet shouldNotBe ""
// markers ride through to the UI layer intact
(results.first().snippet ?: "").contains("research") shouldBe true
// Clean up
DatabaseTestUtils.clearDatabase(repository)
}
it("should handle empty queries gracefully") {
val repository = DatabaseTestUtils.createTestRepository()
val searchService = SearchService(repository)
// Test various empty query formats
searchService.searchPrimary("") shouldHaveSize 0
searchService.searchPrimary(" ") shouldHaveSize 0
searchService.searchCombined("") shouldHaveSize 0
searchService.searchSimple("") shouldHaveSize 0
// Clean up
DatabaseTestUtils.clearDatabase(repository)
}
it("should perform simple search without ranking") {
val repository = DatabaseTestUtils.createTestRepository()
val quizRepository = DatabaseTestUtils.createTestQuizRepository()
val fileSystem = TestFileSystem()
val indexer = FlowFileIndexer(repository, fileSystem = fileSystem)
val searchService = SearchService(repository)
// Add test files
fileSystem.addFile("/test/sample1.org", SampleOrgFiles.SIMPLE_ORG_CONTENT)
// Index the files
indexer.indexDirectory("/test")
// Perform simple search
val results = searchService.searchSimple("Simple")
// Verify results
results shouldHaveSize 1
results.first().title shouldBe "Simple Heading"
// Clean up
DatabaseTestUtils.clearDatabase(repository)
}
}
})SearchViewModel tests
package computer.whatthefuck.arcology.app.viewmodel
import computer.whatthefuck.arcology.database.RoamRepository
import computer.whatthefuck.arcology.database.QuizRepository
import computer.whatthefuck.arcology.domain.OrgNode
import computer.whatthefuck.arcology.search.SearchResult
import computer.whatthefuck.arcology.search.SearchService
import computer.whatthefuck.arcology.search.SearchSource
import io.kotest.matchers.shouldBe
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SearchViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var searchService: SearchService
private lateinit var repository: RoamRepository
private lateinit var quizRepository: QuizRepository
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
searchService = mockk(relaxed = true)
repository = mockk(relaxed = true)
quizRepository = mockk(relaxed = true)
coEvery { quizRepository.getTagsForFlashcardNodesWithCount() } returns emptyList()
coEvery { repository.getNodesByPropertyKey(any()) } returns emptyList()
coEvery { searchService.getRecentNodes(any()) } returns emptyList()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `initial query is empty`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.query.value shouldBe ""
}
@Test
fun `initial search mode is COMBINED`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.searchMode.value shouldBe SearchMode.COMBINED
}
@Test
fun `initial selected tags is empty`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.selectedTags.value shouldBe emptySet()
}
@Test
fun `updateQuery updates query state`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.updateQuery("test query")
viewModel.query.value shouldBe "test query"
}
@Test
fun `setSearchMode updates mode`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.setSearchMode(SearchMode.TITLE)
viewModel.searchMode.value shouldBe SearchMode.TITLE
}
@Test
fun `toggleTag adds tag when not present`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.toggleTag("project")
viewModel.selectedTags.value shouldBe setOf("project")
}
@Test
fun `toggleTag removes tag when already present`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.toggleTag("project")
viewModel.toggleTag("project")
viewModel.selectedTags.value shouldBe emptySet()
}
@Test
fun `toggleTag accumulates multiple tags`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.toggleTag("project")
viewModel.toggleTag("work")
viewModel.selectedTags.value shouldBe setOf("project", "work")
}
@Test
fun `clearSelectedTags empties the set`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.toggleTag("project")
viewModel.toggleTag("work")
viewModel.clearSelectedTags()
viewModel.selectedTags.value shouldBe emptySet()
}
@Test
fun `searchForTag sets mode to TAGS and selects single tag`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.searchForTag("important")
viewModel.searchMode.value shouldBe SearchMode.TAGS
viewModel.selectedTags.value shouldBe setOf("important")
}
@Test
fun `searchForTag replaces previously selected tags`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
viewModel.toggleTag("old-tag")
viewModel.searchForTag("new-tag")
viewModel.selectedTags.value shouldBe setOf("new-tag")
}
@Test
fun `searchResults returns results after debounce`() = runTest {
val mockNode = OrgNode(
id = "test-id",
file = "/test.org",
level = 1,
position = 0,
title = "Test Node"
)
coEvery {
searchService.searchCombined(any(), any(), any())
} returns listOf(
SearchResult(mockNode, 1.0, SearchSource.TITLE_METADATA)
)
coEvery { repository.getTagsByNode("test-id") } returns listOf("tag1")
val viewModel = SearchViewModel(searchService, repository, quizRepository)
advanceUntilIdle() // let init complete
viewModel.updateQuery("test")
advanceTimeBy(350) // past 300ms debounce
advanceUntilIdle()
val results = viewModel.searchResults.value
results shouldHaveSize 1
results.first().title shouldBe "Test Node"
results.first().tags shouldBe listOf("tag1")
}
@Test
fun `empty query loads recent nodes`() = runTest {
val recentNode = OrgNode(
id = "recent-id",
file = "/recent.org",
level = 1,
position = 0,
title = "Recent Node"
)
coEvery { searchService.getRecentNodes(any()) } returns listOf(
SearchResult(recentNode, 0.0, SearchSource.TITLE_METADATA)
)
coEvery { repository.getTagsByNode("recent-id") } returns emptyList()
val viewModel = SearchViewModel(searchService, repository, quizRepository)
advanceTimeBy(350)
advanceUntilIdle()
val results = viewModel.searchResults.value
results shouldHaveSize 1
results.first().nodeId shouldBe "recent-id"
}
@Test
fun `refresh increments trigger and reloads tags`() = runTest {
coEvery { quizRepository.getTagsForFlashcardNodesWithCount() } returns listOf("project" to 5L)
val viewModel = SearchViewModel(searchService, repository, quizRepository)
advanceUntilIdle()
viewModel.refresh()
advanceUntilIdle()
viewModel.availableTags.value shouldHaveSize 1
viewModel.availableTags.value.first().tag shouldBe "project"
viewModel.availableTags.value.first().count shouldBe 5L
}
@Test
fun `searchResults carries snippet from content matches`() = runTest {
val mockNode = OrgNode(
id = "content-id",
file = "/content.org",
level = 1,
position = 0,
title = "Content Node"
)
coEvery {
searchService.searchCombined(any(), any(), any())
} returns listOf(
SearchResult(
mockNode,
1.0,
SearchSource.CONTENT,
snippet = "some text with <hl>needle</hl> inside"
)
)
coEvery { repository.getTagsByNode("content-id") } returns emptyList()
val viewModel = SearchViewModel(searchService, repository, quizRepository)
advanceUntilIdle()
viewModel.updateQuery("needle")
advanceTimeBy(350)
advanceUntilIdle()
val results = viewModel.searchResults.value
results shouldHaveSize 1
results.first().snippet shouldBe "some text with <hl>needle</hl> inside"
}
@Test
fun `searchResults carries publish key from heading property`() = runTest {
val mockNode = OrgNode(
id = "pub-id",
file = "/published.org",
level = 1,
position = 0,
title = "Published Node"
)
coEvery { searchService.searchCombined(any(), any(), any()) } returns listOf(
SearchResult(mockNode, 1.0, SearchSource.TITLE_METADATA)
)
coEvery { repository.getTagsByNode("pub-id") } returns emptyList()
coEvery {
repository.getNodesByPropertyKey("ARCOLOGY_KEY")
} returns listOf("pub-id" to "garden/my-page")
val viewModel = SearchViewModel(searchService, repository, quizRepository)
advanceUntilIdle()
viewModel.updateQuery("anything")
advanceTimeBy(350)
advanceUntilIdle()
val results = viewModel.searchResults.value
results shouldHaveSize 1
results.first().publishKey shouldBe "garden/my-page"
}
@Test
fun `publish key falls back to file properties when heading key missing`() = runTest {
val mockNode = OrgNode(
id = "file-key-id",
file = "/preamble-published.org",
level = 1,
position = 0,
title = "Preamble Published Node"
)
coEvery { searchService.searchCombined(any(), any(), any()) } returns listOf(
SearchResult(mockNode, 1.0, SearchSource.TITLE_METADATA)
)
coEvery { repository.getTagsByNode("file-key-id") } returns emptyList()
coEvery { repository.getNodesByPropertyKey("ARCOLOGY_KEY") } returns emptyList()
// Node lookup must succeed for the file-path fallback
coEvery { repository.getNodeById("file-key-id") } returns mockNode
coEvery {
repository.getFileProperties("/preamble-published.org")
} returns mapOf("ARCOLOGY_KEY" to "lionsrear/recipes")
val viewModel = SearchViewModel(searchService, repository, quizRepository)
advanceUntilIdle()
viewModel.updateQuery("anything")
advanceTimeBy(350)
advanceUntilIdle()
val results = viewModel.searchResults.value
results shouldHaveSize 1
results.first().publishKey shouldBe "lionsrear/recipes"
}
@Test
fun `publish key is null when node and file carry no ARCOLOGY_KEY`() = runTest {
val mockNode = OrgNode(
id = "private-id",
file = "/private.org",
level = 1,
position = 0,
title = "Private Node"
)
coEvery { searchService.searchCombined(any(), any(), any()) } returns listOf(
SearchResult(mockNode, 1.0, SearchSource.TITLE_METADATA)
)
coEvery { repository.getTagsByNode("private-id") } returns emptyList()
coEvery { repository.getNodesByPropertyKey("ARCOLOGY_KEY") } returns emptyList()
coEvery { repository.getNodeById("private-id") } returns mockNode
coEvery { repository.getFileProperties("/private.org") } returns emptyMap()
val viewModel = SearchViewModel(searchService, repository, quizRepository)
advanceUntilIdle()
viewModel.updateQuery("anything")
advanceTimeBy(350)
advanceUntilIdle()
val results = viewModel.searchResults.value
results shouldHaveSize 1
results.first().publishKey shouldBe null
}
@Test
fun `search mode cycles through all modes`() = runTest {
val viewModel = SearchViewModel(searchService, repository, quizRepository)
SearchMode.entries.forEach { mode ->
viewModel.setSearchMode(mode)
viewModel.searchMode.value shouldBe mode
}
}
}Related Modules
App Bootstrap — Koin DI provides
SearchViewModelfactory withSearchServiceandRoamRepositoryApp Data Layer —
AppPreferencesfor directory state checkGraph Screen — context menu actions (Center on Graph, Pin/Unpin) wire into
GraphViewModelIndexer —
SearchServicebacked by FTS indexes created during indexingModels & Repository —
RoamRepositoryfor tag/node/FTS queries consumed bySearchService