diff --git a/VueApp/src/Effort/__tests__/course-add-dialog.test.ts b/VueApp/src/Effort/__tests__/course-add-dialog.test.ts new file mode 100644 index 00000000..d70f0171 --- /dev/null +++ b/VueApp/src/Effort/__tests__/course-add-dialog.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ref } from "vue" +import { setActivePinia, createPinia } from "pinia" + +/** + * Tests for CourseAddDialog error handling and validation behavior. + * + * These tests validate that the component properly handles errors + * when creating courses manually. + * + * The actual component UI is tested via Playwright MCP (see SMOKETEST-EFFORT-COURSE.md). + */ + +// Mock the effort service +const mockCreateCourse = vi.fn() +vi.mock("../services/effort-service", () => ({ + effortService: { + createCourse: (...args: unknown[]) => mockCreateCourse(...args), + }, +})) + +// Validation helper functions (moved outside tests for consistent-function-scoping) +const isValidEnrollment = (v: number) => v >= 0 && Number.isInteger(v) +const isValidUnits = (v: number) => v >= 0 + +describe("CourseAddDialog - Error Handling", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe("Create Course Error States", () => { + it("should capture error message when API returns success: false", () => { + const error = ref(null) + + const result = { success: false, error: "Course already exists for this term" } + + if (!result.success) { + error.value = result.error ?? "Failed to create course" + } + + expect(error.value).toBe("Course already exists for this term") + }) + + it("should use default message when API returns no error message", () => { + const error = ref(null) + + const result = { success: false, error: null } + + if (!result.success) { + error.value = result.error ?? "Failed to create course" + } + + expect(error.value).toBe("Failed to create course") + }) + }) + + describe("Service Mock Behavior", () => { + it("effortService.createCourse returns success with course data", async () => { + mockCreateCourse.mockResolvedValue({ success: true, course: { id: 1 } }) + + const result = await mockCreateCourse() + + expect(result.success).toBeTruthy() + expect(result.course.id).toBe(1) + }) + + it("effortService.createCourse returns failure with error message", async () => { + mockCreateCourse.mockResolvedValue({ + success: false, + error: "Course already exists", + }) + + const result = await mockCreateCourse() + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Course already exists") + }) + + it("effortService.createCourse rejects with exception", async () => { + mockCreateCourse.mockRejectedValue(new Error("Database error")) + + await expect(mockCreateCourse()).rejects.toThrow("Database error") + }) + }) + + describe("Form Data Normalization", () => { + it("should trim and uppercase subject code", () => { + const input = " dvm " + const normalized = input.trim().toUpperCase() + + expect(normalized).toBe("DVM") + }) + + it("should trim and uppercase course number", () => { + const input = " 443r " + const normalized = input.trim().toUpperCase() + + expect(normalized).toBe("443R") + }) + + it("should trim CRN", () => { + const input = " 12345 " + const normalized = input.trim() + + expect(normalized).toBe("12345") + }) + }) + + describe("Validation Logic", () => { + it("should validate CRN is 5 digits", () => { + const crnRegex = /^\d{5}$/ + + expect(crnRegex.test("12345")).toBeTruthy() + expect(crnRegex.test("1234")).toBeFalsy() + expect(crnRegex.test("123456")).toBeFalsy() + expect(crnRegex.test("abcde")).toBeFalsy() + }) + + it("should validate enrollment is non-negative integer", () => { + expect(isValidEnrollment(0)).toBeTruthy() + expect(isValidEnrollment(25)).toBeTruthy() + expect(isValidEnrollment(-1)).toBeFalsy() + expect(isValidEnrollment(2.5)).toBeFalsy() + }) + + it("should validate units is non-negative", () => { + expect(isValidUnits(0)).toBeTruthy() + expect(isValidUnits(4)).toBeTruthy() + expect(isValidUnits(0.5)).toBeTruthy() + expect(isValidUnits(-1)).toBeFalsy() + }) + + it("should validate subject code max length", () => { + const maxLength = 3 + + expect("DVM".length <= maxLength).toBeTruthy() + expect("DVMX".length <= maxLength).toBeFalsy() + }) + }) + + describe("Form Reset Behavior", () => { + it("should reset form data to defaults", () => { + const defaultDepartment = "APC" + const formData = ref({ + subjCode: "OLD", + crseNumb: "999", + seqNumb: "002", + crn: "99999", + enrollment: 50, + units: 5, + custDept: "VME", + }) + + // Simulate reset logic + formData.value = { + subjCode: "", + crseNumb: "", + seqNumb: "", + crn: "", + enrollment: 0, + units: 0, + custDept: defaultDepartment, + } + + expect(formData.value.subjCode).toBe("") + expect(formData.value.enrollment).toBe(0) + expect(formData.value.custDept).toBe("APC") + }) + }) +}) diff --git a/VueApp/src/Effort/__tests__/course-edit-dialog.test.ts b/VueApp/src/Effort/__tests__/course-edit-dialog.test.ts new file mode 100644 index 00000000..e33a1c7e --- /dev/null +++ b/VueApp/src/Effort/__tests__/course-edit-dialog.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ref } from "vue" +import { setActivePinia, createPinia } from "pinia" + +/** + * Tests for CourseEditDialog error handling and validation behavior. + * + * These tests validate that the component properly handles errors + * when updating courses. + * + * The actual component UI is tested via Playwright MCP (see SMOKETEST-EFFORT-COURSE.md). + */ + +// Mock the effort service +const mockUpdateCourse = vi.fn() +const mockUpdateCourseEnrollment = vi.fn() +vi.mock("../services/effort-service", () => ({ + effortService: { + updateCourse: (...args: unknown[]) => mockUpdateCourse(...args), + updateCourseEnrollment: (...args: unknown[]) => mockUpdateCourseEnrollment(...args), + }, +})) + +// Validation helper functions (moved outside tests for consistent-function-scoping) +const isRCourse = (crseNumb: string) => crseNumb.toUpperCase().endsWith("R") +const isValidEnrollment = (v: number) => v >= 0 +const isValidUnits = (v: number) => v >= 0 + +describe("CourseEditDialog - Error Handling", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe("Update Course Error States", () => { + it("should capture error message when API returns success: false", () => { + const error = ref(null) + + const result = { success: false, error: "Failed to update course" } + + if (!result.success) { + error.value = result.error ?? "Failed to update course" + } + + expect(error.value).toBe("Failed to update course") + }) + + it("should use default message when API returns no error message", () => { + const error = ref(null) + + const result = { success: false, error: null } + + if (!result.success) { + error.value = result.error ?? "Failed to update course" + } + + expect(error.value).toBe("Failed to update course") + }) + }) + + describe("Service Mock Behavior - Full Update", () => { + it("effortService.updateCourse returns success", async () => { + mockUpdateCourse.mockResolvedValue({ success: true }) + + const result = await mockUpdateCourse(1, { enrollment: 30, units: 4, custDept: "DVM" }) + + expect(result.success).toBeTruthy() + }) + + it("effortService.updateCourse returns failure with error message", async () => { + mockUpdateCourse.mockResolvedValue({ + success: false, + error: "Invalid department code", + }) + + const result = await mockUpdateCourse() + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Invalid department code") + }) + + it("effortService.updateCourse rejects with exception", async () => { + mockUpdateCourse.mockRejectedValue(new Error("Database error")) + + await expect(mockUpdateCourse()).rejects.toThrow("Database error") + }) + }) + + describe("Service Mock Behavior - Enrollment Only", () => { + it("effortService.updateCourseEnrollment returns success", async () => { + mockUpdateCourseEnrollment.mockResolvedValue({ success: true }) + + const result = await mockUpdateCourseEnrollment(2, 75) + + expect(result.success).toBeTruthy() + }) + + it("effortService.updateCourseEnrollment returns failure", async () => { + mockUpdateCourseEnrollment.mockResolvedValue({ + success: false, + error: "Cannot update enrollment for non-R course", + }) + + const result = await mockUpdateCourseEnrollment() + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Cannot update enrollment for non-R course") + }) + }) + + describe("R-Course Detection Logic", () => { + it("should identify R-course by course number suffix", () => { + expect(isRCourse("443R")).toBeTruthy() + expect(isRCourse("443r")).toBeTruthy() + expect(isRCourse("443")).toBeFalsy() + expect(isRCourse("R443")).toBeFalsy() + }) + }) + + describe("Validation Logic", () => { + it("should validate enrollment is non-negative", () => { + expect(isValidEnrollment(0)).toBeTruthy() + expect(isValidEnrollment(30)).toBeTruthy() + expect(isValidEnrollment(-1)).toBeFalsy() + }) + + it("should validate units is non-negative", () => { + expect(isValidUnits(0)).toBeTruthy() + expect(isValidUnits(4)).toBeTruthy() + expect(isValidUnits(-1)).toBeFalsy() + }) + }) + + describe("Form Data Population", () => { + it("should populate form with course data", () => { + const course = { + id: 1, + enrollment: 20, + units: 4, + custDept: "DVM", + } + + const formData = ref({ + enrollment: 0, + units: 0, + custDept: "", + }) + + // Simulate form population + formData.value = { + enrollment: course.enrollment, + units: course.units, + custDept: course.custDept, + } + + expect(formData.value.enrollment).toBe(20) + expect(formData.value.units).toBe(4) + expect(formData.value.custDept).toBe("DVM") + }) + + it("should handle null course gracefully", () => { + const course = null + const formData = ref({ + enrollment: 0, + units: 0, + custDept: "", + }) + + // Simulate null handling + if (course) { + formData.value = { + enrollment: course.enrollment, + units: course.units, + custDept: course.custDept, + } + } + + // Form should remain unchanged + expect(formData.value.enrollment).toBe(0) + }) + }) + + describe("Edit Mode Selection", () => { + it("should use updateCourseEnrollment for enrollment-only mode", async () => { + const enrollmentOnly = true + const courseId = 2 + const newEnrollment = 75 + + if (enrollmentOnly) { + mockUpdateCourseEnrollment.mockResolvedValue({ success: true }) + await mockUpdateCourseEnrollment(courseId, newEnrollment) + } else { + await mockUpdateCourse(courseId, {}) + } + + expect(mockUpdateCourseEnrollment).toHaveBeenCalledWith(2, 75) + expect(mockUpdateCourse).not.toHaveBeenCalled() + }) + + it("should use updateCourse for full edit mode", async () => { + const enrollmentOnly = false + const courseId = 1 + const updateData = { enrollment: 30, units: 4, custDept: "DVM" } + + if (enrollmentOnly) { + await mockUpdateCourseEnrollment(courseId, updateData.enrollment) + } else { + mockUpdateCourse.mockResolvedValue({ success: true }) + await mockUpdateCourse(courseId, updateData) + } + + expect(mockUpdateCourse).toHaveBeenCalledWith(1, updateData) + expect(mockUpdateCourseEnrollment).not.toHaveBeenCalled() + }) + }) +}) diff --git a/VueApp/src/Effort/__tests__/course-import-dialog.test.ts b/VueApp/src/Effort/__tests__/course-import-dialog.test.ts new file mode 100644 index 00000000..e9cfdb80 --- /dev/null +++ b/VueApp/src/Effort/__tests__/course-import-dialog.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { ref } from "vue" +import { setActivePinia, createPinia } from "pinia" + +/** + * Tests for CourseImportDialog error handling behavior. + * + * These tests validate that the component properly displays error feedback + * to users when import operations fail, rather than showing generic errors. + * + * The actual component UI is tested via Playwright MCP (see SMOKETEST-EFFORT-COURSE.md). + */ + +// Mock the effort service +const mockSearchBannerCourses = vi.fn() +const mockImportCourse = vi.fn() +vi.mock("../services/effort-service", () => ({ + effortService: { + searchBannerCourses: (...args: unknown[]) => mockSearchBannerCourses(...args), + importCourse: (...args: unknown[]) => mockImportCourse(...args), + }, +})) + +describe("CourseImportDialog - Error Handling", () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe("Import Error States", () => { + it("should capture error message when API returns success: false", () => { + const importError = ref(null) + + // Simulate the error handling logic from the component + const result = { success: false, error: "Course DVM 443 with 4 units already exists for this term" } + + if (!result.success) { + importError.value = result.error ?? "Failed to import course" + } + + expect(importError.value).toBe("Course DVM 443 with 4 units already exists for this term") + }) + + it("should use default message when API returns no error message", () => { + const importError = ref(null) + + const result = { success: false, error: null } + + if (!result.success) { + importError.value = result.error ?? "Failed to import course" + } + + expect(importError.value).toBe("Failed to import course") + }) + + it("should capture error message from thrown exception", () => { + const importError = ref(null) + + // Simulate exception handling logic + try { + throw new Error("Failed to import course. Please check all field values are valid.") + } catch (err) { + importError.value = err instanceof Error ? err.message : "Failed to import course" + } + + expect(importError.value).toBe("Failed to import course. Please check all field values are valid.") + }) + + it("should handle non-Error exceptions gracefully", () => { + const importError = ref(null) + + // Simulate handling a non-Error value (e.g., from external code) + const nonErrorValue: string = "string error" + importError.value = nonErrorValue instanceof Error ? nonErrorValue.message : "Failed to import course" + + expect(importError.value).toBe("Failed to import course") + }) + }) + + describe("Search Error States", () => { + it("should capture search error from thrown exception", () => { + const searchError = ref("") + + try { + throw new Error("Search failed") + } catch (err) { + searchError.value = err instanceof Error ? err.message : "Error searching for courses" + } + + expect(searchError.value).toBe("Search failed") + }) + + it("should use default message for non-Error exceptions", () => { + const searchError = ref("") + + // Simulate handling a non-Error value (e.g., from external code) + const nonErrorValue: string = "unknown error" + searchError.value = nonErrorValue instanceof Error ? nonErrorValue.message : "Error searching for courses" + + expect(searchError.value).toBe("Error searching for courses") + }) + }) + + describe("Service Mock Behavior", () => { + it("effortService.importCourse rejects with specific error message", async () => { + mockImportCourse.mockRejectedValue( + new Error("Failed to import course. Please check all field values are valid."), + ) + + await expect(mockImportCourse()).rejects.toThrow("Failed to import course") + }) + + it("effortService.importCourse rejects with duplicate error message", async () => { + mockImportCourse.mockRejectedValue(new Error("Course DVM 443 with 4 units already exists for this term")) + + await expect(mockImportCourse()).rejects.toThrow("already exists") + }) + + it("effortService.searchBannerCourses rejects with error message", async () => { + mockSearchBannerCourses.mockRejectedValue(new Error("Search failed")) + + await expect(mockSearchBannerCourses()).rejects.toThrow("Search failed") + }) + + it("effortService.importCourse returns success with course data", async () => { + mockImportCourse.mockResolvedValue({ success: true, course: { id: 1, crn: "12345" } }) + + const result = await mockImportCourse() + + expect(result.success).toBeTruthy() + expect(result.course.id).toBe(1) + }) + + it("effortService.importCourse returns failure with error message", async () => { + mockImportCourse.mockResolvedValue({ + success: false, + error: "Course already exists", + }) + + const result = await mockImportCourse() + + expect(result.success).toBeFalsy() + expect(result.error).toBe("Course already exists") + }) + }) + + describe("Error Reset Behavior", () => { + it("should clear import error when starting new import", () => { + const importError = ref("Previous error") + + // Simulate the startImport logic + importError.value = null + + expect(importError.value).toBeNull() + }) + + it("should clear search error when dialog opens", () => { + const searchError = ref("Previous search error") + + // Simulate the dialog open watcher logic + searchError.value = "" + + expect(searchError.value).toBe("") + }) + }) +}) diff --git a/VueApp/src/Effort/components/CourseAddDialog.vue b/VueApp/src/Effort/components/CourseAddDialog.vue new file mode 100644 index 00000000..b0e6e154 --- /dev/null +++ b/VueApp/src/Effort/components/CourseAddDialog.vue @@ -0,0 +1,213 @@ + + + diff --git a/VueApp/src/Effort/components/CourseEditDialog.vue b/VueApp/src/Effort/components/CourseEditDialog.vue new file mode 100644 index 00000000..fa398133 --- /dev/null +++ b/VueApp/src/Effort/components/CourseEditDialog.vue @@ -0,0 +1,163 @@ + + + diff --git a/VueApp/src/Effort/components/CourseImportDialog.vue b/VueApp/src/Effort/components/CourseImportDialog.vue new file mode 100644 index 00000000..c360cf5a --- /dev/null +++ b/VueApp/src/Effort/components/CourseImportDialog.vue @@ -0,0 +1,457 @@ + + + diff --git a/VueApp/src/Effort/components/EffortLeftNav.vue b/VueApp/src/Effort/components/EffortLeftNav.vue index 178d8ff2..7450f5c6 100644 --- a/VueApp/src/Effort/components/EffortLeftNav.vue +++ b/VueApp/src/Effort/components/EffortLeftNav.vue @@ -66,7 +66,11 @@ v-if="hasManageTerms" clickable v-ripple - :to="{ name: 'TermManagement' }" + :to=" + currentTerm + ? { name: 'TermManagement', query: { termCode: currentTerm.termCode } } + : { name: 'TermManagement' } + " class="leftNavLink" > @@ -74,12 +78,29 @@ - + + + + Courses + + + + @@ -117,7 +138,7 @@ + + diff --git a/VueApp/src/Effort/pages/EffortHome.vue b/VueApp/src/Effort/pages/EffortHome.vue index f0e68ee6..41e3b606 100644 --- a/VueApp/src/Effort/pages/EffortHome.vue +++ b/VueApp/src/Effort/pages/EffortHome.vue @@ -1,7 +1,37 @@ - + diff --git a/VueApp/src/Effort/pages/TermManagement.vue b/VueApp/src/Effort/pages/TermManagement.vue index 4f3288ec..a9968257 100644 --- a/VueApp/src/Effort/pages/TermManagement.vue +++ b/VueApp/src/Effort/pages/TermManagement.vue @@ -269,10 +269,10 @@
- Back to Term Selection + {{ currentTermCode ? "Back" : "Back to Term Selection" }}
@@ -280,16 +280,27 @@