UI/logic updates, tests added, backend updated

This commit is contained in:
2026-03-18 06:46:45 +00:00
parent b80d2a7379
commit 251e3c5821
27 changed files with 2113 additions and 1142 deletions

View File

@@ -190,6 +190,17 @@ html, body {
border-radius: 4px;
}
.save-success-btn {
opacity: 1 !important;
color: white !important;
cursor: default;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.transition-swing {
transition: all 0.3s ease;
}
.print-btn {
background-color: #3b4e1e !important;
color: #f4e4bc !important;
@@ -208,89 +219,82 @@ html, body {
@media print {
@page {
margin: 0.5in 0.75in !important;
margin: 0 !important;
size: auto;
}
.v-application,
.v-application__wrap,
main.v-main,
.v-container {
padding-top: 0 !important;
margin-top: 0 !important;
position: static !important;
}
.recipe-content {
margin-top: -20px !important;
padding-top: 0 !important;
}
.chat-container,
.v-app-bar,
.no-print,
.separator,
.v-divider,
button,
.v-btn {
display: none !important;
}
body, .recipe-bg, .landing-page, .recipe-card {
body {
background: white !important;
background-image: none !important;
box-shadow: none !important;
color: black !important;
padding: 0.75in !important;
margin: 0 !important;
width: 100% !important;
height: auto !important;
min-height: 0 !important;
box-sizing: border-box !important;
overflow: visible !important;
position: relative !important;
}
.chat-container, .v-app-bar, .no-print, .separator, .v-divider, .recipe-description, button, .v-btn {
display: none !important;
height: 0 !important;
margin: 0 !important;
padding: 0 !important;
}
.v-row,
.v-col,
.v-container,
.v-card,
.recipe-card,
div[class*="v-col"] {
border: none !important;
outline: none !important;
.v-application, .v-application__wrap, main.v-main, .v-container, .recipe-card {
height: auto !important;
min-height: 0 !important;
margin: 0 !important;
padding: 0 !important;
position: static !important;
}
html, body {
height: auto !important;
overflow: visible !important;
}
.v-application {
display: block !important; /* Prevents flex-grow from creating a 2nd page */
}
/* 3. Tighten the Footer (Just in case) */
footer, .brand-subtitle {
page-break-after: avoid !important;
}
.v-card,
.recipe-card,
.v-application,
.v-application__wrap,
[class*="elevation-"] {
box-shadow: none !important;
border: none !important;
background-color: transparent !important;
background-image: none !important;
}
/* 2. Specific fix for Vuetify's "thin" borders */
.v-border-thin,
.border,
.border-sm {
border-width: 0 !important;
}
.v-border-thin,
.border,
.border-sm {
border-width: 0 !important;
}
header.text-center {
margin-top: 0 !important;
padding-top: 0 !important;
margin-bottom: 2px !important;
}
header.text-center img,
.v-img,
[class*="v-img"] {
max-height: 60px !important;
margin-bottom: 2px !important;
}
.brand-subtitle {
margin-bottom: 5px !important;
font-size: 0.7rem !important;
}
.recipe-content {
margin: 0 !important;
padding: 0 !important;
}
header.text-center img, .v-img, [class*="v-img"] {
max-height: 65px !important;
margin-bottom: 2px !important;
}
.recipe-title {
margin-top: 0 !important;
padding-top: 0 !important;
margin-bottom: 45px !important;
margin-bottom: 40px !important;
font-size: 1.6rem !important;
text-align: center;
font-weight: bold !important;
line-height: 1.2 !important;
}
.recipe-content .v-row {
@@ -298,53 +302,41 @@ html, body {
flex-direction: row !important;
flex-wrap: nowrap !important;
width: 100% !important;
gap: 0.4in !important;
gap: 0.5in !important;
align-items: flex-start !important;
margin-top: 0 !important;
padding-top: 0 !important;
}
.recipe-content .v-row > div:first-child {
flex: 0 0 35% !important;
width: 35% !important;
max-width: 35% !important;
padding: 0 !important;
flex: 0 0 33% !important;
max-width: 33% !important;
}
.recipe-content .v-row > div:last-child {
flex: 0 0 60% !important;
width: 60% !important;
max-width: 60% !important;
padding: 0 !important;
margin: 0 !important;
flex: 0 0 62% !important;
max-width: 62% !important;
}
.section-header {
margin-bottom: 12px !important;
padding-bottom: 0 !important;
border-bottom: none !important;
width: 100% !important;
}
* {
border: none !important;
box-shadow: none !important;
outline: none !important;
font-weight: bold !important;
margin-bottom: 15px !important;
text-transform: uppercase;
}
.ingredient-item, .step-text {
font-size: 0.95rem !important;
line-height: 1.3 !important;
color: black !important;
}
.step-number {
border: none !important;
line-height: 1.4 !important;
}
.instruction-step {
margin-bottom: 8px !important;
gap: 8px !important;
margin-bottom: 10px !important;
display: flex !important;
gap: 10px !important;
}
.recipe-content, .v-row, .v-col, * {
overflow: visible !important;
height: auto !important;
}
.step-number, .ingredient-item::before {
@@ -352,12 +344,6 @@ html, body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.recipe-content, .v-row, .v-col {
overflow: visible !important;
height: auto !important;
border: none !important;
}
}
.section-header {

View File

@@ -94,10 +94,11 @@
text-align: center !important;
}
.v-textarea .v-field__input {
font-weight: 500 !important;
color: #2c2925 !important;
.v-textarea .v-field__input, .v-textarea textarea {
font-family: 'Libre Baskerville', serif !important;
font-size: 1.1rem !important;
line-height: 1.6 !important;
color: #2c2925 !important
}
.v-field-label {
@@ -105,6 +106,13 @@
opacity: 0.6 !important;
}
.v-textarea .v-label,
.v-textarea .v-field-label {
font-family: 'Libre Baskerville', serif !important;
font-style: italic;
}
.v-field__outline {
--v-field-border-opacity: 1 !important;
color: #d1c7b7 !important;

View File

@@ -4,11 +4,17 @@
<v-divider class="mb-10 separator"></v-divider>
<h2 class="recipe-title text-center mb-4">{{ recipe.title }}</h2>
<p v-if="recipe.description" class="recipe-description text-center mb-16 text-italic">
{{ recipe.description }}
</p>
<v-img
v-if="recipe.imageUrl"
:src="recipe.imageUrl"
class="recipe-image rounded-lg mb-8 mx-auto"
elevation="2"
max-height="400"
cover
></v-img>
<v-row class="mt-10" no-gutters>
<v-row class="mt-10" density="compact">
<v-col cols="12" md="5" class="pe-md-10">
<div class="section-header justify-center mb-6">
<v-icon icon="mdi-spoon-sugar" class="mr-2" size="small"></v-icon>
@@ -45,26 +51,25 @@
</v-btn>
<v-btn
v-if="!hasSaved"
class="save-recipe-btn px-12"
class="px-12 transition-swing"
size="large"
elevation="0"
:loading="isSaving"
:disabled="hasSaved"
:color="hasSaved ? '#556b2f' : '#5d4a36'"
:class="hasSaved ? 'save-success-btn' : 'save-recipe-btn'"
@click="$emit('save')"
>
<v-icon icon="mdi-content-save-check-outline" class="mr-2"></v-icon>
Save to Collection
<template v-if="!hasSaved">
<v-icon icon="mdi-content-save-check-outline" class="mr-2"></v-icon>
Save to Collection
</template>
<template v-else>
<v-icon icon="mdi-check-decagram" class="mr-2"></v-icon>
Saved in Archives
</template>
</v-btn>
<v-chip
v-else
color="#556b2f"
variant="outlined"
prepend-icon="mdi-check-decagram"
class="pa-6"
>
Saved to Archives
</v-chip>
</v-row>
</div>
</transition>
@@ -75,7 +80,7 @@ import { ref } from 'vue'
import '@/assets/css/app-theme.css'
defineProps({
//recipe: { type: Object, default: null },
recipe: { type: Object, default: null },
isSaving: { type: Boolean, default: false },
hasSaved: { type: Boolean, default: false }
})
@@ -87,7 +92,7 @@ const printRecipe = () => {
}
// mock output
const recipe = ref({
/*const recipe = ref({
title: "Bakery-Style Lemon Blueberry Muffins",
ingredients: [
"2 cups all-purpose flour",
@@ -109,5 +114,5 @@ const recipe = ref({
"Toss the blueberries in a teaspoon of flour, then gently fold them into the batter.",
"Divide the batter evenly into the muffin cups and bake for 18-20 minutes until golden."
]
})
}) */
</script>

View File

@@ -18,6 +18,7 @@
<v-col cols="12" md="11">
<div class="chat-container">
<div class="section-header mb-4 d-flex align-center">
<v-spacer></v-spacer>
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
<span>Ask the Chef</span>
<v-spacer></v-spacer>
@@ -82,9 +83,39 @@ const userQuery = ref('')
const chatLoading = ref(false)
const chatMessages = ref([])
const chatDisplay = ref(null)
const router = ref(false)
const saving = ref(false)
const hasSaved = ref(false)
const isAuthenticated = async () => {
try {
await $fetch('/api/auth/manage/info', { credentials: 'include' })
return true
} catch {
return false
}
}
const saveToCollection = async () => {
if (!recipe.value || hasSaved.value) return
saving.value = true
try {
await $fetch(`${config.public.apiBase}api/recipe/save`, {
method: 'POST',
credentials: 'include',
body: recipe.value
})
hasSaved.value = true
} catch (error) {
console.error("Save failed:", error)
} finally {
saving.value = false
}
}
const askChef = async () => {
if (!userQuery.value.trim()) return

View File

@@ -30,8 +30,15 @@
class="rounded-sm mb-4 d-flex align-center justify-center"
style="border: 1px solid #e8e2d6;"
>
<v-img
v-if="recipe.imageUrl"
:src="recipe.imageUrl"
cover
class="recipe-thumbnail"
></v-img>
<v-icon
:icon="getRecipeIcon(recipe)"
v-else
icon="mdi-camera-outline"
size="80"
color="#d1c7b7"
></v-icon>
@@ -77,6 +84,7 @@
<v-dialog v-model="showDetails" max-width="800" persistent>
<v-card v-if="selectedRecipe" class="recipe-card pa-8">
<v-btn
v-if="!isEditing"
icon="mdi-close"
variant="text"
position="absolute"
@@ -100,7 +108,7 @@
<v-row justify="center" class="px-md-10">
<v-col cols="12" md="5" class="d-flex flex-column align-center">
<div style="width: 100%; max-width: 300px;">
<h3 class="section-header mb-4">
<h3 class="section-header justify-center mb-4">
<v-icon icon="mdi-basket-outline" class="mr-2" size="small"></v-icon>
Ingredients
</h3>
@@ -127,7 +135,7 @@
</v-col>
<v-col cols="12" md="7">
<h3 class="section-header mb-4">
<h3 class="section-header justify-center mb-4">
<v-icon icon="mdi-chef-hat" class="mr-2" size="small"></v-icon>
Instructions
</h3>
@@ -178,6 +186,45 @@
</p>
</footer>
</v-card>
<v-row justify="center" class="mb-4">
<v-col cols="12" class="d-flex flex-column align-center">
<v-hover v-slot="{ isHovering, props }">
<v-card
v-bind="props"
width="200"
height="200"
class="rounded-lg d-flex align-center justify-center cursor-pointer position-relative"
@click="$refs.fileInput.click()"
:elevation="isHovering ? 4 : 1"
style="border: 2px dashed #d1c7b7; background: #fcfaf5;"
>
<v-img
v-if="selectedRecipe.imageUrl"
:src="selectedRecipe.imageUrl"
cover
class="rounded-lg"
></v-img>
<div
v-if="isEditing && (!selectedRecipe.imageUrl || isHovering)"
class="d-flex flex-column align-center justify-center position-absolute"
style="background: rgba(255,255,255,0.7); inset: 0;"
>
<v-icon icon="mdi-camera-plus" color="#556b2f" size="large"></v-icon>
<span class="brand-subtitle" style="font-size: 0.7rem;">Update Photo</span>
</div>
</v-card>
</v-hover>
<input
type="file"
ref="fileInput"
accept="image/*"
style="display: none"
@change="handleImageUpload"
/>
</v-col>
</v-row>
</v-dialog>
</v-container>
</template>
@@ -214,6 +261,17 @@ const fetchRecipes = async () => {
}
}
const handleImageUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
selectedRecipe.value.imageUrl = e.target.result;
};
reader.readAsDataURL(file);
};
const openRecipe = (recipe) => {
selectedRecipe.value = { ...recipe }
@@ -252,25 +310,31 @@ const closeDetails = () => {
const saveChanges = async () => {
try {
const payload = { ...selectedRecipe.value };
if (typeof payload.ingredients === 'string') {
payload.ingredients = payload.ingredients.split('\n').filter(i => i.trim());
}
if (typeof payload.instructions === 'string') {
payload.instructions = payload.instructions.split('\n').filter(i => i.trim());
}
const payload = {
...selectedRecipe.value,
ingredients: typeof selectedRecipe.value.ingredients === 'string'
? selectedRecipe.value.ingredients.split('\n').filter(i => i.trim())
: selectedRecipe.value.ingredients,
instructions: typeof selectedRecipe.value.instructions === 'string'
? selectedRecipe.value.instructions.split('\n').filter(i => i.trim())
: selectedRecipe.value.instructions
};
await $fetch(`${config.public.apiBase}api/recipe/update/${selectedRecipe.value.id}`, {
await $fetch(`${config.public.apiBase}api/recipe/update/${payload.id}`, {
method: 'PUT',
credentials: 'include',
body: payload
body: payload,
credentials: 'include'
});
await fetchRecipes();
const index = recipes.value.findIndex(r => r.id === payload.id);
if (index !== -1) {
recipes.value[index] = { ...payload };
}
closeDetails();
} catch (e) {
console.error("Failed to update recipe:", e);
alert("Could not save changes.");
console.error("The kitchen ledger could not be updated:", e);
alert("Could not save changes. Please try again.");
}
}

View File

@@ -1,6 +1,6 @@
<template>
<v-container fluid class="pa-0 landing-wrapper">
<v-row no-gutters justify="center" align="start" class="pt-6">
<v-row density="compact" justify="center" align="start" class="pt-6">
<v-col cols="12" class="text-center px-4">
<v-card class="recipe-card pa-8 mx-auto mt-2" max-width="900">
@@ -29,7 +29,7 @@
</p>
<v-spacer></v-spacer>
<v-btn v-if="isLoggedIn" to="/uploader" class="mt-12 column-btn">
Got to Uploader
Go to Uploader
</v-btn>
</v-col>
<v-col cols="12" md="4" class="text-center d-flex flex-column align-center">

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,8 @@
"@prisma/client": "^7.4.2",
"axios": "^1.13.6",
"dotenv": "^17.3.1",
"nuxt": "^4.1.3",
"mdi": "^2.2.43",
"nuxt": "^4.1.3",
"prisma": "^6.19.2",
"sass": "^1.97.3",
"vite-plugin-vuetify": "^2.1.3",
@@ -27,11 +27,12 @@
"vuetify-nuxt-module": "^0.19.5"
},
"devDependencies": {
"@nuxt/test-utils": "^4.0.0",
"@types/node": "^25.3.3",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^20.8.3",
"happy-dom": "^20.8.4",
"jsdom": "^28.1.0",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
}
}

View File

@@ -1,8 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
describe('Frontend Setup', () => {
it('checks that 1 + 1 is 2', () => {
expect(1 + 1).toBe(2)
})
})

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import ChatPage from "@/pages/chat.vue"
const vuetify = createVuetify({ components, directives })
global.ResizeObserver = class ResizeObserver {
observe() {} unobserve() {} disconnect() {}
};
global.Element.prototype.scrollTo = vi.fn();
vi.stubGlobal('useRuntimeConfig', () => ({
public: { apiBase: 'http://localhost:5000/' }
}))
const mockRouter = { push: vi.fn(), resolve: vi.fn(() => ({ href: '' })) }
vi.stubGlobal('useRouter', () => mockRouter)
const mockFetch = vi.fn()
vi.stubGlobal('$fetch', mockFetch)
describe('ChatPage.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
global.Element.prototype.scrollTo = vi.fn()
})
const mountOptions = {
global: {
plugins: [vuetify],
stubs: { RecipeDisplay: true },
provide: { 'router': mockRouter }
}
}
it('shows the placeholder when chat is empty', () => {
const wrapper = mount(ChatPage, mountOptions)
expect(wrapper.text()).toContain('"What shall we create today?"')
})
it('adds a user message and clears input on send', async () => {
const wrapper = mount(ChatPage, mountOptions)
const vm = wrapper.vm as any
vm.userQuery = 'How do I make a roux?'
mockFetch.mockResolvedValueOnce({ reply: 'Test', recipe: null })
await vm.askChef()
expect(vm.chatMessages[0].text).toBe('How do I make a roux?')
expect(vm.userQuery).toBe('')
})
it('displays the assistant reply from the .NET API', async () => {
mockFetch.mockResolvedValueOnce({
reply: 'A roux is equal parts flour and fat.',
recipe: null
})
const wrapper = mount(ChatPage, mountOptions)
const vm = wrapper.vm as any
vm.userQuery = 'Tell me about roux'
await vm.askChef()
expect(vm.chatMessages[1].text).toContain('equal parts flour')
})
it('shows error message if the API fails', async () => {
mockFetch.mockRejectedValueOnce(new Error('Backend Down'))
const wrapper = mount(ChatPage, mountOptions)
const vm = wrapper.vm as any
vm.userQuery = 'Help!'
await vm.askChef()
expect(wrapper.text()).toContain('The kitchen is currently closed')
})
})

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import GalleryPage from "@/pages/gallery.vue"
const vuetify = createVuetify({ components, directives })
global.ResizeObserver = class ResizeObserver {
observe() {} unobserve() {} disconnect() {}
};
global.visualViewport = {
width: 1024,
height: 768,
offsetLeft: 0,
offsetTop: 0,
pageLeft: 0,
pageTop: 0,
scale: 1,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
} as unknown as VisualViewport;
vi.stubGlobal('useRuntimeConfig', () => ({
public: { apiBase: 'http://localhost:5000/' }
}))
const mockFetch = vi.fn()
vi.stubGlobal('$fetch', mockFetch)
const mockNavigate = vi.fn()
vi.stubGlobal('navigateTo', mockNavigate)
describe('GalleryPage.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mountOptions = {
global: { plugins: [vuetify] }
}
it('shows loading state initially and then renders recipes', async () => {
mockFetch.mockResolvedValueOnce([
{ id: 1, title: 'Bolognese', createdAt: new Date().toISOString(), ingredients: [], instructions: [] }
])
const wrapper = mount(GalleryPage, mountOptions)
expect(wrapper.text()).toContain('Opening Collection...')
await vi.waitFor(() => {
expect(wrapper.text()).toContain('Bolognese')
})
})
it('enters editing mode and formats arrays into strings', async () => {
mockFetch.mockResolvedValueOnce([
{
id: 1,
title: 'Muffins',
ingredients: ['Flour', 'Sugar'],
instructions: ['Mix', 'Bake'],
createdAt: new Date().toISOString()
}
])
const wrapper = mount(GalleryPage, mountOptions)
await vi.waitFor(() => expect(wrapper.vm.recipes.length).toBe(1))
const vm = wrapper.vm as any
vm.editRecipe(vm.recipes[0])
expect(vm.isEditing).toBe(true)
expect(vm.selectedRecipe.ingredients).toBe('Flour\nSugar')
})
it('shoves updated recipe back to .NET API on saveChanges', async () => {
const mockRecipe = { id: 1, title: 'Old Title', ingredients: 'Water', instructions: 'Boil', createdAt: new Date().toISOString() }
mockFetch.mockResolvedValueOnce([mockRecipe])
const wrapper = mount(GalleryPage, mountOptions)
await vi.waitFor(() => expect(wrapper.vm.recipes.length).toBe(1))
const vm = wrapper.vm as any
vm.selectedRecipe = { ...mockRecipe, title: 'New Title' }
vm.isEditing = true
mockFetch.mockResolvedValueOnce({ success: true })
await vm.saveChanges()
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('api/recipe/update/1'),
expect.objectContaining({ method: 'PUT' })
)
expect(vm.recipes[0].title).toBe('New Title')
})
it('redirects to login if API returns 401', async () => {
mockFetch.mockRejectedValueOnce({ status: 401 })
mount(GalleryPage, mountOptions)
await vi.waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login')
})
})
})

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import IndexPage from '@/pages/index.vue'
const vuetify = createVuetify({ components, directives })
const mockIsLoggedIn = ref(false)
vi.stubGlobal('useState', vi.fn((key, init) => {
if (key === 'isLoggedIn') return mockIsLoggedIn
return ref(init ? init() : null)
}))
describe('IndexPage.vue', () => {
it('renders the brand title and subtitle', () => {
const wrapper = mount(IndexPage, {
global: {
plugins: [vuetify],
stubs: { 'nuxt-link': true }
}
})
expect(wrapper.text()).toContain('Seasoned')
expect(wrapper.text()).toContain('A Recipe Generator')
})
it('shows "Get Started" button when NOT logged in', () => {
const wrapper = mount(IndexPage, {
global: { plugins: [vuetify] }
})
expect(wrapper.text()).toContain('Get Started')
expect(wrapper.text()).not.toContain('Talk to Chef')
})
it('hides "Get Started" and shows action buttons when logged in', async () => {
const wrapper = mount(IndexPage, {
global: { plugins: [vuetify] }
})
const isLoggedIn = useState('isLoggedIn')
isLoggedIn.value = true
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Talk to Chef')
expect(wrapper.text()).toContain('Go to Uploader')
expect(wrapper.text()).not.toContain('Get Started')
})
})

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import LoginPage from "@/pages/login.vue"
const vuetify = createVuetify({ components, directives })
// Standard Mocks
global.ResizeObserver = class ResizeObserver {
observe() {} unobserve() {} disconnect() {}
};
vi.stubGlobal('useRuntimeConfig', () => ({
public: { apiBase: 'http://localhost:5000/' }
}))
const mockFetch = vi.fn()
vi.stubGlobal('$fetch', mockFetch)
const mockNavigate = vi.fn()
vi.stubGlobal('navigateTo', mockNavigate)
// Mock Nuxt's useState
vi.stubGlobal('useState', () => ({ value: false }))
describe('LoginPage.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mountOptions = {
global: { plugins: [vuetify] }
}
it('switches between Login and Register modes', async () => {
const wrapper = mount(LoginPage, mountOptions)
// Default is Login
expect(wrapper.find('h1').text()).toBe('Sign In')
expect(wrapper.find('input[label="Confirm Password"]').exists()).toBe(false)
// Click toggle
await wrapper.find('.auth-toggle-btn').trigger('click')
expect(wrapper.find('h1').text()).toBe('Join Us')
// V-expand-transition might need a tick or we check the v-if logic
expect(wrapper.vm.isLogin).toBe(false)
})
it('shows error if passwords do not match in registration mode', async () => {
const wrapper = mount(LoginPage, mountOptions)
const vm = wrapper.vm as any
vm.isLogin = false
vm.email = 'test@test.com'
vm.password = 'password123'
vm.confirmPassword = 'differentPassword'
await vm.handleAuth()
expect(vm.errorMessage).toBe('Passwords do not match.')
expect(mockFetch).not.toHaveBeenCalled()
})
it('calls login API and redirects on success', async () => {
mockFetch.mockResolvedValueOnce({ token: 'fake-token' })
const wrapper = mount(LoginPage, mountOptions)
const vm = wrapper.vm as any
vm.email = 'chef@seasoned.com'
vm.password = 'secret'
await vm.handleAuth()
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('api/auth/login'),
expect.any(Object)
)
expect(mockNavigate).toHaveBeenCalledWith('/')
})
it('displays specific error for 401 Unauthorized', async () => {
mockFetch.mockRejectedValueOnce({ status: 401 })
const wrapper = mount(LoginPage, mountOptions)
const vm = wrapper.vm as any
await vm.handleAuth()
expect(vm.errorMessage).toContain('Invalid email or password')
})
})

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import RecipeDisplay from '../app/components/RecipeDisplay.vue'
const vuetify = createVuetify({
components,
directives,
})
describe('RecipeDisplay.vue', () => {
const mockRecipe = {
title: 'Bakery-Style Muffins',
description: 'Fresh from the oven.',
ingredients: ['2 cups flour', '1 cup sugar'],
instructions: ['Preheat oven', 'Bake muffins'],
imageUrl: 'data:image/png;base64,header_captured_image'
}
it('renders the title and all ingredients correctly', () => {
const wrapper = mount(RecipeDisplay, {
props: { recipe: mockRecipe },
global: { plugins: [vuetify] }
})
expect(wrapper.find('.recipe-title').text()).toBe('Bakery-Style Muffins')
const ingredientItems = wrapper.findAll('.ingredient-item')
expect(ingredientItems).toHaveLength(2)
expect(ingredientItems[0].text()).toContain('2 cups flour')
})
it('displays the recipe image when imageUrl is provided', () => {
const wrapper = mount(RecipeDisplay, {
props: { recipe: mockRecipe },
global: { plugins: [vuetify] }
})
const img = wrapper.findComponent({ name: 'VImg' })
expect(img.exists()).toBe(true)
expect(img.props('src')).toBe(mockRecipe.imageUrl)
})
it('emits "save" when the save button is clicked', async () => {
const wrapper = mount(RecipeDisplay, {
props: {
recipe: mockRecipe,
isSaving: false,
hasSaved: false
},
global: {
plugins: [vuetify]
}
})
const saveBtn = wrapper.find('.save-recipe-btn')
await saveBtn.trigger('click')
expect(wrapper.emitted()).toHaveProperty('save')
})
it('shows the "Saved in Archives" state when hasSaved is true', async () => {
const recipe = { title: 'Bakery-Style Muffins', ingredients: [], instructions: [] }
const wrapper = mount(RecipeDisplay, {
props: {
recipe,
hasSaved: true
},
global: { plugins: [vuetify] }
})
expect(wrapper.text()).toContain('Saved in Archives')
})
it('triggers the browser print dialog when the print button is clicked', async () => {
const printSpy = vi.spyOn(window, 'print').mockImplementation(() => {})
const wrapper = mount(RecipeDisplay, {
props: { recipe: mockRecipe },
global: { plugins: [vuetify] }
})
const printBtn = wrapper.find('.print-btn')
await printBtn.trigger('click')
expect(printSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import Uploader from "@/pages/uploader.vue"
const vuetify = createVuetify({ components, directives })
global.ResizeObserver = class ResizeObserver {
observe() {} unobserve() {} disconnect() {}
};
vi.stubGlobal('useRuntimeConfig', () => ({
public: { apiBase: 'http://localhost:5000/' }
}))
const mockRouter = { push: vi.fn() }
vi.stubGlobal('useRouter', () => mockRouter)
const mockFetch = vi.fn()
vi.stubGlobal('$fetch', mockFetch)
describe('Uploader.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mountOptions = {
global: {
plugins: [vuetify],
stubs: { RecipeDisplay: true },
provide: { 'router': mockRouter }
}
}
it('renders the drop zone and upload button', () => {
const wrapper = mount(Uploader, mountOptions)
expect(wrapper.text()).toContain('Analyze Recipe')
})
it('shows the filename when a file is selected', async () => {
const wrapper = mount(Uploader, mountOptions)
const file = new File(['(data)'], 'grandmas-cookies.png', { type: 'image/png' })
const vm = wrapper.vm as any
vm.files = [file]
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('grandmas-cookies.png')
})
it('shows loading state on the button when analyzing', async () => {
const wrapper = mount(Uploader, mountOptions)
const vm = wrapper.vm as any
vm.loading = true
await wrapper.vm.$nextTick()
const btn = wrapper.find('.analyze-btn')
expect(btn.attributes('class')).toContain('v-btn--loading')
})
it('restores a recipe from localStorage on mount', async () => {
const savedRecipe = { title: 'Restored Cake', ingredients: [], instructions: [] }
localStorage.setItem('pending_recipe', JSON.stringify(savedRecipe))
const wrapper = mount(Uploader, mountOptions)
const vm = wrapper.vm as any
expect(vm.recipe.title).toBe('Restored Cake')
expect(localStorage.getItem('pending_recipe')).toBeNull()
})
})

View File

@@ -1,16 +1,28 @@
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
css: false,
server: {
deps: {
inline: [/@exodus\/bytes/, /html-encoding-sniffer/],
inline: [
/@exodus\/bytes/,
/html-encoding-sniffer/,
/vuetify/
],
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './app'),
'~': path.resolve(__dirname, './app')
},
},
})