diff --git a/src/src/lib/components/annotations/3dAnnotations.svelte b/src/src/lib/components/annotations/3dAnnotations.svelte index 780a39ae..8fa3ac12 100644 --- a/src/src/lib/components/annotations/3dAnnotations.svelte +++ b/src/src/lib/components/annotations/3dAnnotations.svelte @@ -1,221 +1,260 @@ - -
- - - - - - {#if annotationMode && activePoint} -
- - -
- {/if} -
- - - \ No newline at end of file + import { onMount } from 'svelte'; + import * as THREE from 'three'; + import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; + import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; + import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; + import Menu from '$lib/components/hotspot/3dMenu.svelte'; + import { Button } from 'flowbite-svelte'; + + + let canvas: HTMLCanvasElement; + let camera: THREE.PerspectiveCamera, scene: THREE.Scene, renderer: THREE.WebGLRenderer; + let controls: OrbitControls; + let labelRenderer: CSS2DRenderer; + let raycaster: THREE.Raycaster; + let mouse: THREE.Vector2; + let annotationMode = false; // Toggle for annotation mode + let annotationText = ''; + let activePoint: THREE.Vector3 | null = null; + let tooltipX: number = 0; + let tooltipY: number = 0; + + export let data: { + role: string; + models: { title: string; file_path: string; description: string }[]; + }; + + let { models } = data; + let selectedModel: string | null = null; + const annotations: { + [key: string]: { position: THREE.Vector3; text: string; labelDiv: HTMLDivElement }; + } = {}; + + function toggleAnnotationMode() { + annotationMode = !annotationMode; + if (!annotationMode) { + activePoint = null; // Clear active point when exiting annotation mode + } + } + + function addAnnotation() { + if (annotationText.trim() && activePoint) { + createAnnotation(activePoint, annotationText); + annotationText = ''; // Clear text after adding + activePoint = null; // Clear active point + } + } + + function createAnnotation(position: THREE.Vector3, text: string) { + // Create a circle as a THREE.Sprite + const circleTexture = new THREE.TextureLoader().load('/images/circle.png'); + const spriteMaterial = new THREE.SpriteMaterial({ + map: circleTexture, + depthTest: false, + depthWrite: false, + sizeAttenuation: false + }); + const sprite = new THREE.Sprite(spriteMaterial); + sprite.position.copy(position); + sprite.scale.set(0.05, 0.05, 0.05); + scene.add(sprite); + + const labelDiv = document.createElement('div'); + labelDiv.className = 'annotation-label'; + labelDiv.textContent = text; + const label = new CSS2DObject(labelDiv); + label.position.copy(position); + scene.add(label); + + annotations[text] = { position, text, labelDiv }; + } + + function onMouseClick(event: MouseEvent) { + if (!annotationMode) return; + + const rect = canvas.getBoundingClientRect(); + mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + + raycaster.setFromCamera(mouse, camera); + const intersects = raycaster.intersectObjects(scene.children, true); + + if (intersects.length > 0) { + const point = intersects[0].point; + activePoint = point; + + const vector = new THREE.Vector3(); + vector.copy(activePoint).project(camera); + + const canvas = renderer.domElement; + const widthHalf = 0.5 * canvas.width; + const heightHalf = 0.5 * canvas.height; + + tooltipX = vector.x * widthHalf + widthHalf; + tooltipY = -(vector.y * heightHalf) + heightHalf; + } + } + + onMount(() => { + initScene(); + animate(); + + window.addEventListener('click', onMouseClick); + }); + + function initScene() { + scene = new THREE.Scene(); + camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); + camera.position.z = 5; + + renderer = new THREE.WebGLRenderer({ canvas }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setClearColor(0xffffff); + + labelRenderer = new CSS2DRenderer(); + labelRenderer.setSize(window.innerWidth, window.innerHeight); + labelRenderer.domElement.style.position = 'absolute'; + labelRenderer.domElement.style.top = '0'; + labelRenderer.domElement.style.pointerEvents = 'none'; + document.body.appendChild(labelRenderer.domElement); + + raycaster = new THREE.Raycaster(); + mouse = new THREE.Vector2(); + + const ambientLight = new THREE.AmbientLight(0x404040); + scene.add(ambientLight); + const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5); + directionalLight.position.set(1, 1, 1).normalize(); + scene.add(directionalLight); + + controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.25; + controls.enableRotate = true; + + window.addEventListener('resize', onWindowResize, false); + } + + function loadModel(file_path: string) { + const loader = new GLTFLoader(); + loader.load(file_path, (gltf) => { + scene.add(gltf.scene); + }); + } + + function handleModelSelection(file_path: string) { + selectedModel = file_path; + localStorage.setItem('selectedModel', selectedModel); + loadModel(file_path); + } + + function animate() { + requestAnimationFrame(animate); + + // Get the canvas's bounding rect + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const canvasWidth = rect.width; + const canvasHeight = rect.height; + + Object.values(annotations).forEach(({ position, labelDiv }) => { + const spriteScreenPosition = position.clone().project(camera); + + // normalized coordinates to pixel coordinates + const widthHalf = canvasWidth / 2; + const heightHalf = canvasHeight / 2; + const spriteX = spriteScreenPosition.x * widthHalf + widthHalf; + const spriteY = -(spriteScreenPosition.y * heightHalf) + heightHalf; + + // Update the label's position + labelDiv.style.position = 'absolute'; + labelDiv.style.left = `${spriteX + rect.left}px`; + labelDiv.style.top = `${spriteY + rect.top}px`; + + console.log(`Sprite Position: ${spriteX}, ${spriteY}`); + console.log(`Canvas Bounds: ${rect.left}, ${rect.top}, ${rect.width}, ${rect.height}`); + + // Show/hide labels based on visibility + if ( + spriteScreenPosition.z < 0 || + spriteX < 0 || + spriteX > canvasWidth || + spriteY < 0 || + spriteY > canvasHeight + ) { + labelDiv.style.display = 'none'; + } else { + labelDiv.style.display = 'block'; + } + }); + + // Render the scene and labels + renderer.render(scene, camera); + labelRenderer.render(scene, camera); + } + + function onWindowResize() { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + labelRenderer.setSize(window.innerWidth, window.innerHeight); + } + + +
+ + + + + + {#if annotationMode && activePoint} +
+ + +
+ {/if} +
+ + diff --git a/src/src/routes/(app)/workspaces/[workspace]/quizzes/[quiz]/+page.server.ts b/src/src/routes/(app)/workspaces/[workspace]/quizzes/[quiz]/+page.server.ts index f5685adb..8e3f7ab6 100644 --- a/src/src/routes/(app)/workspaces/[workspace]/quizzes/[quiz]/+page.server.ts +++ b/src/src/routes/(app)/workspaces/[workspace]/quizzes/[quiz]/+page.server.ts @@ -38,7 +38,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { content: option.content, points: option.points })) - : null // if null + : null })), workspaceID, models diff --git a/src/src/routes/(app)/workspaces/[workspace]/quizzes/page.server.test.ts b/src/src/routes/(app)/workspaces/[workspace]/quizzes/page.server.test.ts index 31350fdd..21430624 100644 --- a/src/src/routes/(app)/workspaces/[workspace]/quizzes/page.server.test.ts +++ b/src/src/routes/(app)/workspaces/[workspace]/quizzes/page.server.test.ts @@ -1,4 +1,4 @@ -import { fail } from '@sveltejs/kit'; + import { describe, it, expect, vi, beforeEach } from 'vitest'; //import mongoose from 'mongoose'; @@ -116,48 +116,5 @@ describe('Quizzes Management', () => { expect(Quizzes.findByIdAndDelete).toHaveBeenCalledWith('123'); }); }); - - describe('actions.toggleAvailability', () => { - it('should toggle quiz availability successfully', async () => { - const mockFormData = new FormData(); - mockFormData.append('quizId', '123'); - mockFormData.append('isAvailable', 'true'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(mockFormData) - }; - - const mockQuiz = { - isAvailable: false, - save: vi.fn() - }; - - (Quizzes.findById as any).mockResolvedValue(mockQuiz); - - const result = await quizzesModule.actions.toggleAvailability({ - request: mockRequest - } as any); - - expect(result).toEqual({ success: true }); - expect(mockQuiz.isAvailable).toBe(true); - expect(mockQuiz.save).toHaveBeenCalled(); - }); - - it('should fail if quiz not found', async () => { - const mockFormData = new FormData(); - mockFormData.append('quizId', '123'); - mockFormData.append('isAvailable', 'true'); - - const mockRequest = { - formData: vi.fn().mockResolvedValue(mockFormData) - }; - - (Quizzes.findById as any).mockResolvedValue(null); - - await quizzesModule.actions.toggleAvailability({ request: mockRequest } as any); - - expect(fail).toHaveBeenCalledWith(404, { error: 'Quiz not found' }); - }); - }); }); }); diff --git a/src/src/tests/unit/components/modal/createQuiz.test.ts b/src/src/tests/unit/components/modal/createQuiz.test.ts new file mode 100644 index 00000000..b599313e --- /dev/null +++ b/src/src/tests/unit/components/modal/createQuiz.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { render, fireEvent } from '@testing-library/svelte'; +import ModalComponent from '$lib/components/modals/quizzes/Add.svelte'; + +describe('ModalComponent - Add Quiz Modal', () => { + const defaultProps = { + open: true + }; + + it('should open modal when open is true', () => { + const { getByText } = render(ModalComponent, { + props: { ...defaultProps, open: true } + }); + + expect(getByText('Add Quiz')).toBeInTheDocument(); + }); + + it('should hide modal when open is false', () => { + const { queryByText } = render(ModalComponent, { + props: { ...defaultProps, open: false } + }); + + expect(queryByText('Add Quiz')).not.toBeInTheDocument(); + }); + + + + it('should submit form with valid inputs', async () => { + const { getByLabelText, getByRole, getByText } = render(ModalComponent, { + props: { ...defaultProps } + }); + + const titleInput = getByLabelText('Title') as HTMLInputElement; + const durationInput = getByLabelText('Duration (in minutes)') as HTMLInputElement; + const instructionsTextarea = getByLabelText('Add Instructions') as HTMLTextAreaElement; + const submitButton = getByRole('button', { name: 'Create Quiz' }); + + + await fireEvent.input(titleInput, { target: { value: 'New Quiz' } }); + await fireEvent.input(durationInput, { target: { value: '45' } }); + await fireEvent.input(instructionsTextarea, { target: { value: 'These are instructions.' } }); + + // Simulate form submission + await fireEvent.click(submitButton); + + expect(titleInput.value).toBe('New Quiz'); + expect(durationInput.value).toBe('45'); + expect(instructionsTextarea.value).toBe('These are instructions.'); + }); + + it('should display toolbar buttons correctly', () => { + const { getByRole } = render(ModalComponent, { + props: { ...defaultProps, open: true } + }); + + expect(getByRole('button', { name: 'Attach file' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Embed map' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Upload image' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Format code' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Add emoji' })).toBeInTheDocument(); + }); +}); diff --git a/src/src/tests/unit/components/modal/questionType.test.ts b/src/src/tests/unit/components/modal/questionType.test.ts new file mode 100644 index 00000000..6d6e8776 --- /dev/null +++ b/src/src/tests/unit/components/modal/questionType.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { render, fireEvent } from '@testing-library/svelte'; +import ModalComponent from '$lib/components/modals/quizzes/Edit.svelte'; + +describe('ModalComponent - Question Type Modal', () => { + const defaultProps = { + open: true + }; + + it('should open modal when open is true', () => { + const { getByText } = render(ModalComponent, { + props: { ...defaultProps, open: true } + }); + + expect(getByText('Choose Question Type')).toBeInTheDocument(); + }); + + it('should display "Add" button and disable it when no option is selected', () => { + const { getByText } = render(ModalComponent, { + props: { ...defaultProps, open: true } + }); + + const addButton = getByText('Add'); + expect(addButton).toBeInTheDocument(); + expect(addButton).toBeDisabled(); + }); + + it('should enable "Add" button when a question type is selected', async () => { + const { getByText, getByLabelText } = render(ModalComponent, { + props: { ...defaultProps, open: true } + }); + + const selectInput = getByLabelText('Select Question Type'); + const addButton = getByText('Add'); + + // Initially disabled + expect(addButton).toBeDisabled(); + + // Simulate selecting a question type + await fireEvent.change(selectInput, { target: { value: 'multiple-choice' } }); + + // Now the button should be enabled + expect(addButton).not.toBeDisabled(); + }); + + it('should close modal when "Add" button is clicked with a valid selection', async () => { + const { getByText, getByLabelText, component } = render(ModalComponent, { + props: { ...defaultProps, open: true } + }); + + const selectInput = getByLabelText('Select Question Type'); + const addButton = getByText('Add'); + + // Mock the dispatch event listener + let dispatchedEvent = null; + component.$on('select', (event) => { + dispatchedEvent = event.detail; + }); + + // Simulate selecting a question type and clicking the "Add" button + await fireEvent.change(selectInput, { target: { value: '3d-hotspot' } }); + await fireEvent.click(addButton); + + // Check if the event was dispatched correctly with the selected type + expect(dispatchedEvent).toEqual({ type: '3d-hotspot' }); + }); +}); diff --git a/src/src/tests/unit/components/modal/submission.test.ts b/src/src/tests/unit/components/modal/submission.test.ts new file mode 100644 index 00000000..08bab411 --- /dev/null +++ b/src/src/tests/unit/components/modal/submission.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/svelte'; +import ModalComponent from '$lib/components/modals/quizzes/Submission.svelte'; // Adjust the import path as needed + +// Mock the navigation function +vi.mock('$utils/navigation', () => ({ + navigateToParentRoute: vi.fn() +})); + +describe('ModalComponent - Quiz Submission Modal', () => { + const defaultProps = { + open: true, + submissionMessage: 'Your quiz has been submitted successfully!', + percentageScore: 85.5 + }; + + it('should open modal when open is true', () => { + const { getByText } = render(ModalComponent, { + props: { ...defaultProps, open: true } + }); + + expect(getByText('Quiz Submission')).toBeInTheDocument(); + }); + + it('should hide modal when open is false', () => { + const { queryByText } = render(ModalComponent, { + props: { ...defaultProps, open: false } + }); + + expect(queryByText('Quiz Submission')).not.toBeInTheDocument(); + }); + + + + it('should display submission message and percentage score correctly', () => { + const { getByText } = render(ModalComponent, { + props: defaultProps + }); + + expect(getByText('Your quiz has been submitted successfully!')).toBeInTheDocument(); + expect(getByText('Percentage Score: 85.50%')).toBeInTheDocument(); + }); + + +}); diff --git a/src/src/tests/unit/components/modal/timeElasped.test.ts b/src/src/tests/unit/components/modal/timeElasped.test.ts new file mode 100644 index 00000000..fe0fedf0 --- /dev/null +++ b/src/src/tests/unit/components/modal/timeElasped.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent } from '@testing-library/svelte'; +import ModalComponent from '$lib/components/modals/quizzes/TimeElapsed.svelte'; // Adjust the import path as needed + +// Mock the navigation function +vi.mock('$utils/navigation', () => ({ + navigateToParentRoute: vi.fn() +})); + +describe('ModalComponent - Time Elapsed Modal', () => { + const defaultProps = { + open: true, + submissionMessage: 'Time is up! You have completed the quiz.', + totalPoints: 85 + }; + + it('should open modal when open is true', () => { + const { getByText } = render(ModalComponent, { + props: { ...defaultProps, open: true } + }); + + expect(getByText('Time Elapsed')).toBeInTheDocument(); + }); + + it('should hide modal when open is false', () => { + const { queryByText } = render(ModalComponent, { + props: { ...defaultProps, open: false } + }); + + expect(queryByText('Time Elapsed')).not.toBeInTheDocument(); + }); + + + + it('should display submission message and total points correctly', () => { + const { getByText } = render(ModalComponent, { + props: defaultProps + }); + + expect(getByText('Time is up! You have completed the quiz.')).toBeInTheDocument(); + expect(getByText('Total Points: 85')).toBeInTheDocument(); + }); + + +}); diff --git a/src/static/images/circle.png b/src/static/images/circle.png new file mode 100644 index 00000000..8d5722a6 Binary files /dev/null and b/src/static/images/circle.png differ