Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/modules/core/stores/core.store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
68 changes: 68 additions & 0 deletions src/modules/core/tests/core.store.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
});
7 changes: 7 additions & 0 deletions src/modules/home/components/home.statistics.component.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
:animation-speed="animationSpeed"
:background-colors="backgroundColors"
:halo-colors="haloColors"
:halo-count="haloCount"
no-margin
>
<v-container class="fill-height" :style="`max-width: ${config.vuetify.theme.maxWidth}`">
Expand Down Expand Up @@ -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,
},
Comment on lines +148 to +153
// Overlap: slides section up into previous section
overlap: {
type: [Boolean, String, Object],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@
<!-- Halo/Blob layers -->
<div class="halo-container">
<div class="halo halo-1" :style="haloStyle(0)"></div>
<div class="halo halo-2" :style="haloStyle(1)"></div>
<div class="halo halo-3" :style="haloStyle(2)"></div>
<div class="halo halo-4" :style="haloStyle(3)"></div>
<div class="halo halo-5" :style="haloStyle(4)"></div>
<div v-if="haloCount >= 2" class="halo halo-2" :style="haloStyle(1)"></div>
<div v-if="haloCount >= 3" class="halo halo-3" :style="haloStyle(2)"></div>
<div v-if="haloCount >= 4" class="halo halo-4" :style="haloStyle(3)"></div>
<div v-if="haloCount >= 5" class="halo halo-5" :style="haloStyle(4)"></div>
</div>

<!-- Glass overlay -->
Expand Down Expand Up @@ -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,
},
Comment on lines +94 to +99
// Remove top margin (for non-banner usage like stats)
noMargin: {
type: Boolean,
Expand Down
31 changes: 30 additions & 1 deletion src/modules/home/tests/home.statistics.component.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ vi.mock('../components/utils/home.blur.background.component.vue', () => ({
default: {
name: 'HomeBlurBackgroundComponent',
template: '<div><slot /></div>',
props: ['ratio', 'animationSpeed', 'backgroundColors', 'haloColors', 'noMargin'],
props: ['ratio', 'animationSpeed', 'backgroundColors', 'haloColors', 'haloCount', 'noMargin'],
},
}));

Expand Down Expand Up @@ -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);
});
});
});
});
109 changes: 109 additions & 0 deletions src/modules/home/tests/home.view.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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: '<div />' } }));
vi.mock('../components/home.presentation.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.about.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.capabilities.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.features.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.services.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.steps.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.gallery.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.social.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.articles.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.statistics.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.faq.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.cta.component.vue', () => ({ default: { template: '<div />' } }));
vi.mock('../components/home.contact.component.vue', () => ({ default: { template: '<div />' } }));

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();
});
});
2 changes: 1 addition & 1 deletion src/modules/home/views/home.view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand Down
50 changes: 50 additions & 0 deletions src/modules/tasks/tests/task.view.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
8 changes: 1 addition & 7 deletions src/modules/tasks/views/task.view.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export default {
set(title) {
const tasksStore = useTasksStore();
tasksStore.task.title = title;
this.save = true;
},
},
description: {
Expand All @@ -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() {
Expand Down
Loading