diff --git a/src/modules/core/stores/core.store.js b/src/modules/core/stores/core.store.js index 61986a42b..fe312d6f3 100644 --- a/src/modules/core/stores/core.store.js +++ b/src/modules/core/stores/core.store.js @@ -50,7 +50,8 @@ export const useCoreStore = defineStore('core', { */ refreshNav(isLoggedIn) { const visible = pickBy(this.routes, (i) => { - if (i.meta.display !== false && i.meta.icon) { + const moduleDisplay = config.modules?.[i.name]?.display; + if (moduleDisplay !== false && i.meta.display !== false && i.meta.icon) { if (!('action' in i.meta)) return i; // no guard, always displayed if (isLoggedIn && ability.can(i.meta.action, i.meta.subject)) return i; } diff --git a/src/modules/core/tests/core.store.unit.tests.js b/src/modules/core/tests/core.store.unit.tests.js index 2ab4126f3..109a3d147 100644 --- a/src/modules/core/tests/core.store.unit.tests.js +++ b/src/modules/core/tests/core.store.unit.tests.js @@ -7,6 +7,13 @@ vi.mock('../../../lib/helpers/ability', () => ({ ability: mockAbility, })); +// Mutable config mock — mutate mockConfig.modules between tests to exercise T3 config-driven nav filter +const mockConfig = vi.hoisted(() => ({ + vuetify: { theme: { dark: false } }, + modules: {}, +})); +vi.mock('../../../lib/services/config', () => ({ default: mockConfig })); + import { useCoreStore } from '../stores/core.store'; describe('Core Store', () => { @@ -457,4 +464,65 @@ describe('Core Store', () => { expect(coreStore.nav.map((r) => r.name)).toEqual(['zero', 'ten']); }); }); + + describe('refreshNav — config.modules display override (T3)', () => { + beforeEach(() => { + // Reset modules config between tests + mockConfig.modules = {}; + }); + + it('filters out a route when config.modules[name].display is false even if meta.display is undefined', () => { + const coreStore = useCoreStore(); + mockConfig.modules = { tasks: { display: false } }; + const mockRoutes = [ + { path: '/', name: 'home', meta: { display: true, icon: 'fa-solid fa-house' } }, + { path: '/tasks', name: 'tasks', meta: { icon: 'fa-solid fa-check' } }, // meta.display undefined + ]; + + coreStore.init(mockRoutes); + coreStore.refreshNav(false); + + expect(coreStore.nav.find((r) => r.name === 'tasks')).toBeUndefined(); + expect(coreStore.nav.find((r) => r.name === 'home')).toBeDefined(); + }); + + it('shows a route when config.modules[name] is absent (no override)', () => { + const coreStore = useCoreStore(); + mockConfig.modules = {}; + const mockRoutes = [ + { path: '/tasks', name: 'tasks', meta: { display: true, icon: 'fa-solid fa-check' } }, + ]; + + coreStore.init(mockRoutes); + coreStore.refreshNav(false); + + expect(coreStore.nav.find((r) => r.name === 'tasks')).toBeDefined(); + }); + + it('shows a route when config.modules[name].display is true', () => { + const coreStore = useCoreStore(); + mockConfig.modules = { tasks: { display: true } }; + const mockRoutes = [ + { path: '/tasks', name: 'tasks', meta: { display: true, icon: 'fa-solid fa-check' } }, + ]; + + coreStore.init(mockRoutes); + coreStore.refreshNav(false); + + expect(coreStore.nav.find((r) => r.name === 'tasks')).toBeDefined(); + }); + + it('config.modules display:false takes priority over meta.display:true', () => { + const coreStore = useCoreStore(); + mockConfig.modules = { tasks: { display: false } }; + const mockRoutes = [ + { path: '/tasks', name: 'tasks', meta: { display: true, icon: 'fa-solid fa-check' } }, + ]; + + coreStore.init(mockRoutes); + coreStore.refreshNav(false); + + expect(coreStore.nav.find((r) => r.name === 'tasks')).toBeUndefined(); + }); + }); }); diff --git a/src/modules/home/components/home.statistics.component.vue b/src/modules/home/components/home.statistics.component.vue index 168516726..02630892b 100644 --- a/src/modules/home/components/home.statistics.component.vue +++ b/src/modules/home/components/home.statistics.component.vue @@ -50,6 +50,7 @@ :animation-speed="animationSpeed" :background-colors="backgroundColors" :halo-colors="haloColors" + :halo-count="haloCount" no-margin > @@ -144,6 +145,12 @@ export default { type: Array, default: null, }, + // For blur variant - number of animated halos (1..5). Lower = cheaper. + haloCount: { + type: Number, + default: 4, + validator: (v) => v >= 1 && v <= 5, + }, // Overlap: slides section up into previous section overlap: { type: [Boolean, String, Object], diff --git a/src/modules/home/components/utils/home.blur.background.component.vue b/src/modules/home/components/utils/home.blur.background.component.vue index 5675b39c9..fee2d14e5 100644 --- a/src/modules/home/components/utils/home.blur.background.component.vue +++ b/src/modules/home/components/utils/home.blur.background.component.vue @@ -48,10 +48,10 @@
-
-
-
-
+
+
+
+
@@ -91,6 +91,12 @@ export default { type: Array, default: null, }, + // Number of animated halos to render (1..5). Lower = cheaper. + haloCount: { + type: Number, + default: 5, + validator: (v) => v >= 1 && v <= 5, + }, // Remove top margin (for non-banner usage like stats) noMargin: { type: Boolean, diff --git a/src/modules/home/tests/home.statistics.component.unit.tests.js b/src/modules/home/tests/home.statistics.component.unit.tests.js index e18a58cd3..10be0de41 100644 --- a/src/modules/home/tests/home.statistics.component.unit.tests.js +++ b/src/modules/home/tests/home.statistics.component.unit.tests.js @@ -10,7 +10,7 @@ vi.mock('../components/utils/home.blur.background.component.vue', () => ({ default: { name: 'HomeBlurBackgroundComponent', template: '
', - props: ['ratio', 'animationSpeed', 'backgroundColors', 'haloColors', 'noMargin'], + props: ['ratio', 'animationSpeed', 'backgroundColors', 'haloColors', 'haloCount', 'noMargin'], }, })); @@ -208,4 +208,33 @@ describe('HomeStatisticsComponent', () => { expect(wrapper.vm.isPendingStatValue('1000+')).toBe(false); expect(wrapper.vm.isPendingStatValue(0)).toBe(false); }); + + describe('haloCount prop (T5)', () => { + it('defaults haloCount to 4', () => { + const wrapper = mount(HomeStatisticsComponent, { + props: { setup: [{ value: '100', title: 'Test' }] }, + global: globalOpts(vuetify), + }); + expect(wrapper.vm.haloCount).toBe(4); + }); + + it('passes haloCount to homeBlurBackgroundComponent', () => { + const wrapper = mount(HomeStatisticsComponent, { + props: { setup: [{ value: '100', title: 'Test' }], haloCount: 2 }, + global: globalOpts(vuetify), + }); + const blur = wrapper.findComponent({ name: 'HomeBlurBackgroundComponent' }); + expect(blur.props('haloCount')).toBe(2); + }); + + it('accepts valid haloCount values 1 through 5', () => { + [1, 2, 3, 4, 5].forEach((count) => { + const wrapper = mount(HomeStatisticsComponent, { + props: { setup: [{ value: '10', title: 'T' }], haloCount: count }, + global: globalOpts(vuetify), + }); + expect(wrapper.vm.haloCount).toBe(count); + }); + }); + }); }); diff --git a/src/modules/home/tests/home.view.unit.tests.js b/src/modules/home/tests/home.view.unit.tests.js new file mode 100644 index 000000000..2f063615e --- /dev/null +++ b/src/modules/home/tests/home.view.unit.tests.js @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount, flushPromises } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; + +/** + * Hoisted store action mocks — must be defined before any imports that use them. + */ +const initStatisticsMock = vi.hoisted(() => vi.fn()); +const getStatisticsMock = vi.hoisted(() => vi.fn()); +const getNewsMock = vi.hoisted(() => vi.fn()); + +vi.mock('../stores/home.store', () => ({ + useHomeStore: () => ({ + news: [], + statistics: null, + initStatistics: initStatisticsMock, + getStatistics: getStatisticsMock, + getNews: getNewsMock, + }), +})); + +// Stub all child components to avoid deep render +vi.mock('../components/home.hero.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.presentation.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.about.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.capabilities.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.features.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.services.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.steps.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.gallery.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.social.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.articles.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.statistics.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.faq.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.cta.component.vue', () => ({ default: { template: '
' } })); +vi.mock('../components/home.contact.component.vue', () => ({ default: { template: '
' } })); + +import HomeView from '../views/home.view.vue'; + +/** + * Mount home.view with a given config. + * @param {Object} homeConfig - Value of config.home + * @returns {import('@vue/test-utils').VueWrapper} + */ +const mountView = (homeConfig = {}) => + mount(HomeView, { + global: { + plugins: [createVuetify(), createPinia()], + config: { + globalProperties: { + config: { home: homeConfig }, + }, + }, + }, + }); + +describe('home.view — created() statistics.dynamic guard (T4)', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + it('calls initStatistics and getStatistics when statistics is truthy and dynamic is not set', async () => { + mountView({ statistics: { content: [] } }); + await flushPromises(); + + expect(initStatisticsMock).toHaveBeenCalledTimes(1); + expect(getStatisticsMock).toHaveBeenCalledTimes(1); + }); + + it('calls initStatistics but skips getStatistics when statistics.dynamic is false', async () => { + mountView({ statistics: { content: [], dynamic: false } }); + await flushPromises(); + + expect(initStatisticsMock).toHaveBeenCalledTimes(1); + expect(getStatisticsMock).not.toHaveBeenCalled(); + }); + + it('calls initStatistics and getStatistics when statistics.dynamic is true', async () => { + mountView({ statistics: { content: [], dynamic: true } }); + await flushPromises(); + + expect(initStatisticsMock).toHaveBeenCalledTimes(1); + expect(getStatisticsMock).toHaveBeenCalledTimes(1); + }); + + it('skips initStatistics and getStatistics when config.home.statistics is falsy', async () => { + mountView({}); + await flushPromises(); + + expect(initStatisticsMock).not.toHaveBeenCalled(); + expect(getStatisticsMock).not.toHaveBeenCalled(); + }); + + it('calls getNews when config.home.articles is set', async () => { + mountView({ articles: { enabled: true } }); + await flushPromises(); + + expect(getNewsMock).toHaveBeenCalledTimes(1); + }); + + it('does not call getNews when config.home.articles is falsy', async () => { + mountView({}); + await flushPromises(); + + expect(getNewsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/home/views/home.view.vue b/src/modules/home/views/home.view.vue index 5dfa4ee8e..6c381d402 100644 --- a/src/modules/home/views/home.view.vue +++ b/src/modules/home/views/home.view.vue @@ -109,7 +109,7 @@ export default { const homeStore = useHomeStore(); if (this.config.home.statistics) { homeStore.initStatistics(); - homeStore.getStatistics(); + if (this.config.home.statistics.dynamic !== false) homeStore.getStatistics(); } if (this.config.home.articles) homeStore.getNews(); }, diff --git a/src/modules/tasks/tests/task.view.unit.tests.js b/src/modules/tasks/tests/task.view.unit.tests.js index 979c38f32..a6f000f16 100644 --- a/src/modules/tasks/tests/task.view.unit.tests.js +++ b/src/modules/tasks/tests/task.view.unit.tests.js @@ -195,3 +195,53 @@ describe('task.view — remove()', () => { expect(mockPush).not.toHaveBeenCalled(); }); }); + +describe('task.view — save flag on user action (T6)', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + mockTask.id = null; + mockTask.title = 'My Task'; + mockTask.description = 'My Description'; + }); + + it('sets save=true when title computed setter is called', async () => { + const wrapper = mountView(); + await flushPromises(); + + // Simulate user editing the title + wrapper.vm.title = 'Updated Title'; + + expect(wrapper.vm.save).toBe(true); + }); + + it('sets save=true when description computed setter is called', async () => { + const wrapper = mountView(); + await flushPromises(); + + // Simulate user editing the description + wrapper.vm.description = 'Updated Description'; + + expect(wrapper.vm.save).toBe(true); + }); + + it('does not have a watch block on task (removed in T6)', () => { + const wrapper = mountView(); + // The $options.watch should be absent or not contain a 'task' watcher + const watch = wrapper.vm.$options.watch; + if (watch) { + expect(watch).not.toHaveProperty('task'); + } else { + // No watch block at all — task watcher is gone (expected outcome) + expect(watch).toBeUndefined(); + } + }); + + it('save starts as null (not pre-triggered on mount for new task)', async () => { + const wrapper = mountView(); + await flushPromises(); + + // For a new task (no id), save should remain null until user edits + expect(wrapper.vm.save).toBeNull(); + }); +}); diff --git a/src/modules/tasks/views/task.view.vue b/src/modules/tasks/views/task.view.vue index f880a5d85..7e8450d5c 100644 --- a/src/modules/tasks/views/task.view.vue +++ b/src/modules/tasks/views/task.view.vue @@ -87,6 +87,7 @@ export default { set(title) { const tasksStore = useTasksStore(); tasksStore.task.title = title; + this.save = true; }, }, description: { @@ -96,15 +97,8 @@ export default { set(description) { const tasksStore = useTasksStore(); tasksStore.task.description = description; - }, - }, - }, - watch: { - task: { - handler() { this.save = true; }, - deep: true, }, }, async created() {