From f418790c378b3cc621c18dc14ab0f20621540597 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 07:23:53 +0000 Subject: [PATCH 01/11] =?UTF-8?q?feat(056):=20search=20&=20filter=20UX=20?= =?UTF-8?q?=E2=80=94=20useDebounce,=20SearchBar,=20FilterChip,=20EmptyStat?= =?UTF-8?q?e;=20debounced=20search,=20filter=20chips,=20result=20count,=20?= =?UTF-8?q?empty=20states,=20URL=20query=20sync=20across=204=20views;=2021?= =?UTF-8?q?=20new=20Vitest=20tests=20(131=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/devstress/My3DLearning/sessions/07758158-53bd-470f-accd-c438110dc18c Co-authored-by: devstress <30769729+devstress@users.noreply.github.com> --- .../__tests__/components/EmptyState.spec.ts | 38 ++++++++++++ .../__tests__/components/FilterChip.spec.ts | 36 +++++++++++ .../__tests__/components/SearchBar.spec.ts | 48 +++++++++++++++ .../__tests__/composables/useDebounce.spec.ts | 60 +++++++++++++++++++ .../src/Web.Vue/src/components/EmptyState.vue | 43 +++++++++++++ .../src/Web.Vue/src/components/FilterChip.vue | 34 +++++++++++ .../src/Web.Vue/src/components/SearchBar.vue | 33 ++++++++++ .../Web.Vue/src/composables/useDebounce.ts | 15 +++++ .../src/Web.Vue/src/views/HomeModelsView.vue | 48 +++++++++++---- .../src/Web.Vue/src/views/LandBlocksView.vue | 49 +++++++++++---- .../src/Web.Vue/src/views/MarketplaceView.vue | 59 ++++++++++++++---- .../src/Web.Vue/src/views/VillagesView.vue | 47 +++++++++++---- 12 files changed, 466 insertions(+), 44 deletions(-) create mode 100644 Terranes/src/Web.Vue/src/__tests__/components/EmptyState.spec.ts create mode 100644 Terranes/src/Web.Vue/src/__tests__/components/FilterChip.spec.ts create mode 100644 Terranes/src/Web.Vue/src/__tests__/components/SearchBar.spec.ts create mode 100644 Terranes/src/Web.Vue/src/__tests__/composables/useDebounce.spec.ts create mode 100644 Terranes/src/Web.Vue/src/components/EmptyState.vue create mode 100644 Terranes/src/Web.Vue/src/components/FilterChip.vue create mode 100644 Terranes/src/Web.Vue/src/components/SearchBar.vue create mode 100644 Terranes/src/Web.Vue/src/composables/useDebounce.ts diff --git a/Terranes/src/Web.Vue/src/__tests__/components/EmptyState.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/EmptyState.spec.ts new file mode 100644 index 0000000..2b61fdd --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/EmptyState.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import EmptyState from '../../components/EmptyState.vue'; + +describe('EmptyState', () => { + it('renders default title and message', () => { + const wrapper = mount(EmptyState); + expect(wrapper.text()).toContain('No results found'); + expect(wrapper.text()).toContain('Try adjusting your search or filters.'); + }); + + it('renders custom title and message', () => { + const wrapper = mount(EmptyState, { + props: { title: 'No villages', message: 'Create one!' }, + }); + expect(wrapper.text()).toContain('No villages'); + expect(wrapper.text()).toContain('Create one!'); + }); + + it('renders SVG icon', () => { + const wrapper = mount(EmptyState, { props: { icon: 'village' } }); + expect(wrapper.find('svg').exists()).toBe(true); + expect(wrapper.find('svg').attributes('aria-hidden')).toBe('true'); + }); + + it('has empty-state class for styling', () => { + const wrapper = mount(EmptyState); + expect(wrapper.find('.empty-state').exists()).toBe(true); + }); + + it('renders different icon paths for each type', () => { + const searchWrapper = mount(EmptyState, { props: { icon: 'search' } }); + const landWrapper = mount(EmptyState, { props: { icon: 'land' } }); + const searchPath = searchWrapper.find('path').attributes('d'); + const landPath = landWrapper.find('path').attributes('d'); + expect(searchPath).not.toBe(landPath); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/FilterChip.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/FilterChip.spec.ts new file mode 100644 index 0000000..46c0eaa --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/FilterChip.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import FilterChip from '../../components/FilterChip.vue'; + +describe('FilterChip', () => { + it('renders label and value', () => { + const wrapper = mount(FilterChip, { + props: { label: 'Status', value: 'Active' }, + }); + expect(wrapper.text()).toContain('Status'); + expect(wrapper.text()).toContain('Active'); + }); + + it('emits remove on close button click', async () => { + const wrapper = mount(FilterChip, { + props: { label: 'Status', value: 'Active' }, + }); + await wrapper.find('.btn-close').trigger('click'); + expect(wrapper.emitted('remove')).toBeTruthy(); + }); + + it('has correct aria-label on close button', () => { + const wrapper = mount(FilterChip, { + props: { label: 'Layout', value: 'Grid' }, + }); + expect(wrapper.find('.btn-close').attributes('aria-label')).toBe('Remove Layout filter'); + }); + + it('uses badge styling', () => { + const wrapper = mount(FilterChip, { + props: { label: 'Name', value: 'Test' }, + }); + expect(wrapper.find('.badge').exists()).toBe(true); + expect(wrapper.find('.filter-chip').exists()).toBe(true); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/components/SearchBar.spec.ts b/Terranes/src/Web.Vue/src/__tests__/components/SearchBar.spec.ts new file mode 100644 index 0000000..60a43e3 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/components/SearchBar.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import SearchBar from '../../components/SearchBar.vue'; + +describe('SearchBar', () => { + it('renders search input with placeholder', () => { + const wrapper = mount(SearchBar, { + props: { modelValue: '', placeholder: 'Search villages...' }, + }); + const input = wrapper.find('input'); + expect(input.exists()).toBe(true); + expect(input.attributes('placeholder')).toBe('Search villages...'); + }); + + it('renders search icon', () => { + const wrapper = mount(SearchBar, { props: { modelValue: '' } }); + expect(wrapper.find('.input-group-text').text()).toBe('๐Ÿ”'); + }); + + it('emits update:modelValue on input', async () => { + const wrapper = mount(SearchBar, { props: { modelValue: '' } }); + await wrapper.find('input').setValue('test'); + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test']); + }); + + it('shows clear button when value is non-empty', () => { + const wrapper = mount(SearchBar, { props: { modelValue: 'hello' } }); + const clearBtn = wrapper.find('button[aria-label="Clear search"]'); + expect(clearBtn.exists()).toBe(true); + }); + + it('hides clear button when value is empty', () => { + const wrapper = mount(SearchBar, { props: { modelValue: '' } }); + const clearBtn = wrapper.find('button[aria-label="Clear search"]'); + expect(clearBtn.exists()).toBe(false); + }); + + it('emits empty string on clear button click', async () => { + const wrapper = mount(SearchBar, { props: { modelValue: 'hello' } }); + await wrapper.find('button[aria-label="Clear search"]').trigger('click'); + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['']); + }); + + it('has aria-label on input', () => { + const wrapper = mount(SearchBar, { props: { modelValue: '' } }); + expect(wrapper.find('input').attributes('aria-label')).toBe('Search'); + }); +}); diff --git a/Terranes/src/Web.Vue/src/__tests__/composables/useDebounce.spec.ts b/Terranes/src/Web.Vue/src/__tests__/composables/useDebounce.spec.ts new file mode 100644 index 0000000..9358529 --- /dev/null +++ b/Terranes/src/Web.Vue/src/__tests__/composables/useDebounce.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ref, nextTick } from 'vue'; +import { useDebounce } from '../../composables/useDebounce'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns initial value immediately', () => { + const source = ref('hello'); + const debounced = useDebounce(source, 300); + expect(debounced.value).toBe('hello'); + }); + + it('does not update before delay', async () => { + const source = ref('a'); + const debounced = useDebounce(source, 300); + source.value = 'b'; + await nextTick(); + vi.advanceTimersByTime(200); + expect(debounced.value).toBe('a'); + }); + + it('updates after delay', async () => { + const source = ref('a'); + const debounced = useDebounce(source, 300); + source.value = 'b'; + await nextTick(); + vi.advanceTimersByTime(300); + expect(debounced.value).toBe('b'); + }); + + it('resets timer on rapid changes', async () => { + const source = ref('a'); + const debounced = useDebounce(source, 300); + source.value = 'b'; + await nextTick(); + vi.advanceTimersByTime(200); + source.value = 'c'; + await nextTick(); + vi.advanceTimersByTime(200); + expect(debounced.value).toBe('a'); + vi.advanceTimersByTime(100); + expect(debounced.value).toBe('c'); + }); + + it('works with number values', async () => { + const source = ref(0); + const debounced = useDebounce(source, 100); + source.value = 42; + await nextTick(); + vi.advanceTimersByTime(100); + expect(debounced.value).toBe(42); + }); +}); diff --git a/Terranes/src/Web.Vue/src/components/EmptyState.vue b/Terranes/src/Web.Vue/src/components/EmptyState.vue new file mode 100644 index 0000000..c8da6f4 --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/EmptyState.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/Terranes/src/Web.Vue/src/components/FilterChip.vue b/Terranes/src/Web.Vue/src/components/FilterChip.vue new file mode 100644 index 0000000..922206e --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/FilterChip.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/Terranes/src/Web.Vue/src/components/SearchBar.vue b/Terranes/src/Web.Vue/src/components/SearchBar.vue new file mode 100644 index 0000000..be13dba --- /dev/null +++ b/Terranes/src/Web.Vue/src/components/SearchBar.vue @@ -0,0 +1,33 @@ + + + diff --git a/Terranes/src/Web.Vue/src/composables/useDebounce.ts b/Terranes/src/Web.Vue/src/composables/useDebounce.ts new file mode 100644 index 0000000..549d5e8 --- /dev/null +++ b/Terranes/src/Web.Vue/src/composables/useDebounce.ts @@ -0,0 +1,15 @@ +import { ref, watch, type Ref } from 'vue'; + +export function useDebounce(source: Ref, delayMs = 300): Ref { + const debounced = ref(source.value) as Ref; + let timeout: ReturnType | null = null; + + watch(source, (val) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + debounced.value = val; + }, delayMs); + }); + + return debounced; +} diff --git a/Terranes/src/Web.Vue/src/views/HomeModelsView.vue b/Terranes/src/Web.Vue/src/views/HomeModelsView.vue index 356da5e..c1e1ae9 100644 --- a/Terranes/src/Web.Vue/src/views/HomeModelsView.vue +++ b/Terranes/src/Web.Vue/src/views/HomeModelsView.vue @@ -1,24 +1,46 @@