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:
+21
-25
@@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Generated
+183
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user