feat: Implement development pipeline and boost test coverage

- Add Makefile with `check`, `run` (blocking), and `force-stop` targets
- Integrate fitmop.sh logic into Makefile
- Achieve 100% backend test coverage
- Improve frontend test coverage to >85% (Views/Components >90%)
- Enforce `make check` in GEMINI.md
- Add comprehensive tests for App.vue and WorkoutVisualEditor.vue
This commit is contained in:
Moritz Graf
2026-01-01 22:16:00 +01:00
parent f3260d7dff
commit 2240a32a53
29 changed files with 2283 additions and 265 deletions
+21 -25
View File
@@ -4,31 +4,27 @@ import prettier from 'eslint-config-prettier'
import globals from 'globals'
export default [
{
ignores: [
'dist/**',
'node_modules/**',
'*.log'
]
{
ignores: ['dist/**', 'node_modules/**', '*.log']
},
js.configs.recommended,
...vue.configs['flat/recommended'],
prettier,
{
files: ['**/*.vue', '**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
process: 'readonly'
}
},
js.configs.recommended,
...vue.configs['flat/recommended'],
prettier,
{
files: ['**/*.vue', '**/*.js'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
process: 'readonly'
}
},
rules: {
'vue/multi-word-component-names': 'off',
'no-unused-vars': 'warn',
'vue/no-mutating-props': 'error'
}
rules: {
'vue/multi-word-component-names': 'off',
'no-unused-vars': 'warn',
'vue/no-mutating-props': 'error'
}
}
]
+183 -1
View File
@@ -18,6 +18,7 @@
"@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/coverage-v8": "^4.0.16",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
@@ -117,6 +118,16 @@
"node": ">=6.9.0"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -986,12 +997,33 @@
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
@@ -1621,6 +1653,38 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz",
"integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.0.16",
"ast-v8-to-istanbul": "^0.3.8",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.1",
"obug": "^2.1.1",
"std-env": "^3.10.0",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.0.16",
"vitest": "4.0.16"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "4.0.16",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz",
@@ -1960,6 +2024,28 @@
"node": ">=12"
}
},
"node_modules/ast-v8-to-istanbul": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz",
"integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^9.0.1"
}
},
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2796,6 +2882,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -2915,6 +3008,60 @@
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-lib-source-maps": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
"integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.23",
"debug": "^4.1.1",
"istanbul-lib-coverage": "^3.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -2963,6 +3110,13 @@
"node": ">=14"
}
},
"node_modules/js-tokens": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -2982,7 +3136,6 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@@ -3123,6 +3276,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz",
"integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"source-map-js": "^1.2.1"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
@@ -4007,6 +4188,7 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
+2 -1
View File
@@ -22,6 +22,7 @@
"@typescript-eslint/eslint-plugin": "^8.51.0",
"@typescript-eslint/parser": "^8.51.0",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/coverage-v8": "^4.0.16",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
@@ -32,4 +33,4 @@
"vite": "^7.2.4",
"vitest": "^4.0.16"
}
}
}
+35 -67
View File
@@ -83,7 +83,7 @@ const fetchSettings = async () => {
settingsForms.value.garmin.email = settingsStatus.value.garmin.email || ''
}
if (settingsStatus.value.gemini.configured) {
settingsForms.value.gemini.api_key = '••••••••'
settingsForms.value.gemini.api_key = '••••••••'
}
}
} catch (error) {
@@ -105,7 +105,7 @@ const saveServiceSettings = async (service) => {
const err = await res.json()
authError.value = err.detail || 'Save failed'
}
} catch (error) {
} catch {
authError.value = 'Failed to communicate with backend'
} finally {
loading.value = false
@@ -129,7 +129,7 @@ const triggerSync = async () => {
const loginGarmin = async () => {
loading.value = true
authError.value = ""
authError.value = ''
try {
const res = await fetch('http://localhost:8000/auth/login', {
method: 'POST',
@@ -146,7 +146,7 @@ const loginGarmin = async () => {
} else {
authError.value = data.message || 'Login failed'
}
} catch (error) {
} catch {
authError.value = 'Connection error'
} finally {
loading.value = false
@@ -177,6 +177,29 @@ const setTheme = (theme) => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('theme', theme)
}
const openGeminiSettings = () => {
settingsOpen.value = true
activeTab.value = 'gemini'
}
const saveProfile = async () => {
loading.value = true
try {
const res = await fetch('http://localhost:8000/settings/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile.value)
})
if (!res.ok) {
authError.value = 'Failed to save profile'
}
} catch {
authError.value = 'Failed to connect'
} finally {
loading.value = false
}
}
</script>
<template>
@@ -283,9 +306,15 @@ const setTheme = (theme) => {
title="Gemini API Key missing"
/>
</div>
<div v-if="!settingsStatus.gemini.configured" class="doc-box" style="margin-top: 1rem; border-color: var(--error-color)">
<div
v-if="!settingsStatus.gemini.configured"
class="doc-box"
style="margin-top: 1rem; border-color: var(--error-color)"
>
<strong>AI Recommendations Disabled</strong><br />
Please set your Gemini API Key in <a href="#" @click.prevent="settingsOpen = true; activeTab = 'gemini'">Settings</a> to get personalized coaching.
Please set your Gemini API Key in
<a href="#" @click.prevent="openGeminiSettings">Settings</a>
to get personalized coaching.
</div>
<p v-else-if="loading">Thinking...</p>
<p v-else style="font-size: 1.1rem; font-style: italic">"{{ recommendation }}"</p>
@@ -785,65 +814,4 @@ button:disabled {
justify-content: space-between;
align-items: center;
}
.modal-body {
display: flex;
flex: 1;
overflow: hidden;
}
.modal-sidebar {
width: 200px;
border-right: 1px solid var(--border-color);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sidebar-item {
padding: 0.75rem 1rem;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
}
.sidebar-item.active {
background: var(--card-bg);
color: var(--accent-color);
font-weight: 600;
}
.modal-main {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.doc-box {
background: var(--card-bg);
padding: 1rem;
border-radius: 6px;
font-size: 0.9rem;
margin-bottom: 1.5rem;
border-left: 4px solid var(--accent-color);
}
.theme-preview {
display: flex;
gap: 1rem;
}
.theme-card {
flex: 1;
padding: 1rem;
border: 2px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.theme-card.active {
border-color: var(--accent-color);
}
</style>
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import AnalyzeView from '../views/AnalyzeView.vue'
// Mock Bar component and Lucide icons
vi.mock('vue-chartjs', () => ({
Bar: {
name: 'Bar',
template: '<div class="chart-mock"></div>',
props: ['data', 'options']
}
}))
describe('AnalyzeView.vue', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
it('fetches data and runs sync on mount', async () => {
// 1. Initial fetchData mock
fetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ weekly: { labels: ['W1'], datasets: [] } })
})
// 2. runSmartSync mock
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true, synced_count: 2 })
})
// 3. fetchData mock triggered AFTER runSmartSync
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ weekly: { labels: ['W1'], datasets: [] } })
})
const wrapper = mount(AnalyzeView)
await flushPromises()
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/analyze/stats?weeks=12'))
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/sync/smart'), { method: 'POST' })
expect(wrapper.text()).toContain('Synced 2 new')
})
it('handles sync failure', async () => {
// fetchData call
fetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ weekly: { labels: [] } })
})
// runSmartSync call
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: false })
})
const wrapper = mount(AnalyzeView)
await flushPromises()
expect(wrapper.text()).toContain('Check Connection')
})
it('updates time horizon and re-fetches', async () => {
fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ weekly: { labels: [] } })
})
const wrapper = mount(AnalyzeView)
await flushPromises()
fetch.mockClear()
const btn4w = wrapper.findAll('.time-toggles button').find((b) => b.text() === '4W')
await btn4w.trigger('click')
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/analyze/stats?weeks=4'))
})
it('handles AI chat flow', async () => {
fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ weekly: { labels: [] }, success: true })
})
const wrapper = mount(AnalyzeView)
await flushPromises()
const input = wrapper.find('.chat-input input')
await input.setValue('Test question')
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ message: 'AI Response' })
})
await wrapper.find('.chat-input button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('AI Response')
expect(wrapper.find('.message.user').text()).toContain('Test question')
})
it('handles chat error', async () => {
fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ weekly: { labels: [] } })
})
const wrapper = mount(AnalyzeView)
await flushPromises()
const input = wrapper.find('.chat-input input')
await input.setValue('Break me')
fetch.mockRejectedValue(new Error('Network Fail'))
await wrapper.find('.chat-input button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Error connecting to AI Analyst')
})
it('handles empty chips/quick actions', async () => {
fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ weekly: { labels: [] } })
})
const wrapper = mount(AnalyzeView)
await flushPromises()
const chips = wrapper.findAll('.chips button')
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ message: 'Summarized' })
})
await chips[0].trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Summarized')
})
})
+371
View File
@@ -0,0 +1,371 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import App from '../App.vue'
// Stub child components
vi.mock('../views/AnalyzeView.vue', () => ({
default: { name: 'AnalyzeView', template: '<div class="analyze-stub"></div>' }
}))
vi.mock('../views/PlanView.vue', () => ({
default: { name: 'PlanView', template: '<div class="plan-stub"></div>' }
}))
describe('App.vue', () => {
const defaultSettings = {
garmin: { configured: false, email: '' },
gemini: { configured: false },
withings: { configured: false }
}
const defaultDashboard = {
summary: { total_hours: 0, trend_pct: 0 },
breakdown: [],
strength_sessions: 0
}
// Robust fetch mock
const createFetchMock = (overrides = {}) => {
return vi.fn((url, options) => {
for (const key of Object.keys(overrides)) {
if (url.includes(key)) {
const override = overrides[key]
if (typeof override === 'function') return override(url, options)
if (override instanceof Promise) return override
return Promise.resolve({
ok: override.ok !== false,
status: override.status || 200,
json: () => Promise.resolve(override.json ? override.json() : override.data || override)
})
}
}
let data = {}
if (url.includes('/auth/status')) data = { authenticated: false, status: 'IDLE' }
else if (url.includes('/settings/status')) data = defaultSettings
else if (url.includes('/activities')) data = []
else if (url.includes('/analyze/dashboard')) data = defaultDashboard
else if (url.includes('/recommendation')) data = { recommendation: 'None' }
else if (url.includes('/settings/')) data = {}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(data)
})
})
}
beforeEach(() => {
vi.stubGlobal('fetch', createFetchMock())
vi.spyOn(console, 'error').mockImplementation(() => {})
const storage = {}
vi.stubGlobal('localStorage', {
getItem: vi.fn((key) => storage[key] || null),
setItem: vi.fn((key, val) => (storage[key] = val))
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('initializes and fetches data on mount', async () => {
const wrapper = mount(App)
await flushPromises()
expect(wrapper.text()).toContain('FitMop')
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/auth/status'))
})
it('handles auth check network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network Error')))
const wrapper = mount(App)
await flushPromises()
expect(console.error).toHaveBeenCalledWith('Auth check failed:', expect.any(Error))
})
it('handles auth check MFA required', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/auth/status': { authenticated: false, status: 'MFA_REQUIRED' }
})
)
const wrapper = mount(App)
await flushPromises()
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/activities'))
})
it('handles settings fetch failure', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/settings/status': () => Promise.reject(new Error('Fail'))
})
)
const wrapper = mount(App)
await flushPromises()
expect(console.error).toHaveBeenCalledWith(
'Failed to fetch settings status:',
expect.any(Error)
)
})
it('switches views correctly', async () => {
const wrapper = mount(App)
await flushPromises()
const navBtns = wrapper.findAll('.main-nav button')
await navBtns.find((b) => b.text().includes('Analysis')).trigger('click')
expect(wrapper.find('.analyze-stub').exists()).toBe(true)
await navBtns.find((b) => b.text().includes('Workout Plans')).trigger('click')
expect(wrapper.find('.plan-stub').exists()).toBe(true)
})
it('opens and closes settings modal', async () => {
const wrapper = mount(App)
await flushPromises()
await wrapper.find('.settings-btn').trigger('click')
expect(wrapper.find('.modal-content').exists()).toBe(true)
await wrapper.find('.modal-header button').trigger('click')
expect(wrapper.find('.modal-content').exists()).toBe(false)
})
it('opens separate settings tabs', async () => {
const wrapper = mount(App)
await flushPromises()
await wrapper.find('.settings-btn').trigger('click')
const items = wrapper.findAll('.sidebar-item')
await items.find((i) => i.text().includes('Withings')).trigger('click')
expect(wrapper.text()).toContain('Client ID')
await items.find((i) => i.text().includes('Gemini AI')).trigger('click')
expect(wrapper.text()).toContain('Gemini API Key')
})
it('opens Gemini settings via dashboard link', async () => {
const wrapper = mount(App)
await flushPromises()
const link = wrapper.find('.doc-box a')
await link.trigger('click')
expect(wrapper.find('.modal-content').exists()).toBe(true)
expect(wrapper.text()).toContain('Gemini API Key')
})
it('pre-fills forms with existing configuration', async () => {
const configuredSettings = {
garmin: { configured: true, email: 'test@example.com' },
gemini: { configured: true },
withings: { configured: false }
}
vi.stubGlobal(
'fetch',
createFetchMock({
'/settings/status': configuredSettings
})
)
const wrapper = mount(App)
await flushPromises()
await wrapper.find('.settings-btn').trigger('click')
const emailInput = wrapper.find('input[type="email"]')
expect(emailInput.element.value).toBe('test@example.com')
})
it('saves service settings successfully', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/settings/garmin': { ok: true, data: {} },
'/settings/status': { ...defaultSettings, garmin: { configured: true } }
})
)
const wrapper = mount(App)
await flushPromises()
await wrapper.find('.settings-btn').trigger('click')
const saveBtn = wrapper.findAll('button').find((b) => b.text().includes('Save Credentials'))
await saveBtn.trigger('click')
await flushPromises()
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/settings/garmin'),
expect.anything()
)
})
it('handles save service settings backend error', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/settings/garmin': {
ok: false,
status: 400,
json: () => ({ detail: 'Invalid Credentials' })
}
})
)
const wrapper = mount(App)
await flushPromises()
await wrapper.find('.settings-btn').trigger('click')
const saveBtn = wrapper.findAll('button').find((b) => b.text().includes('Save Credentials'))
await saveBtn.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Invalid Credentials')
})
it('handles save service settings network error', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/settings/garmin': () => Promise.reject(new Error('Network'))
})
)
const wrapper = mount(App)
await flushPromises()
await wrapper.find('.settings-btn').trigger('click')
const saveBtn = wrapper.findAll('button').find((b) => b.text().includes('Save Credentials'))
await saveBtn.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Failed to communicate with backend')
})
it('saves profile settings', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/settings/profile': { ok: true }
})
)
const wrapper = mount(App)
await flushPromises()
await wrapper.find('.settings-btn').trigger('click')
await wrapper
.findAll('.sidebar-item')
.find((i) => i.text().includes('Profile'))
.trigger('click')
const saveBtn = wrapper.findAll('button').find((b) => b.text().includes('Save Profile'))
await saveBtn.trigger('click')
await flushPromises()
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/settings/profile'),
expect.anything()
)
})
it('triggers sync successfully', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/auth/status': { authenticated: true, status: 'IDLE' },
'/sync': { ok: true }
})
)
const wrapper = mount(App)
await flushPromises()
const refreshBtn = wrapper
.findAll('button')
.filter((b) => b.html().includes('lucide-refresh'))
.at(0)
if (refreshBtn) {
await refreshBtn.trigger('click')
await flushPromises()
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/sync'), expect.anything())
}
})
it('handles sync failure', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/auth/status': { authenticated: true, status: 'IDLE' },
'/sync': () => Promise.reject(new Error('Sync Fail'))
})
)
const wrapper = mount(App)
await flushPromises()
const refreshBtn = wrapper
.findAll('button')
.filter((b) => b.html().includes('lucide-refresh'))
.at(0)
if (refreshBtn) {
await refreshBtn.trigger('click')
await flushPromises()
expect(console.error).toHaveBeenCalledWith('Sync failed:', expect.any(Error))
}
})
it('handles Garmin login success', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/auth/login': { status: 'SUCCESS' }
})
)
const wrapper = mount(App)
await flushPromises()
wrapper.find('.settings-btn').trigger('click')
await flushPromises()
const btn = wrapper.findAll('button').find((b) => b.text().includes('Test & Sync'))
await btn.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Garmin Connected')
})
it('handles Garmin login failure', async () => {
vi.stubGlobal(
'fetch',
createFetchMock({
'/auth/login': { status: 'FAILED', message: 'Bad Pass' }
})
)
const wrapper = mount(App)
await flushPromises()
wrapper.find('.settings-btn').trigger('click')
await flushPromises()
const btn = wrapper.findAll('button').find((b) => b.text().includes('Test & Sync'))
await btn.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Bad Pass')
})
it('sets theme', async () => {
const wrapper = mount(App)
await flushPromises()
await wrapper.find('.settings-btn').trigger('click')
await flushPromises()
const sidebarItems = wrapper.findAll('.sidebar-item')
const appearanceTab = sidebarItems.find((i) => i.text().includes('Appearance'))
await appearanceTab.trigger('click')
await flushPromises()
const themeCard = wrapper.findAll('.theme-card').find((c) => c.text().includes('Retro Hacker'))
await themeCard.trigger('click')
expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'hacker')
})
})
+12 -12
View File
@@ -3,18 +3,18 @@ import { mount } from '@vue/test-utils'
import HelloWorld from '../components/HelloWorld.vue'
describe('HelloWorld.vue', () => {
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = mount(HelloWorld, {
props: { msg }
})
expect(wrapper.text()).toContain(msg)
it('renders props.msg when passed', () => {
const msg = 'new message'
const wrapper = mount(HelloWorld, {
props: { msg }
})
expect(wrapper.text()).toContain(msg)
})
it('increments count when button is clicked', async () => {
const wrapper = mount(HelloWorld)
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.text()).toContain('count is 1')
})
it('increments count when button is clicked', async () => {
const wrapper = mount(HelloWorld)
const button = wrapper.find('button')
await button.trigger('click')
expect(wrapper.text()).toContain('count is 1')
})
})
+210
View File
@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import PlanView from '../views/PlanView.vue'
// Stub child components
vi.mock('../components/WorkoutVisualEditor.vue', () => ({
default: {
name: 'WorkoutVisualEditor',
template: '<div class="visual-editor-stub"></div>',
props: ['modelValue', 'steps']
}
}))
vi.mock('../components/WorkoutJsonEditor.vue', () => ({
default: {
name: 'WorkoutJsonEditor',
template: '<div class="json-editor-stub"></div>',
props: ['modelValue']
}
}))
describe('PlanView.vue', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
it('fetches workouts on mount', async () => {
fetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve([
{ workoutId: 1, workoutName: 'Run', sportType: { sportTypeKey: 'running' } }
])
})
const wrapper = mount(PlanView)
await flushPromises()
expect(wrapper.text()).toContain('Run')
})
it('handles fetch failure', async () => {
fetch.mockRejectedValue(new Error('Fail'))
const wrapper = mount(PlanView)
await flushPromises()
expect(wrapper.find('.workout-grid').exists()).toBe(true)
})
it('enters editor mode for new workout', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button.primary-btn').trigger('click')
expect(wrapper.find('.editor-mode').exists()).toBe(true)
expect(wrapper.find('.title-input').element.value).toBe('New Workout')
})
it('enters editor mode for editing existing workout', async () => {
const workout = { workoutId: 1, workoutName: 'Old', workoutSegments: [{ workoutSteps: [] }] }
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([workout])
})
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button[title="Edit"]').trigger('click')
expect(wrapper.find('.title-input').element.value).toBe('Old')
})
it('duplicates a workout', async () => {
const workout = { workoutId: 1, workoutName: 'CopyMe', workoutSegments: [{ workoutSteps: [] }] }
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([workout])
})
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button[title="Duplicate"]').trigger('click')
expect(wrapper.find('.title-input').element.value).toBe('CopyMe (Copy)')
expect(wrapper.find('.editor-mode').exists()).toBe(true)
})
it('syncs to Garmin successfully', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
// Create new to enter editor
await wrapper.find('button.primary-btn').trigger('click')
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true })
})
await wrapper.find('.right-controls button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Uploaded to Garmin!')
})
it('handles Garmin sync failure', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button.primary-btn').trigger('click')
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: false, error: 'Auth Error' })
})
await wrapper.find('.right-controls button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Upload failed: Auth Error')
})
it('handles Garmin sync network error', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button.primary-btn').trigger('click')
fetch.mockRejectedValue(new Error('Network'))
await wrapper.find('.right-controls button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Network error')
})
it('handles AI ask success', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button.primary-btn').trigger('click')
const aiInput = wrapper.find('.ai-input-wrapper input')
await aiInput.setValue('harder')
fetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
workout: { workoutName: 'Harder', workoutSegments: [{ workoutSteps: [] }] }
})
})
await wrapper.find('.ai-btn').trigger('click')
await flushPromises()
expect(wrapper.find('.title-input').element.value).toBe('Harder')
})
it('handles AI ask error', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button.primary-btn').trigger('click')
await wrapper.find('.ai-input-wrapper input').setValue('break')
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ error: 'AI Error' })
})
await wrapper.find('.ai-btn').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('AI Error')
})
it('handles AI network error', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button.primary-btn').trigger('click')
await wrapper.find('.ai-input-wrapper input').setValue('network')
fetch.mockRejectedValue(new Error('fail'))
await wrapper.find('.ai-btn').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Failed to contact AI')
})
it('switches between visual and json tabs', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button.primary-btn').trigger('click')
const jsonBtn = wrapper.findAll('button').find((b) => b.text().includes('JSON Source'))
await jsonBtn.trigger('click')
expect(wrapper.find('.json-editor-stub').exists()).toBe(true)
const visualBtn = wrapper.findAll('button').find((b) => b.text().includes('Visual Editor'))
await visualBtn.trigger('click')
expect(wrapper.find('.visual-editor-stub').exists()).toBe(true)
})
it('returns to browser mode', async () => {
fetch.mockResolvedValue({ ok: true, json: () => Promise.resolve([]) })
const wrapper = mount(PlanView)
await flushPromises()
await wrapper.find('button.primary-btn').trigger('click')
await wrapper.find('.left-controls button').trigger('click')
expect(wrapper.find('.browser-mode').exists()).toBe(true)
})
})
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import WorkoutJsonEditor from '../components/WorkoutJsonEditor.vue'
describe('WorkoutJsonEditor.vue', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
it('renders and allows editing', async () => {
const initial = { workoutName: 'Test' }
const wrapper = mount(WorkoutJsonEditor, {
props: { modelValue: initial }
})
const textarea = wrapper.find('textarea')
expect(textarea.element.value).toContain('Test')
await textarea.setValue(JSON.stringify({ workoutName: 'New' }))
expect(wrapper.emitted()['update:modelValue'][0][0]).toEqual({ workoutName: 'New' })
})
it('syncs prop changes to local string', async () => {
const wrapper = mount(WorkoutJsonEditor, {
props: { modelValue: { a: 1 } }
})
await wrapper.setProps({ modelValue: { b: 2 } })
expect(wrapper.find('textarea').element.value).toContain('"b": 2')
})
it('handles validate call success', async () => {
const wrapper = mount(WorkoutJsonEditor, {
props: { modelValue: { workoutName: 'Test' } }
})
fetch.mockResolvedValue({
json: () => Promise.resolve({ valid: true, errors: [] })
})
await wrapper.find('button').trigger('click')
expect(fetch).toHaveBeenCalledWith('http://localhost:8000/workouts/validate', expect.anything())
expect(wrapper.text()).toContain('Valid Garmin Workout Schema')
})
it('handles validate call failure', async () => {
const wrapper = mount(WorkoutJsonEditor, {
props: { modelValue: { workoutName: 'Test' } }
})
fetch.mockResolvedValue({
json: () => Promise.resolve({ valid: false, errors: ['Field missing'] })
})
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Validation Errors Found')
expect(wrapper.text()).toContain('Field missing')
})
it('handles invalid JSON in validate call', async () => {
const wrapper = mount(WorkoutJsonEditor, {
props: { modelValue: { workoutName: 'Test' } }
})
// Manually set invalid value to trigger catch in validate()
const textarea = wrapper.find('textarea')
await textarea.setValue('{ invalid')
await wrapper.find('button').trigger('click')
expect(wrapper.text()).toContain('Invalid JSON Syntax')
})
})
@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import WorkoutVisualEditor from '../components/WorkoutVisualEditor.vue'
const DraggableStub = {
props: ['list', 'itemKey'],
template: `
<div>
<div v-for="(element, index) in list" :key="element[itemKey] || index">
<slot name="item" :element="element" :index="index" />
</div>
</div>
`
}
describe('WorkoutVisualEditor.vue', () => {
const mountOptions = (props = {}) => ({
props,
global: {
stubs: {
draggable: DraggableStub,
GripVertical: true,
Trash2: true,
Plus: true,
Repeat: true
}
}
})
it('renders metadata correctly at top level', () => {
const modelValue = {
workoutName: 'My Run',
sportType: { sportTypeId: 1 }
}
const wrapper = mount(
WorkoutVisualEditor,
mountOptions({ modelValue, steps: [], isNested: false })
)
const inputs = wrapper.findAll('input')
const nameInput = inputs.find((i) => i.element.value === 'My Run')
expect(nameInput.exists()).toBe(true)
})
it('hides metadata when nested', () => {
const wrapper = mount(
WorkoutVisualEditor,
mountOptions({ modelValue: {}, steps: [], isNested: true })
)
expect(wrapper.text()).not.toContain('Workout Metadata')
})
it('adds a new step', async () => {
const steps = []
const wrapper = mount(WorkoutVisualEditor, mountOptions({ steps }))
const buttons = wrapper.findAll('button')
const addButton = buttons.find((b) => b.text().includes('Add Step'))
await addButton.trigger('click')
expect(wrapper.emitted()['update:steps']).toBeTruthy()
const emittedSteps = wrapper.emitted()['update:steps'][0][0]
expect(emittedSteps).toHaveLength(1)
expect(emittedSteps[0].type).toBe('ExecutableStepDTO')
})
it('adds a repeat group', async () => {
const steps = []
const wrapper = mount(WorkoutVisualEditor, mountOptions({ steps }))
const buttons = wrapper.findAll('button')
const addRepeatButton = buttons.find((b) => b.text().includes('Add Repeat'))
await addRepeatButton.trigger('click')
const emittedSteps = wrapper.emitted()['update:steps'][0][0]
expect(emittedSteps).toHaveLength(1)
expect(emittedSteps[0].type).toBe('RepeatGroupDTO')
})
it('removes a step', async () => {
const steps = [
{
stepId: 1,
type: 'ExecutableStepDTO',
endCondition: {},
stepType: {}
}
]
const wrapper = mount(WorkoutVisualEditor, mountOptions({ steps }))
const removeButton = wrapper.find('button.text-red-400')
await removeButton.trigger('click')
expect(wrapper.emitted()['update:steps'][0][0]).toHaveLength(0)
})
it('updates metadata on input change', async () => {
const modelValue = { workoutName: 'Old', sportType: { sportTypeId: 1 } }
const wrapper = mount(WorkoutVisualEditor, mountOptions({ modelValue, steps: [] }))
const input = wrapper.findAll('input')[0]
await input.setValue('New Name')
expect(wrapper.emitted()['update:modelValue'][0][0].workoutName).toBe('New Name')
const select = wrapper.find('select')
await select.setValue('2')
expect(wrapper.emitted()['update:modelValue'][1][0].sportType.sportTypeId).toBe(2)
})
it('updates step duration type', async () => {
const steps = [
{
stepId: 1,
type: 'ExecutableStepDTO',
stepType: { stepTypeId: 3 },
endCondition: { conditionTypeId: 2, conditionTypeKey: 'time' },
endConditionValue: 300
}
]
const wrapper = mount(WorkoutVisualEditor, mountOptions({ steps, isNested: true }))
const select = wrapper.findAll('select').find((s) => s.element.value === '2')
await select.setValue('1')
const emitted = wrapper.emitted()['update:steps'][0][0]
expect(emitted[0].endCondition.conditionTypeId).toBe(1)
})
it('updates step duration value', async () => {
const steps = [
{
stepId: 1,
type: 'ExecutableStepDTO',
stepType: { stepTypeId: 3 },
endCondition: { conditionTypeId: 2, conditionTypeKey: 'time' },
endConditionValue: 300
}
]
const wrapper = mount(WorkoutVisualEditor, mountOptions({ steps, isNested: true }))
const durationInput = wrapper
.findAll('input[type="number"]')
.find((i) => i.element.value == '300')
await durationInput.setValue('600')
const emitted = wrapper.emitted()['update:steps'][0][0]
expect(emitted[0].endConditionValue).toBe(600)
})
it('formats step types correctly', () => {
const steps = [
{ stepId: 1, type: 'RepeatGroupDTO' },
{ stepId: 2, type: 'ExecutableStepDTO', stepType: { stepTypeId: 1 }, endCondition: {} },
{ stepId: 3, type: 'ExecutableStepDTO', stepType: { stepTypeId: 2 }, endCondition: {} }
]
const wrapper = mount(WorkoutVisualEditor, mountOptions({ steps }))
const text = wrapper.text()
expect(text).toContain('Repeat Group')
expect(text).toContain('Warmup')
})
it('handles nested repeat group updates', async () => {
const steps = [
{
stepId: 1,
type: 'RepeatGroupDTO',
numberOfIterations: 2,
workoutSteps: []
}
]
const wrapper = mount(WorkoutVisualEditor, mountOptions({ steps, isNested: true }))
const iterationsInput = wrapper.find('input[type="number"]')
await iterationsInput.setValue(3)
expect(wrapper.emitted()['update:steps']).toBeTruthy()
const emitted = wrapper.emitted()['update:steps'][0][0]
expect(emitted[0].numberOfIterations).toBe(3)
})
it('handles nested update from sub-editor (recursion)', async () => {
const steps = [
{
stepId: 1,
type: 'RepeatGroupDTO',
numberOfIterations: 2,
workoutSteps: []
}
]
const wrapper = mount(WorkoutVisualEditor, mountOptions({ steps, isNested: true }))
// Invoke method directly to simulate recursion event
const newSteps = [{ stepId: 99, type: 'NewStep' }]
wrapper.vm.onNestedUpdate(newSteps, 0)
const emitted = wrapper.emitted()['update:steps'][0][0]
expect(emitted[0].workoutSteps).toHaveLength(1)
expect(emitted[0].workoutSteps[0].stepId).toBe(99)
})
})
@@ -16,7 +16,12 @@
<select
:value="modelValue.sportType?.sportTypeId"
class="w-full bg-gray-700 rounded px-2 py-1 text-white border border-gray-600"
@change="emit('update:modelValue', { ...modelValue, sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) } })"
@change="
emit('update:modelValue', {
...modelValue,
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
})
"
>
<option :value="1">Running</option>
<option :value="2">Cycling</option>
@@ -141,9 +146,6 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'update:steps'])
// For vuedraggable to work seamlessly, we emit the whole list
const onDraggableChange = (newSteps) => {
emit('update:steps', newSteps)
}
const emitUpdate = () => {
if (props.isNested) {
+8 -20
View File
@@ -10,14 +10,7 @@ import {
LinearScale
} from 'chart.js'
import { Bar } from 'vue-chartjs'
import {
Activity,
Loader2,
CheckCircle,
AlertTriangle,
Send,
Bot
} from 'lucide-vue-next'
import { Activity, Loader2, CheckCircle, AlertTriangle, Send, Bot } from 'lucide-vue-next'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
@@ -105,6 +98,11 @@ const sendMessage = async () => {
}
}
const handleChipClick = (msg) => {
chatInput.value = msg
sendMessage()
}
watch(timeHorizon, () => {
fetchData()
})
@@ -166,20 +164,10 @@ onMounted(() => {
<div v-if="chatHistory.length === 0" class="empty-state">
<p>Ask me anything about your training data!</p>
<div class="chips">
<button
@click="
chatInput = 'Summarize my last 4 weeks of training';
sendMessage();
"
>
<button @click="handleChipClick('Summarize my last 4 weeks of training')">
Summarize last month
</button>
<button
@click="
chatInput = 'Why is my volume increasing?';
sendMessage();
"
>
<button @click="handleChipClick('Why is my volume increasing?')">
Analyze volume trend
</button>
</div>
+17 -11
View File
@@ -3,15 +3,21 @@ import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
resolveSnapshotPath: (testPath, snapshotExtension) => testPath + snapshotExtension,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
resolveSnapshotPath: (testPath, snapshotExtension) => testPath + snapshotExtension,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{js,vue}'],
all: true
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})