Added unit and api tests

This commit is contained in:
Brian McGonagill 2026-03-26 10:21:19 -05:00
parent 9f4204cc73
commit fedf1eb4c5
34 changed files with 9205 additions and 20 deletions

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View 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();
});
});
});

View 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');
});
});
});

View file

@ -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';

View 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
View 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'],
},
},
});