Added unit and api tests
This commit is contained in:
parent
9f4204cc73
commit
fedf1eb4c5
34 changed files with 9205 additions and 20 deletions
1553
frontend/package-lock.json
generated
1553
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,8 +6,11 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:test": "vitest run && vue-tsc -b && vite build",
|
||||
"build:fast": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
|
|
@ -21,9 +24,12 @@
|
|||
"devDependencies": {
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"jsdom": "^29.0.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.1",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
206
frontend/src/components/Modal.test.ts
Normal file
206
frontend/src/components/Modal.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { h } from 'vue';
|
||||
import Modal from './Modal.vue';
|
||||
|
||||
describe('Modal Component', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders when open is true', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true }
|
||||
});
|
||||
|
||||
expect(wrapper.find('dialog').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('dialog is not visible when open is false', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: false }
|
||||
});
|
||||
|
||||
const dialog = wrapper.find('dialog');
|
||||
expect(dialog.exists()).toBe(true);
|
||||
expect(dialog.attributes('open')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('displays title when provided', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, title: 'Test Title' }
|
||||
});
|
||||
|
||||
expect(wrapper.find('header h3').text()).toBe('Test Title');
|
||||
});
|
||||
|
||||
it('does not show header when title is empty', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, title: '' }
|
||||
});
|
||||
|
||||
expect(wrapper.find('header').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('displays message when provided', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, message: 'Test message content' }
|
||||
});
|
||||
|
||||
expect(wrapper.find('p').text()).toBe('Test message content');
|
||||
});
|
||||
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true },
|
||||
slots: {
|
||||
default: h('div', { class: 'custom-content' }, 'Custom slot')
|
||||
}
|
||||
});
|
||||
|
||||
expect(wrapper.find('.custom-content').text()).toBe('Custom slot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert Mode', () => {
|
||||
it('shows only confirm button in alert mode', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'alert' }
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAll('footer button');
|
||||
expect(buttons.length).toBe(1);
|
||||
});
|
||||
|
||||
it('uses default confirm text "OK"', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'alert' }
|
||||
});
|
||||
|
||||
expect(wrapper.find('footer button').text()).toBe('OK');
|
||||
});
|
||||
|
||||
it('uses custom confirm text when provided', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'alert', confirmText: 'Got it' }
|
||||
});
|
||||
|
||||
expect(wrapper.find('footer button').text()).toBe('Got it');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Confirm Mode', () => {
|
||||
it('shows both confirm and cancel buttons', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'confirm' }
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAll('footer button');
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it('shows cancel button first', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'confirm' }
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAll('footer button');
|
||||
expect(buttons[0].classes()).toContain('secondary');
|
||||
expect(buttons[0].text()).toBe('Cancel');
|
||||
});
|
||||
|
||||
it('uses custom cancel text', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'confirm', cancelText: 'No Way' }
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAll('footer button');
|
||||
expect(buttons[0].text()).toBe('No Way');
|
||||
});
|
||||
|
||||
it('confirm button is not secondary by default', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'confirm' }
|
||||
});
|
||||
|
||||
const confirmBtn = wrapper.findAll('footer button')[1];
|
||||
expect(confirmBtn.classes()).not.toContain('secondary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Danger Mode', () => {
|
||||
it('confirm button has secondary class when danger is true', () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'confirm', danger: true }
|
||||
});
|
||||
|
||||
const confirmBtn = wrapper.findAll('footer button')[1];
|
||||
expect(confirmBtn.classes()).toContain('secondary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events', () => {
|
||||
it('emits confirm event when confirm button clicked', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true }
|
||||
});
|
||||
|
||||
await wrapper.find('footer button').trigger('click');
|
||||
|
||||
expect(wrapper.emitted('confirm')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits cancel event when cancel button clicked', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'confirm' }
|
||||
});
|
||||
|
||||
await wrapper.findAll('footer button')[0].trigger('click');
|
||||
|
||||
expect(wrapper.emitted('cancel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits update:open false when confirm clicked', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true },
|
||||
attrs: { 'onUpdate:open': vi.fn() }
|
||||
});
|
||||
|
||||
await wrapper.find('footer button').trigger('click');
|
||||
|
||||
expect(wrapper.emitted()['update:open']).toBeTruthy();
|
||||
expect(wrapper.emitted()['update:open'][0]).toEqual([false]);
|
||||
});
|
||||
|
||||
it('emits update:open false when cancel clicked', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'confirm' },
|
||||
attrs: { 'onUpdate:open': vi.fn() }
|
||||
});
|
||||
|
||||
await wrapper.findAll('footer button')[0].trigger('click');
|
||||
|
||||
expect(wrapper.emitted()['update:open']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Keyboard Handling', () => {
|
||||
it('emits update:open when Escape is pressed', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true }
|
||||
});
|
||||
|
||||
await wrapper.trigger('keydown', { key: 'Escape' });
|
||||
|
||||
expect(wrapper.emitted()['update:open']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('emits cancel and closes on backdrop click in confirm mode', async () => {
|
||||
const wrapper = mount(Modal, {
|
||||
props: { open: true, type: 'confirm' }
|
||||
});
|
||||
|
||||
await wrapper.find('dialog').trigger('click.self');
|
||||
|
||||
expect(wrapper.emitted()['cancel']).toBeTruthy();
|
||||
expect(wrapper.emitted()['update:open']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
151
frontend/src/composables/useModal.test.ts
Normal file
151
frontend/src/composables/useModal.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { state, handleConfirm, handleCancel, alert, confirm } from './useModal';
|
||||
|
||||
describe('useModal Composable', () => {
|
||||
beforeEach(() => {
|
||||
state.open = false;
|
||||
state.title = '';
|
||||
state.message = '';
|
||||
state.type = 'alert';
|
||||
state.confirmText = 'OK';
|
||||
state.cancelText = 'Cancel';
|
||||
state.danger = false;
|
||||
state.resolve = null;
|
||||
});
|
||||
|
||||
describe('alert', () => {
|
||||
it('should set state for alert dialog', async () => {
|
||||
const alertPromise = alert('Test message', 'Test Title');
|
||||
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.message).toBe('Test message');
|
||||
expect(state.title).toBe('Test Title');
|
||||
expect(state.type).toBe('alert');
|
||||
|
||||
handleConfirm();
|
||||
await alertPromise;
|
||||
});
|
||||
|
||||
it('should use default title when not provided', async () => {
|
||||
const alertPromise = alert('Message only');
|
||||
|
||||
expect(state.title).toBe('');
|
||||
|
||||
handleConfirm();
|
||||
await alertPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm', () => {
|
||||
it('should set state for confirm dialog', async () => {
|
||||
const confirmPromise = confirm('Are you sure?', 'Confirm Action');
|
||||
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.message).toBe('Are you sure?');
|
||||
expect(state.title).toBe('Confirm Action');
|
||||
expect(state.type).toBe('confirm');
|
||||
|
||||
handleConfirm();
|
||||
const result = await confirmPromise;
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should set danger mode', async () => {
|
||||
const confirmPromise = confirm('Delete this?', 'Danger', true);
|
||||
|
||||
expect(state.danger).toBe(true);
|
||||
|
||||
handleCancel();
|
||||
const result = await confirmPromise;
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should resolve true on confirm', async () => {
|
||||
const confirmPromise = confirm('Continue?');
|
||||
|
||||
handleConfirm();
|
||||
const result = await confirmPromise;
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve false on cancel', async () => {
|
||||
const confirmPromise = confirm('Cancel this?');
|
||||
|
||||
handleCancel();
|
||||
const result = await confirmPromise;
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleConfirm', () => {
|
||||
it('should resolve promise with true', async () => {
|
||||
const confirmPromise = confirm('Test');
|
||||
|
||||
handleConfirm();
|
||||
|
||||
const result = await confirmPromise;
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should close the modal', () => {
|
||||
confirm('Test');
|
||||
|
||||
handleConfirm();
|
||||
|
||||
expect(state.open).toBe(false);
|
||||
});
|
||||
|
||||
it('should not throw when resolve is null', () => {
|
||||
state.resolve = null;
|
||||
|
||||
expect(() => handleConfirm()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCancel', () => {
|
||||
it('should resolve promise with false', async () => {
|
||||
const confirmPromise = confirm('Test');
|
||||
|
||||
handleCancel();
|
||||
|
||||
const result = await confirmPromise;
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should close the modal', () => {
|
||||
confirm('Test');
|
||||
|
||||
handleCancel();
|
||||
|
||||
expect(state.open).toBe(false);
|
||||
});
|
||||
|
||||
it('should not throw when resolve is null', () => {
|
||||
state.resolve = null;
|
||||
|
||||
expect(() => handleCancel()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state defaults', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(state.confirmText).toBe('OK');
|
||||
expect(state.cancelText).toBe('Cancel');
|
||||
expect(state.type).toBe('alert');
|
||||
expect(state.danger).toBe(false);
|
||||
});
|
||||
|
||||
it('should use custom confirm text', async () => {
|
||||
confirm('Test', 'Title');
|
||||
expect(state.confirmText).toBe('OK');
|
||||
|
||||
state.confirmText = 'Yes';
|
||||
expect(state.confirmText).toBe('Yes');
|
||||
});
|
||||
|
||||
it('should use custom cancel text', async () => {
|
||||
confirm('Test', 'Title');
|
||||
expect(state.cancelText).toBe('Cancel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { adminService } from '../services/api';
|
||||
import { alert, confirm } from '../composables/useModal';
|
||||
|
|
|
|||
110
frontend/src/utils/units.test.ts
Normal file
110
frontend/src/utils/units.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDistance, formatRadius, kmToMiles, metersToFeet, formatDistanceShort } from './units';
|
||||
|
||||
describe('Units Conversion', () => {
|
||||
describe('kmToMiles', () => {
|
||||
it('converts 1 km to approximately 0.621 miles', () => {
|
||||
expect(kmToMiles(1)).toBeCloseTo(0.621371, 5);
|
||||
});
|
||||
|
||||
it('converts 10 km to approximately 6.214 miles', () => {
|
||||
expect(kmToMiles(10)).toBeCloseTo(6.21371, 4);
|
||||
});
|
||||
|
||||
it('handles zero', () => {
|
||||
expect(kmToMiles(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles fractional values', () => {
|
||||
expect(kmToMiles(0.5)).toBeCloseTo(0.3106855, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metersToFeet', () => {
|
||||
it('converts 1 meter to approximately 3.281 feet', () => {
|
||||
expect(metersToFeet(1)).toBeCloseTo(3.28084, 4);
|
||||
});
|
||||
|
||||
it('converts 100 meters to approximately 328 feet', () => {
|
||||
expect(metersToFeet(100)).toBeCloseTo(328.084, 2);
|
||||
});
|
||||
|
||||
it('handles zero', () => {
|
||||
expect(metersToFeet(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Distance Formatting', () => {
|
||||
describe('formatDistance (metric)', () => {
|
||||
it('formats small distances (<1km) in meters', () => {
|
||||
expect(formatDistance(0.1, 'METRIC')).toBe('100 m');
|
||||
expect(formatDistance(0.5, 'METRIC')).toBe('500 m');
|
||||
expect(formatDistance(0.05, 'METRIC')).toBe('50 m');
|
||||
});
|
||||
|
||||
it('formats distances >= 1km in kilometers', () => {
|
||||
expect(formatDistance(1, 'METRIC')).toBe('1.00 km');
|
||||
expect(formatDistance(2.5, 'METRIC')).toBe('2.50 km');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDistance (imperial)', () => {
|
||||
it('formats small distances in feet', () => {
|
||||
const result = formatDistance(0.01, 'IMPERIAL');
|
||||
expect(result).toMatch(/^\d+ ft$/);
|
||||
});
|
||||
|
||||
it('formats larger distances in miles', () => {
|
||||
expect(formatDistance(1, 'IMPERIAL')).toBe('0.62 mi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRadius', () => {
|
||||
it('formats small metric radii in meters', () => {
|
||||
expect(formatRadius(100, 'METRIC')).toBe('100 m');
|
||||
expect(formatRadius(500, 'METRIC')).toBe('500 m');
|
||||
});
|
||||
|
||||
it('formats larger metric radii in kilometers', () => {
|
||||
expect(formatRadius(1500, 'METRIC')).toBe('1.5 km');
|
||||
});
|
||||
|
||||
it('formats small imperial radii in feet', () => {
|
||||
const result = formatRadius(500, 'IMPERIAL');
|
||||
expect(result).toMatch(/^\d+ ft$/);
|
||||
});
|
||||
|
||||
it('formats larger imperial radii in miles', () => {
|
||||
const result = formatRadius(8000, 'IMPERIAL');
|
||||
expect(result).toMatch(/^\d+\.\d+ mi$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDistanceShort', () => {
|
||||
it('formats small metric distances in meters', () => {
|
||||
expect(formatDistanceShort(0.1, 'METRIC')).toBe('100 m');
|
||||
expect(formatDistanceShort(0.5, 'METRIC')).toBe('500 m');
|
||||
});
|
||||
|
||||
it('formats larger metric distances in km with 1 decimal', () => {
|
||||
expect(formatDistanceShort(1, 'METRIC')).toBe('1.0 km');
|
||||
expect(formatDistanceShort(2.5, 'METRIC')).toBe('2.5 km');
|
||||
});
|
||||
|
||||
it('formats small imperial distances in feet', () => {
|
||||
expect(formatDistanceShort(0.005, 'IMPERIAL')).toBe('16 ft');
|
||||
expect(formatDistanceShort(0.02, 'IMPERIAL')).toBe('66 ft');
|
||||
});
|
||||
|
||||
it('formats larger imperial distances in miles with 1 decimal', () => {
|
||||
expect(formatDistanceShort(1, 'IMPERIAL')).toBe('0.6 mi');
|
||||
expect(formatDistanceShort(5.5, 'IMPERIAL')).toBe('3.4 mi');
|
||||
});
|
||||
|
||||
it('handles zero', () => {
|
||||
expect(formatDistanceShort(0, 'METRIC')).toBe('0 m');
|
||||
expect(formatDistanceShort(0, 'IMPERIAL')).toBe('0 ft');
|
||||
});
|
||||
});
|
||||
});
|
||||
16
frontend/vitest.config.ts
Normal file
16
frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/**/*.{ts,vue}', '!src/**/*.d.ts'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue