Update progress documentation to reflect completed SearchBar component and additional passing tests

This commit is contained in:
2026-01-29 03:51:06 +00:00
parent e98cb05b14
commit f30c7880f6
3 changed files with 370 additions and 10 deletions

View File

@@ -0,0 +1,206 @@
<template>
<div class="search-bar">
<div class="search-input-wrapper">
<input
ref="searchInput"
type="text"
v-model="searchQuery"
placeholder="Search in file... (Ctrl+F)"
class="search-input"
:disabled="isDisabled"
/>
<button
v-if="searchQuery"
@click="clearSearch"
class="btn-clear"
title="Clear search"
:disabled="isDisabled"
>
</button>
</div>
<div v-if="hasSearchResults" class="search-results">
<span :title="currentResultLineTitle">
{{ resultCountDisplay }}
<template v-if="currentResultLineNumber">
(Line {{ currentResultLineNumber }})
</template>
</span>
<button
@click="goToPrevResult"
class="btn-nav"
:disabled="!hasSearchResults"
title="Previous result (Shift+Ctrl+G)"
>
</button>
<button
@click="goToNextResult"
class="btn-nav"
:disabled="!hasSearchResults"
title="Next result (Ctrl+G)"
>
</button>
</div>
<div v-if="searchHistory.history.value.length > 0" class="search-history">
<button
v-for="(item, index) in searchHistory.history.value"
:key="index"
@click="applyHistoryItem(item)"
class="history-item"
>
{{ item }}
</button>
</div>
<div v-if="searchError" class="search-error">
{{ searchError }}
</div>
<div v-if="isSearching" class="search-status">
Searching...
</div>
</div>
</template>
<script setup>
import { computed, toRef } from 'vue';
import { useGamemasterSearch } from '../../composables/useGamemasterSearch.js';
const props = defineProps({
fileLines: {
type: Array,
default: () => []
},
displayLines: {
type: Array,
default: () => []
},
disabled: {
type: Boolean,
default: false
}
});
const fileLines = toRef(props, 'fileLines');
const displayLines = toRef(props, 'displayLines');
const {
searchQuery,
searchResults,
currentResultIndex,
isSearching,
searchError,
searchHistory,
clearSearch,
goToNextResult,
goToPrevResult,
applyHistoryItem,
currentResultLineNumber,
resultCountDisplay,
hasSearchResults
} = useGamemasterSearch(fileLines, displayLines);
const isDisabled = computed(() => props.disabled || fileLines.value.length === 0);
const currentResultLineTitle = computed(() => {
if (!currentResultLineNumber.value) return '';
return `Line ${currentResultLineNumber.value}`;
});
</script>
<style scoped>
.search-bar {
display: flex;
flex-direction: column;
gap: 10px;
margin: 15px 0;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-input {
width: 100%;
padding: 8px 35px 8px 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
background: white;
}
.search-input:disabled {
background: #f5f5f5;
color: #999;
}
.btn-clear {
position: absolute;
right: 8px;
background: none;
border: none;
cursor: pointer;
font-size: 16px;
color: #666;
padding: 4px;
}
.btn-clear:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.search-results {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
}
.btn-nav {
background: #f5f5f5;
border: 1px solid #ddd;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.btn-nav:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.search-history {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.history-item {
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 12px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
color: #555;
}
.search-error {
color: #d9534f;
font-size: 13px;
}
.search-status {
color: #666;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,154 @@
/**
* SearchBar Component Tests
* Verifies search UI rendering and interactions
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import SearchBar from '../../../../src/components/gamemaster/SearchBar.vue';
import { useGamemasterSearch } from '../../../../src/composables/useGamemasterSearch.js';
vi.mock('../../../../src/composables/useGamemasterSearch.js', () => ({
useGamemasterSearch: vi.fn()
}));
const createSearchMock = overrides => ({
searchQuery: ref(''),
searchResults: ref([]),
currentResultIndex: ref(0),
isSearching: ref(false),
searchError: ref(null),
searchHistory: { history: ref([]) },
clearSearch: vi.fn(),
goToNextResult: vi.fn(),
goToPrevResult: vi.fn(),
applyHistoryItem: vi.fn(),
currentResultLineNumber: ref(null),
resultCountDisplay: ref('0 results'),
hasSearchResults: ref(false),
...overrides
});
describe('SearchBar Component', () => {
let mockSearch;
beforeEach(() => {
mockSearch = createSearchMock();
useGamemasterSearch.mockReturnValue(mockSearch);
});
it('renders the search input', () => {
const wrapper = mount(SearchBar, {
props: {
fileLines: ['line one'],
displayLines: []
}
});
const input = wrapper.find('input.search-input');
expect(input.exists()).toBe(true);
expect(input.attributes('placeholder')).toContain('Search in file');
});
it('disables input when fileLines are empty', () => {
const wrapper = mount(SearchBar, {
props: {
fileLines: [],
displayLines: []
}
});
const input = wrapper.find('input.search-input');
expect(input.attributes('disabled')).toBeDefined();
});
it('shows clear button when searchQuery is not empty', async () => {
mockSearch.searchQuery.value = 'Pikachu';
const wrapper = mount(SearchBar, {
props: {
fileLines: ['line one'],
displayLines: []
}
});
const clearButton = wrapper.find('button.btn-clear');
expect(clearButton.exists()).toBe(true);
await clearButton.trigger('click');
expect(mockSearch.clearSearch).toHaveBeenCalled();
});
it('renders search results summary and line number', () => {
mockSearch.hasSearchResults.value = true;
mockSearch.resultCountDisplay.value = '2 / 5';
mockSearch.currentResultLineNumber.value = 42;
const wrapper = mount(SearchBar, {
props: {
fileLines: ['line one'],
displayLines: []
}
});
const results = wrapper.find('.search-results');
expect(results.exists()).toBe(true);
expect(results.text()).toContain('2 / 5');
expect(results.text()).toContain('Line 42');
const titleSpan = results.find('span');
expect(titleSpan.attributes('title')).toBe('Line 42');
});
it('triggers next/previous navigation', async () => {
mockSearch.hasSearchResults.value = true;
const wrapper = mount(SearchBar, {
props: {
fileLines: ['line one'],
displayLines: []
}
});
const buttons = wrapper.findAll('button.btn-nav');
expect(buttons.length).toBe(2);
await buttons[0].trigger('click');
await buttons[1].trigger('click');
expect(mockSearch.goToPrevResult).toHaveBeenCalled();
expect(mockSearch.goToNextResult).toHaveBeenCalled();
});
it('renders search history items and applies selection', async () => {
mockSearch.searchHistory.history.value = ['Pikachu', 'Charizard'];
const wrapper = mount(SearchBar, {
props: {
fileLines: ['line one'],
displayLines: []
}
});
const historyButtons = wrapper.findAll('button.history-item');
expect(historyButtons.length).toBe(2);
await historyButtons[0].trigger('click');
expect(mockSearch.applyHistoryItem).toHaveBeenCalledWith('Pikachu');
});
it('shows error and searching status', () => {
mockSearch.searchError.value = 'Worker failed';
mockSearch.isSearching.value = true;
const wrapper = mount(SearchBar, {
props: {
fileLines: ['line one'],
displayLines: []
}
});
expect(wrapper.find('.search-error').text()).toContain('Worker failed');
expect(wrapper.find('.search-status').text()).toContain('Searching');
});
});