UI/logic updates, tests added, backend updated
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
77
Seasoned.Frontend/test/ChatPage.spec.ts
Normal file
77
Seasoned.Frontend/test/ChatPage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
113
Seasoned.Frontend/test/GalleryPage.spec.ts
Normal file
113
Seasoned.Frontend/test/GalleryPage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
54
Seasoned.Frontend/test/IndexPage.spec.ts
Normal file
54
Seasoned.Frontend/test/IndexPage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
93
Seasoned.Frontend/test/LoginPage.spec.ts
Normal file
93
Seasoned.Frontend/test/LoginPage.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
90
Seasoned.Frontend/test/RecipeDisplay.spec.ts
Normal file
90
Seasoned.Frontend/test/RecipeDisplay.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
74
Seasoned.Frontend/test/Uploader.spec.ts
Normal file
74
Seasoned.Frontend/test/Uploader.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user