From 2c573f237de14e9ce432abcab6e2d10b6ec4d322 Mon Sep 17 00:00:00 2001 From: Bruno Antonio dos Santos Bezerra Date: Mon, 17 Nov 2025 08:43:55 -0300 Subject: [PATCH 01/12] =?UTF-8?q?implementa=C3=A7=C3=A3o=20de=20metas=20em?= =?UTF-8?q?=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/models/Class.ts | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/server/src/models/Class.ts b/server/src/models/Class.ts index cb6988d8..85e87d02 100644 --- a/server/src/models/Class.ts +++ b/server/src/models/Class.ts @@ -6,12 +6,14 @@ export class Class { private semester: number; private year: number; private enrollments: Enrollment[]; + private metas: string[]; - constructor(topic: string, semester: number, year: number, enrollments: Enrollment[] = []) { + constructor(topic: string, semester: number, year: number, enrollments: Enrollment[] = [], metas: string[] = []) { this.topic = topic; this.semester = semester; this.year = year; this.enrollments = enrollments; + this.metas = metas; } // Getters @@ -31,6 +33,10 @@ export class Class { return [...this.enrollments]; // Return copy to prevent external modification } + getMetas(): string[] { + return [...this.metas]; // Return copy to prevent external modification + } + // Generate unique class ID getClassId(): string { return `${this.topic}-${this.year}-${this.semester}`; @@ -86,6 +92,35 @@ export class Class { return this.enrollments.map(enrollment => enrollment.getStudent()); } + // Metas management + addMeta(meta: string): boolean { + // checar se a meta já existe + if (!this.metas.includes(meta)) { + this.metas.push(meta); + return true; + } + return false; + } + + removeMeta(meta: string): boolean { + // checar se a meta existe + const index = this.metas.indexOf(meta); + if (index !== -1) { + this.metas.splice(index, 1); + return true; + } + return false; + } + + updateMeta(oldMeta: string, newMeta: string): boolean { + const index = this.metas.indexOf(oldMeta); + if (index !== -1 && !this.metas.includes(newMeta)) { + this.metas[index] = newMeta; + return true; + } + return false; + } + // Convert to JSON for API responses toJSON() { return { From a458307dde383584db5dfa887cdcfe3cc4f00cfd Mon Sep 17 00:00:00 2001 From: Bruno Antonio dos Santos Bezerra Date: Mon, 17 Nov 2025 10:03:34 -0300 Subject: [PATCH 02/12] =?UTF-8?q?implementa=C3=A7=C3=A3o=20das=20rotas=20d?= =?UTF-8?q?e=20metas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/models/Class.ts | 9 ++-- server/src/server.ts | 96 +++++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/server/src/models/Class.ts b/server/src/models/Class.ts index 85e87d02..ba25be4e 100644 --- a/server/src/models/Class.ts +++ b/server/src/models/Class.ts @@ -128,12 +128,13 @@ export class Class { topic: this.topic, semester: this.semester, year: this.year, - enrollments: this.enrollments.map(enrollment => enrollment.toJSON()) + enrollments: this.enrollments.map(enrollment => enrollment.toJSON()), + metas: this.metas }; } // Create Class from JSON object - static fromJSON(data: { topic: string; semester: number; year: number; enrollments: any[] }, allStudents: Student[]): Class { + static fromJSON(data: { topic: string; semester: number; year: number; enrollments: any[]; metas: string[] }, allStudents: Student[]): Class { const enrollments = data.enrollments ? data.enrollments.map((enrollmentData: any) => { const student = allStudents.find(s => s.getCPF() === enrollmentData.student.cpf); @@ -143,7 +144,7 @@ export class Class { return Enrollment.fromJSON(enrollmentData, student); }) : []; - - return new Class(data.topic, data.semester, data.year, enrollments); + + return new Class(data.topic, data.semester, data.year, enrollments, data.metas || []); } } \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts index 6e2597b3..9c6a676d 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -46,6 +46,7 @@ const saveDataToFile = (): void => { topic: classObj.getTopic(), semester: classObj.getSemester(), year: classObj.getYear(), + metas: classObj.getMetas(), enrollments: classObj.getEnrollments().map(enrollment => ({ studentCPF: enrollment.getStudent().getCPF(), evaluations: enrollment.getEvaluations().map(evaluation => evaluation.toJSON()) @@ -89,7 +90,7 @@ const loadDataFromFile = (): void => { if (data.classes && Array.isArray(data.classes)) { data.classes.forEach((classData: any) => { try { - const classObj = new Class(classData.topic, classData.semester, classData.year); + const classObj = new Class(classData.topic, classData.semester, classData.year, [], classData.metas || []); classes.addClass(classObj); // Load enrollments for this class @@ -333,6 +334,99 @@ app.delete('/api/classes/:id', (req: Request, res: Response) => { } }); +// GET /api/classes/:id/metas - Pegar todas as metas de uma turma +app.get('/api/classes/:id/metas', (req: Request, res: Response) => { + try { + const { id } = req.params; + const classObj = classes.findClassById(id); + // checar se a turma existe + if (!classObj) { + return res.status(404).json({ error: 'Class not found' }); + } + const metas = classObj.getMetas(); + res.json(metas); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } +}); + +// POST /api/classes/:id/metas - Adicionar uma meta a uma turma +app.post('/api/classes/:id/metas', (req: Request, res: Response) => { + try { + const { id } = req.params; + const { meta } = req.body; + + if (!meta) { + return res.status(400).json({ error: 'Campo título é obrigatório' }); + } + + const classObj = classes.findClassById(id); + if (!classObj) { + return res.status(404).json({ error: 'Class not found' }); + } + + const success = classObj.addMeta(meta); + if (!success) { + return res.status(409).json({ error: 'Já existe uma meta com este título' }); + } + + triggerSave(); // Save to file after adding meta + res.status(201).json({ meta }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } +}); + +// PUT /api/classes/:id/metas - Atualizar uma meta de uma turma +app.put('/api/classes/:id/metas', (req: Request, res: Response) => { + try { + const { id } = req.params; + const { oldMeta, newMeta } = req.body; + + if (!oldMeta || !newMeta) { + return res.status(400).json({ error: 'Campo título é obrigatório' }); + } + + const classObj = classes.findClassById(id); + if (!classObj) { + return res.status(404).json({ error: 'Class not found' }); + } + + const success = classObj.updateMeta(oldMeta, newMeta); + if (!success) { + return res.status(409).json({ error: 'Já existe uma meta com este título' }); + } + + triggerSave(); // Save to file after updating meta + res.status(200).json({ oldMeta, newMeta }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } +}); + +// DELETE /api/classes/:id/metas - Remover uma meta de uma turma +app.delete('/api/classes/:id/metas', (req: Request, res: Response) => { + try { + const { id } = req.params; + const { meta } = req.body; + if (!meta) { + return res.status(400).json({ error: 'Campo título é obrigatório' }); + } + const classObj = classes.findClassById(id); + if (!classObj) { + return res.status(404).json({ error: 'Class not found' }); + } + const success = classObj.removeMeta(meta); + if (!success) { + return res.status(404).json({ error: 'Meta não encontrada' }); + } + triggerSave(); // Save to file after removing meta + res.status(200).json({ message: 'Meta removida com sucesso' }); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } +}); + // POST /api/classes/:classId/enroll - Enroll a student in a class app.post('/api/classes/:classId/enroll', (req: Request, res: Response) => { try { From bb8f92d081753f12f7367fe56a612ab9c31e5cd4 Mon Sep 17 00:00:00 2001 From: Bruno Antonio dos Santos Bezerra Date: Thu, 4 Dec 2025 15:46:37 -0300 Subject: [PATCH 03/12] =?UTF-8?q?cria=C3=A7=C3=A3o=20da=20feature=20metas?= =?UTF-8?q?=20no=20servidor,=20e=20adicionar=20os=20cen=C3=A1rios=20de=20m?= =?UTF-8?q?etas=20nas=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/features/criacao-metas.feature | 52 ++++++++++++++ server/src/models/Class.ts | 71 +++++++++++--------- server/src/server.ts | 82 +++++------------------ 3 files changed, 108 insertions(+), 97 deletions(-) create mode 100644 client/src/features/criacao-metas.feature diff --git a/client/src/features/criacao-metas.feature b/client/src/features/criacao-metas.feature new file mode 100644 index 00000000..8a8ab92b --- /dev/null +++ b/client/src/features/criacao-metas.feature @@ -0,0 +1,52 @@ +Feature: Cadastro de Metas para Turmas + Background: + GIVEN a turma "engenharia-de-software-e-sistemas" existe no sistema + AND estou na página de criação de metas da turma "engenharia-de-software-e-sistemas" + + Scenario: Criar metas com sucesso + GIVEN não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" + WHEN eu envio as metas da turma com os títulos: + | Requisitos | + | Testes de software | + AND eu submeto a criação de metas + THEN vejo a notificação "Metas criadas com sucesso!" + AND a listagem de metas da turma "engenharia-de-software-e-sistemas" exibe os itens com títulos "Requisitos" e "Testes de software" + + Scenario: Tentar criar metas com array vazio (sem títulos) + GIVEN não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" + WHEN eu tento submeter as metas com um array vazio + THEN vejo a mensagem de erro "As metas de uma turma não devem ser vazias!" + AND nenhuma meta é criada na turma "engenharia-de-software-e-sistemas" + + Scenario: Tentar criar metas com títulos duplicados no mesmo envio + GIVEN não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" + WHEN eu envio as metas da turma com os títulos: + | Requisitos | + | Requisitos | + AND eu submeto o formulário de criação de metas + THEN vejo a mensagem de erro "Metas não podem conter duplicatas!" + AND nenhuma meta é criada na turma "engenharia-de-software-e-sistemas" + + Scenario: Atualizar metas para serem criadas + GIVEN as seguintes metas já foram adicionadas para a criação na turma "engenharia-de-software-e-sistemas": + | Requisitos | + | Testes de software | + |Refatoração | + WHEN eu edito as metas adicionadas para a criação, alterando os títulos para: + | Gerência de projetos | + | Testes de software | + | Refatoração | + THEN eu vejo a lista de metas adicionadas para criação atualizada com os títulos: + | Gerência de projetos | + | Testes de software | + | Refatoração | + + Scenario: Remover metas antes de serem criadas + GIVEN as seguintes metas já foram adicionadas para a criação na turma "engenharia-de-software-e-sistemas": + | Requisitos | + | Testes de software | + | Refatoração | + WHEN eu removo a meta com o título "Testes de software" da lista de metas adicionadas para criação + THEN eu vejo a lista de metas adicionadas para criação atualizada com os títulos: + | Requisitos | + | Refatoração | diff --git a/server/src/models/Class.ts b/server/src/models/Class.ts index f1922512..33fb7a1e 100644 --- a/server/src/models/Class.ts +++ b/server/src/models/Class.ts @@ -9,14 +9,16 @@ export class Class { private readonly especificacaoDoCalculoDaMedia: EspecificacaoDoCalculoDaMedia; private enrollments: Enrollment[]; private metas: string[]; + private metasLocked: boolean; - constructor(topic: string, semester: number, year: number, especificacaoDoCalculoDaMedia: EspecificacaoDoCalculoDaMedia, enrollments: Enrollment[] = []) { + constructor(topic: string, semester: number, year: number, metas: string[] = [], especificacaoDoCalculoDaMedia: EspecificacaoDoCalculoDaMedia, enrollments: Enrollment[] = []) { this.topic = topic; this.semester = semester; this.year = year; this.especificacaoDoCalculoDaMedia = especificacaoDoCalculoDaMedia; this.enrollments = enrollments; this.metas = metas; + this.metasLocked = metas.length > 0; } // Getters @@ -40,6 +42,10 @@ export class Class { return [...this.metas]; // Return copy to prevent external modification } + isMetasLocked(): boolean { + return this.metasLocked; + } + // Generate unique class ID getClassId(): string { return `${this.topic}-${this.year}-${this.semester}`; @@ -62,6 +68,26 @@ export class Class { return this.especificacaoDoCalculoDaMedia; } + /** + * Define as metas em lote. Só pode ser chamada uma vez; depois disso as metas ficam imutáveis. + * Lança Error se as metas já estiverem lockadas ou se o array for inválido. + */ + setMetas(metas: string[]): void { + if (this.metasLocked) { + throw new Error('Metas já foram definidas para a turma e não podem ser alteradas!'); + } + if (!Array.isArray(metas) || metas.length === 0) { + throw new Error('As metas de uma turma não devem ser vazias!'); + } + // verificar se array tem duplicatas + const hasDuplicates = metas.length !== new Set(metas).size; + if (hasDuplicates) { + throw new Error('Metas não podem conter duplicatas!'); + } + this.metas = metas; + this.metasLocked = true; + } + // Enrollment management addEnrollment(student: Student): Enrollment { // Check if student is already enrolled @@ -98,35 +124,6 @@ export class Class { getEnrolledStudents(): Student[] { return this.enrollments.map(enrollment => enrollment.getStudent()); } - - // Metas management - addMeta(meta: string): boolean { - // checar se a meta já existe - if (!this.metas.includes(meta)) { - this.metas.push(meta); - return true; - } - return false; - } - - removeMeta(meta: string): boolean { - // checar se a meta existe - const index = this.metas.indexOf(meta); - if (index !== -1) { - this.metas.splice(index, 1); - return true; - } - return false; - } - - updateMeta(oldMeta: string, newMeta: string): boolean { - const index = this.metas.indexOf(oldMeta); - if (index !== -1 && !this.metas.includes(newMeta)) { - this.metas[index] = newMeta; - return true; - } - return false; - } // Convert to JSON for API responses toJSON() { @@ -135,13 +132,15 @@ export class Class { topic: this.topic, semester: this.semester, year: this.year, + metas: this.metas, + metasLocked: this.metasLocked, especificacaoDoCalculoDaMedia: this.especificacaoDoCalculoDaMedia.toJSON(), enrollments: this.enrollments.map(enrollment => enrollment.toJSON()) }; } // Create Class from JSON object - static fromJSON(data: { topic: string; semester: number; year: number; especificacaoDoCalculoDaMedia: any, enrollments: any[] }, allStudents: Student[]): Class { + static fromJSON(data: { topic: string; semester: number; year: number; metas: string[]; especificacaoDoCalculoDaMedia: any, enrollments: any[] }, allStudents: Student[]): Class { const enrollments = data.enrollments ? data.enrollments.map((enrollmentData: any) => { const student = allStudents.find(s => s.getCPF() === enrollmentData.student.cpf); @@ -155,6 +154,14 @@ export class Class { // Novo carregamento do EspecificacaoDoCalculoDaMedia const especificacaoDoCalculoDaMedia = EspecificacaoDoCalculoDaMedia.fromJSON(data.especificacaoDoCalculoDaMedia); - return new Class(data.topic, data.semester, data.year, especificacaoDoCalculoDaMedia, enrollments); + const metas = Array.isArray(data.metas) ? data.metas : []; + const cls = new Class(data.topic, data.semester, data.year, metas, especificacaoDoCalculoDaMedia, enrollments); + // Se o JSON já veio com metas e quisermos respeitar lock vindo do arquivo, checar data.metasLocked + if ((data as any).metasLocked) { + // forçar lock mesmo que metas tenha sido passada vazia (já tratada acima) + (cls as any).metasLocked = true; + } + + return cls; } } diff --git a/server/src/server.ts b/server/src/server.ts index 7564085c..5b4e0abc 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -48,6 +48,8 @@ const saveDataToFile = (): void => { topic: classObj.getTopic(), semester: classObj.getSemester(), year: classObj.getYear(), + metas: classObj.getMetas(), + metasLocked: classObj.isMetasLocked(), especificacaoDoCalculoDaMedia: classObj.getEspecificacaoDoCalculoDaMedia().toJSON(), enrollments: classObj.getEnrollments().map(enrollment => ({ studentCPF: enrollment.getStudent().getCPF(), @@ -92,7 +94,7 @@ const loadDataFromFile = (): void => { if (data.classes && Array.isArray(data.classes)) { data.classes.forEach((classData: any) => { try { - const classObj = new Class(classData.topic, classData.semester, classData.year, EspecificacaoDoCalculoDaMedia.fromJSON(classData.especificacaoDoCalculoDaMedia)); + const classObj = new Class(classData.topic, classData.semester, classData.year, classData.metas, EspecificacaoDoCalculoDaMedia.fromJSON(classData.especificacaoDoCalculoDaMedia)); classes.addClass(classObj); // Load enrollments for this class @@ -301,7 +303,7 @@ app.post('/api/classes', (req: Request, res: Response) => { return res.status(400).json({ error: 'Topic, semester, and year are required' }); } - const classObj = new Class(topic, semester, year, DEFAULT_ESPECIFICACAO_DO_CALCULO_DA_MEDIA); + const classObj = new Class(topic, semester, year, [], DEFAULT_ESPECIFICACAO_DO_CALCULO_DA_MEDIA); const newClass = classes.addClass(classObj); triggerSave(); // Save to file after adding class res.status(201).json(newClass.toJSON()); @@ -364,84 +366,34 @@ app.get('/api/classes/:id/metas', (req: Request, res: Response) => { return res.status(404).json({ error: 'Class not found' }); } const metas = classObj.getMetas(); - res.json(metas); + res.json({ metas }); } catch (error) { res.status(400).json({ error: (error as Error).message }); } }); -// POST /api/classes/:id/metas - Adicionar uma meta a uma turma +// POST /api/classes/:id/metas - Adicionar as metas de uma turma app.post('/api/classes/:id/metas', (req: Request, res: Response) => { try { const { id } = req.params; - const { meta } = req.body; - - if (!meta) { - return res.status(400).json({ error: 'Campo título é obrigatório' }); - } + const { metas } = req.body; + // verificar se a turma existe const classObj = classes.findClassById(id); if (!classObj) { - return res.status(404).json({ error: 'Class not found' }); - } - - const success = classObj.addMeta(meta); - if (!success) { - return res.status(409).json({ error: 'Já existe uma meta com este título' }); + return res.status(404).json({ error: 'Turma não encontrada!' }); } - triggerSave(); // Save to file after adding meta - res.status(201).json({ meta }); - } catch (error) { - res.status(400).json({ error: (error as Error).message }); - } -}); - -// PUT /api/classes/:id/metas - Atualizar uma meta de uma turma -app.put('/api/classes/:id/metas', (req: Request, res: Response) => { - try { - const { id } = req.params; - const { oldMeta, newMeta } = req.body; - - if (!oldMeta || !newMeta) { - return res.status(400).json({ error: 'Campo título é obrigatório' }); - } - - const classObj = classes.findClassById(id); - if (!classObj) { - return res.status(404).json({ error: 'Class not found' }); + try { + classObj.setMetas(metas); + } catch (err) { + return res.status(409).json({ error: (err as Error).message }); } - const success = classObj.updateMeta(oldMeta, newMeta); - if (!success) { - return res.status(409).json({ error: 'Já existe uma meta com este título' }); - } - - triggerSave(); // Save to file after updating meta - res.status(200).json({ oldMeta, newMeta }); - } catch (error) { - res.status(400).json({ error: (error as Error).message }); - } -}); - -// DELETE /api/classes/:id/metas - Remover uma meta de uma turma -app.delete('/api/classes/:id/metas', (req: Request, res: Response) => { - try { - const { id } = req.params; - const { meta } = req.body; - if (!meta) { - return res.status(400).json({ error: 'Campo título é obrigatório' }); - } - const classObj = classes.findClassById(id); - if (!classObj) { - return res.status(404).json({ error: 'Class not found' }); - } - const success = classObj.removeMeta(meta); - if (!success) { - return res.status(404).json({ error: 'Meta não encontrada' }); - } - triggerSave(); // Save to file after removing meta - res.status(200).json({ message: 'Meta removida com sucesso' }); + triggerSave(); // Save to file after adding metas + + // retornar uma mensagem de metas criadas com sucesso + res.status(201).json({ message: 'Metas criadas com sucesso!' }); } catch (error) { res.status(400).json({ error: (error as Error).message }); } From ab1dead4674d72df7287dc40286b5335a64bc0a3 Mon Sep 17 00:00:00 2001 From: basb Date: Sat, 6 Dec 2025 13:32:48 -0300 Subject: [PATCH 04/12] =?UTF-8?q?implementa=C3=A7=C3=A3o=20da=20features?= =?UTF-8?q?=20metas=20no=20cliente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package-lock.json | 371 +++++++++++----------- client/package.json | 4 +- client/src/App.css | 98 +++++- client/src/App.tsx | 13 + client/src/components/Classes.tsx | 155 ++++++++- client/src/components/Evaluations.tsx | 35 +- client/src/features/criacao-metas.feature | 6 - client/src/services/ClassService.ts | 27 +- client/src/types/Class.ts | 4 + 9 files changed, 503 insertions(+), 210 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 7089679f..d2d6b4c8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,11 +13,13 @@ "react-scripts": "5.0.1" }, "devDependencies": { - "@cucumber/cucumber": "^12.2.0", + "@cucumber/cucumber": "^12.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/cucumber": "^6.0.1", "@types/jest": "^30.0.0", + "@types/node": "^24.10.1", "@types/puppeteer": "^5.4.7", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", @@ -2416,31 +2418,30 @@ } }, "node_modules/@cucumber/ci-environment": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-10.0.1.tgz", - "integrity": "sha512-/+ooDMPtKSmvcPMDYnMZt4LuoipfFfHaYspStI4shqw8FyKcfQAmekz6G+QKWjQQrvM+7Hkljwx58MEwPCwwzg==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-12.0.0.tgz", + "integrity": "sha512-SqCEnbCNl3zCXCFpqGUuoaSNhLC0jLw4tKeFcAxTw9MD/QRlJjeAC/fyvVLFuXuSq0OunJlFfxLu+Z3HE+oLPg==", "dev": true, "license": "MIT" }, "node_modules/@cucumber/cucumber": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-12.2.0.tgz", - "integrity": "sha512-b7W4snvXYi1T2puUjxamASCCNhNzVSzb/fQUuGSkdjm/AFfJ24jo8kOHQyOcaoArCG71sVQci4vkZaITzl/V1w==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-12.3.0.tgz", + "integrity": "sha512-36cIyplE1iDl12s4k6lBVpceua8tKLklFTf7CUITPrNHTLlQ/KBr7NYUUHviPzCbj2Ox3BPTZ6qkSLd6WMvVQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@cucumber/ci-environment": "10.0.1", + "@cucumber/ci-environment": "12.0.0", "@cucumber/cucumber-expressions": "18.0.1", - "@cucumber/gherkin": "34.0.0", - "@cucumber/gherkin-streams": "5.0.1", - "@cucumber/gherkin-utils": "9.2.0", - "@cucumber/html-formatter": "21.14.0", - "@cucumber/junit-xml-formatter": "0.8.1", + "@cucumber/gherkin": "37.0.0", + "@cucumber/gherkin-streams": "6.0.0", + "@cucumber/gherkin-utils": "10.0.0", + "@cucumber/html-formatter": "22.2.0", + "@cucumber/junit-xml-formatter": "0.9.0", "@cucumber/message-streams": "4.0.1", - "@cucumber/messages": "28.1.0", + "@cucumber/messages": "31.0.0", "@cucumber/pretty-formatter": "1.0.1", - "@cucumber/tag-expressions": "6.2.0", + "@cucumber/tag-expressions": "8.1.0", "assertion-error-formatter": "^3.0.0", "capital-case": "^1.0.4", "chalk": "^4.1.2", @@ -2449,7 +2450,7 @@ "debug": "^4.3.4", "error-stack-parser": "^2.1.4", "figures": "^3.2.0", - "glob": "^11.0.0", + "glob": "^13.0.0", "has-ansi": "^4.0.1", "indent-string": "^4.0.0", "is-installed-globally": "^0.4.0", @@ -2457,19 +2458,19 @@ "knuth-shuffle-seeded": "^1.0.6", "lodash.merge": "^4.6.2", "lodash.mergewith": "^4.6.2", - "luxon": "3.7.1", + "luxon": "3.7.2", "mime": "^3.0.0", "mkdirp": "^3.0.0", "mz": "^2.7.0", "progress": "^2.0.3", - "read-package-up": "^11.0.0", - "semver": "7.7.2", + "read-package-up": "^12.0.0", + "semver": "7.7.3", "string-argv": "0.3.1", "supports-color": "^8.1.1", "type-fest": "^4.41.0", "util-arity": "^1.1.0", "yaml": "^2.2.2", - "yup": "1.7.0" + "yup": "1.7.1" }, "bin": { "cucumber-js": "bin/cucumber.js" @@ -2491,6 +2492,17 @@ "regexp-match-indices": "1.0.2" } }, + "node_modules/@cucumber/cucumber/node_modules/@cucumber/messages": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-31.0.0.tgz", + "integrity": "sha512-Dqhatp4AjMsH9SREfWz3Q8nlGuwJMTW7YAW5L3OzRId86ZUEu/a8vIL1RO2c0agQefuBS2SVH9fEZ66ovrMYRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2" + } + }, "node_modules/@cucumber/cucumber/node_modules/commander": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", @@ -2502,38 +2514,16 @@ } }, "node_modules/@cucumber/cucumber/node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@cucumber/cucumber/node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, "engines": { "node": "20 || >=22" }, @@ -2542,11 +2532,11 @@ } }, "node_modules/@cucumber/cucumber/node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -2613,19 +2603,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@cucumber/cucumber/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@cucumber/cucumber/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -2669,24 +2646,23 @@ } }, "node_modules/@cucumber/gherkin": { - "version": "34.0.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-34.0.0.tgz", - "integrity": "sha512-659CCFsrsyvuBi/Eix1fnhSheMnojSfnBcqJ3IMPNawx7JlrNJDcXYSSdxcUw3n/nG05P+ptCjmiZY3i14p+tA==", + "version": "37.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-37.0.0.tgz", + "integrity": "sha512-vKJVJ6h4HCktG870wgYUUskNpFxbFI0WmAkVLPTz1LlLwJX7/KOBqFcr2/L3u0pPoHjbLRW+IpbiXLT2T13/wg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@cucumber/messages": ">=19.1.4 <29" + "@cucumber/messages": ">=31.0.0 <32" } }, "node_modules/@cucumber/gherkin-streams": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin-streams/-/gherkin-streams-5.0.1.tgz", - "integrity": "sha512-/7VkIE/ASxIP/jd4Crlp4JHXqdNFxPGQokqWqsaCCiqBiu5qHoKMxcWNlp9njVL/n9yN4S08OmY3ZR8uC5x74Q==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-streams/-/gherkin-streams-6.0.0.tgz", + "integrity": "sha512-HLSHMmdDH0vCr7vsVEURcDA4WwnRLdjkhqr6a4HQ3i4RFK1wiDGPjBGVdGJLyuXuRdJpJbFc6QxHvT8pU4t6jw==", "dev": true, "license": "MIT", "dependencies": { - "commander": "9.1.0", + "commander": "14.0.0", "source-map-support": "0.5.21" }, "bin": { @@ -2699,26 +2675,26 @@ } }, "node_modules/@cucumber/gherkin-streams/node_modules/commander": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.1.0.tgz", - "integrity": "sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || >=14" + "node": ">=20" } }, "node_modules/@cucumber/gherkin-utils": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-9.2.0.tgz", - "integrity": "sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-10.0.0.tgz", + "integrity": "sha512-BcujlDT343GXXNrMPl3ws6Il3zs8dQw3Yp/d3HnOJF8i2snGGgiapoTbko7MdvAt7ivDL7SDo+e1d5Cnpl3llA==", "dev": true, "license": "MIT", "dependencies": { - "@cucumber/gherkin": "^31.0.0", - "@cucumber/messages": "^27.0.0", + "@cucumber/gherkin": "^34.0.0", + "@cucumber/messages": "^29.0.0", "@teppeis/multimaps": "3.0.0", - "commander": "13.1.0", + "commander": "14.0.0", "source-map-support": "^0.5.21" }, "bin": { @@ -2726,69 +2702,53 @@ } }, "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { - "version": "31.0.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-31.0.0.tgz", - "integrity": "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==", + "version": "34.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-34.0.0.tgz", + "integrity": "sha512-659CCFsrsyvuBi/Eix1fnhSheMnojSfnBcqJ3IMPNawx7JlrNJDcXYSSdxcUw3n/nG05P+ptCjmiZY3i14p+tA==", "dev": true, "license": "MIT", "dependencies": { - "@cucumber/messages": ">=19.1.4 <=26" + "@cucumber/messages": ">=19.1.4 <29" } }, "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", - "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-28.1.0.tgz", + "integrity": "sha512-2LzZtOwYKNlCuNf31ajkrekoy2M4z0Z1QGiPH40n4gf5t8VOUFb7m1ojtR4LmGvZxBGvJZP8voOmRqDWzBzYKA==", "dev": true, "license": "MIT", "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", "reflect-metadata": "0.2.2", - "uuid": "10.0.0" - } - }, - "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "uuid": "11.1.0" } }, "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/messages": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", - "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-29.0.1.tgz", + "integrity": "sha512-aAvIYfQD6/aBdF8KFQChC3CQ1Q+GX9orlR6GurGiX6oqaCnBkxA4WU3OQUVepDynEFrPayerqKRFcAMhdcXReQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/uuid": "10.0.0", "class-transformer": "0.5.1", - "reflect-metadata": "0.2.2", - "uuid": "11.0.5" + "reflect-metadata": "0.2.2" } }, "node_modules/@cucumber/gherkin-utils/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@cucumber/gherkin-utils/node_modules/uuid": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", - "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -2799,10 +2759,21 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-31.0.0.tgz", + "integrity": "sha512-Dqhatp4AjMsH9SREfWz3Q8nlGuwJMTW7YAW5L3OzRId86ZUEu/a8vIL1RO2c0agQefuBS2SVH9fEZ66ovrMYRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2" + } + }, "node_modules/@cucumber/html-formatter": { - "version": "21.14.0", - "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.14.0.tgz", - "integrity": "sha512-vQqbmQZc0QiN4c+cMCffCItpODJlOlYtPG7pH6We096dBOa7u0ttDMjT6KrMAnQlcln54rHL46r408IFpuznAw==", + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-22.2.0.tgz", + "integrity": "sha512-fUNC/KngTIz+hAQ2Yr4XjdYq+MO60PwK9SidxBQ54jNI1Vw7erlgsPq0TOWneCIvdjU3qp+YDqYG1hw3zuUuDA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2810,13 +2781,13 @@ } }, "node_modules/@cucumber/junit-xml-formatter": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.8.1.tgz", - "integrity": "sha512-FT1Y96pyd9/ifbE9I7dbkTCjkwEdW9C0MBobUZoKD13c8EnWAt0xl1Yy/v/WZLTk4XfCLte1DATtLx01jt+YiA==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.9.0.tgz", + "integrity": "sha512-WF+A7pBaXpKMD1i7K59Nk5519zj4extxY4+4nSgv5XLsGXHDf1gJnb84BkLUzevNtp2o2QzMG0vWLwSm8V5blw==", "dev": true, "license": "MIT", "dependencies": { - "@cucumber/query": "^13.0.2", + "@cucumber/query": "^14.0.1", "@teppeis/multimaps": "^3.0.0", "luxon": "^3.5.0", "xmlbuilder": "^15.1.1" @@ -2895,9 +2866,9 @@ } }, "node_modules/@cucumber/query": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-13.6.0.tgz", - "integrity": "sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==", + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-14.6.0.tgz", + "integrity": "sha512-bPbfpkDsFCBn95erh3un76QViPqGAo3T7iYews0yA3/JRNoV009s7acxxY+f+OMABPFl0TJVIZlvqX+KayQ+Eg==", "dev": true, "license": "MIT", "dependencies": { @@ -2909,9 +2880,9 @@ } }, "node_modules/@cucumber/tag-expressions": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-6.2.0.tgz", - "integrity": "sha512-KIF0eLcafHbWOuSDWFw0lMmgJOLdDRWjEL1kfXEWrqHmx2119HxVAr35WuEd9z542d3Yyg+XNqSr+81rIKqEdg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-8.1.0.tgz", + "integrity": "sha512-UFeOVUyc711/E7VHjThxMwg3jbGod9TlbM1gxNixX/AGDKg82Eha4cE0tKki3GGUs7uB2NyI+hQAuhB8rL2h5A==", "dev": true, "license": "MIT" }, @@ -4088,6 +4059,13 @@ "@types/node": "*" } }, + "node_modules/@types/cucumber": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/cucumber/-/cucumber-6.0.1.tgz", + "integrity": "sha512-+GZV6xfN0MeN9shDCdny8GbC8N0+U6uca8cjyaJndcwmrUhwS6qOU2vmYn0d71EOwJF568/v3SxJ8VKxuZNYRw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -4416,9 +4394,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", - "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", "peer": true, "dependencies": { @@ -10022,24 +10000,27 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", "dev": true, "license": "ISC", "dependencies": { - "lru-cache": "^10.0.1" + "lru-cache": "^11.1.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/hpack.js": { "version": "2.1.6", @@ -12395,9 +12376,9 @@ } }, "node_modules/luxon": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", - "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "dev": true, "license": "MIT", "engines": { @@ -12789,18 +12770,18 @@ "license": "MIT" }, "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^9.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -16383,51 +16364,54 @@ } }, "node_modules/read-package-up": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", - "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", "dev": true, "license": "MIT", "dependencies": { - "find-up-simple": "^1.0.0", - "read-pkg": "^9.0.0", - "type-fest": "^4.6.0" + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/read-package-up/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.0.tgz", + "integrity": "sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g==", "dev": true, "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.0.0.tgz", + "integrity": "sha512-A70UlgfNdKI5NSvTTfHzLQj7NJRpJ4mT5tGafkllJ4wh71oYuGm/pzphHcmW4s35iox56KSK721AihodoXSc/A==", "dev": true, "license": "MIT", "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.2.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -16451,7 +16435,7 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/type-fest": { + "node_modules/read-pkg/node_modules/parse-json/node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", @@ -16464,6 +16448,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.0.tgz", + "integrity": "sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -18458,6 +18458,19 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "3.4.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", @@ -19228,9 +19241,9 @@ } }, "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", "engines": { @@ -20381,9 +20394,9 @@ } }, "node_modules/yup": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.0.tgz", - "integrity": "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/client/package.json b/client/package.json index c6d61325..2395f152 100644 --- a/client/package.json +++ b/client/package.json @@ -8,11 +8,13 @@ "react-scripts": "5.0.1" }, "devDependencies": { - "@cucumber/cucumber": "^12.2.0", + "@cucumber/cucumber": "^12.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/cucumber": "^6.0.1", "@types/jest": "^30.0.0", + "@types/node": "^24.10.1", "@types/puppeteer": "^5.4.7", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", diff --git a/client/src/App.css b/client/src/App.css index 8cabd26f..1a8eab7d 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -59,6 +59,18 @@ body { gap: 0.5rem; } +.success-message { + background-color: #f0fff4; + color: #065f46; + border: 1px solid #34d399; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + /* Student Form */ .student-form { background: white; @@ -771,6 +783,76 @@ tbody tr:last-child td { background-color: #2563eb; } +/* Metas action button in classes list (yellow, same scale as other action buttons) */ +.metas-btn { + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + color: white; + padding: 0.5rem 1rem; + font-size: 0.8rem; + min-width: auto; + border-radius: 4px; +} + +.metas-btn:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.25); +} + +/* Slight vertical offset so Metas button doesn't sit flush with Edit */ +.metas-btn { position: relative; top: 2px; } + +/* Metas modal buttons */ +.meta-add-btn { + background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); /* green */ + color: white; + padding: 0.5rem 1rem; + font-size: 0.8rem; +} +.meta-add-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(72,187,120,0.2); } + +.meta-edit-btn { + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); /* yellow */ + color: white; + padding: 0.45rem 0.9rem; + font-size: 0.85rem; + border-radius: 6px; + margin-left: 0.25rem; +} +.meta-edit-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(245,158,11,0.2); } + +.meta-delete-btn { + background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%); /* red */ + color: white; + padding: 0.45rem 0.9rem; + font-size: 0.85rem; + border-radius: 6px; + margin-left: 0.25rem; +} +.meta-delete-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 8px rgba(245,101,101,0.2); } + +/* Create metas: same visual as primary submit button */ +.meta-create-btn { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-weight: 600; +} + +/* Make metas modal input a bit larger */ +.metas-modal-content .add-meta-row input { + width: 60%; + padding: 1rem; + font-size: 1rem; + border: 2px solid #d1d5db; + border-radius: 6px; +} + +.metas-modal-content .add-meta-row button { min-width: 80px; } + +.local-metas-list { list-style: none; margin-top: 1rem; padding-left: 0; } +.local-meta-item { display:flex; justify-content:space-between; align-items:center; gap: 1rem; padding: 0.5rem 0; border-bottom: 1px solid #f1f5f9; } + /* Evaluations Table */ .evaluations-table { width: 100%; @@ -1013,7 +1095,7 @@ tbody tr:last-child td { border-collapse: collapse; background: white; min-width: 800px; - table-layout: fixed; /* Fixed layout for uniform column widths */ + table-layout: auto; /* allow columns to size based on content so long metas can expand */ } .student-name-header { @@ -1040,11 +1122,14 @@ tbody tr:last-child td { font-weight: 700; font-size: 0.9rem; border: 1px solid #047857; - width: calc((100% - 200px) / 6); /* Equal width for all 6 goal columns */ + width: auto; /* allow width to expand based on content */ + min-width: 160px; /* reasonable minimum for goals */ text-shadow: 0 1px 2px rgba(0,0,0,0.3); box-shadow: inset 0 1px 0 rgba(255,255,255,0.2); vertical-align: middle; line-height: 1.2; + white-space: normal; + word-wrap: break-word; } .student-row:nth-child(even) { @@ -1089,10 +1174,17 @@ tbody tr:last-child td { padding: 12px 8px; text-align: center; border: 1px solid #cbd5e1; - width: calc((100% - 200px) / 6); /* Equal width for all goal columns */ + width: auto; + min-width: 140px; vertical-align: middle; } +/* Center selects inside evaluation cells */ +.evaluation-cell select { margin: 0 auto; display: block; } + +/* Metas modal actions spacing */ +.metas-actions { display: flex; gap: 1rem; justify-content: flex-end; margin-top: 1rem; } + .student-row:nth-child(even) .evaluation-cell { background-color: #f0f9ff; } diff --git a/client/src/App.tsx b/client/src/App.tsx index cc987904..ffdf9d70 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,7 @@ const App: React.FC = () => { const [selectedClass, setSelectedClass] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); const [editingStudent, setEditingStudent] = useState(null); const [activeTab, setActiveTab] = useState('students'); @@ -109,6 +110,12 @@ const App: React.FC = () => { setError(errorMessage); }; + const handleSuccess = (message: string) => { + setSuccess(message); + // Clear success after a short timeout + setTimeout(() => setSuccess(''), 4000); + }; + return (
@@ -122,6 +129,11 @@ const App: React.FC = () => { Error: {error}
)} + {success && ( +
+ {success} +
+ )} {/* Tab Navigation */}
@@ -213,6 +225,7 @@ const App: React.FC = () => { onClassUpdated={handleClassUpdated} onClassDeleted={handleClassDeleted} onError={handleError} + onSuccess={handleSuccess} /> )}
diff --git a/client/src/components/Classes.tsx b/client/src/components/Classes.tsx index e5d4302f..877b90ee 100644 --- a/client/src/components/Classes.tsx +++ b/client/src/components/Classes.tsx @@ -12,6 +12,7 @@ interface ClassesProps { onClassUpdated: () => void; onClassDeleted: () => void; onError: (errorMessage: string) => void; + onSuccess?: (message: string) => void; } const Classes: React.FC = ({ @@ -19,7 +20,8 @@ const Classes: React.FC = ({ onClassAdded, onClassUpdated, onClassDeleted, - onError + onError, + onSuccess }) => { const [formData, setFormData] = useState({ topic: '', @@ -27,6 +29,12 @@ const Classes: React.FC = ({ year: new Date().getFullYear(), especificacaoDoCalculoDaMedia: DEFAULT_ESPECIFICACAO_DO_CALCULO_DE_MEDIA }); + // Metas management state (local, before sending to server) + const [localMetas, setLocalMetas] = useState([]); + const [editingMetaIndex, setEditingMetaIndex] = useState(null); + const [editingMetaValue, setEditingMetaValue] = useState(''); + // Separate modal state for metas management + const [metaPanelClass, setMetaPanelClass] = useState(null); const [editingClass, setEditingClass] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -151,7 +159,13 @@ const Classes: React.FC = ({ setEditingClass(null); } else { // Add new class - await ClassService.addClass(formData); + const created = await ClassService.addClass(formData); + // After creating a class, prepare localMetas for metas flow + if (created && created.id) { + setLocalMetas([]); + // open metas panel automatically for the newly created class + setEnrollmentPanelClass(created); + } onClassAdded(); } @@ -169,6 +183,62 @@ const Classes: React.FC = ({ } }; + // Metas UI handlers + const handleAddMetaLocally = (meta: string) => { + if (!meta || !meta.trim()) return; + if (localMetas.includes(meta)) { + onError('Meta já adicionada'); + return; + } + setLocalMetas(prev => [...prev, meta]); + }; + + const handleEditMetaStart = (index: number) => { + setEditingMetaIndex(index); + setEditingMetaValue(localMetas[index]); + }; + + const handleEditMetaSave = () => { + if (editingMetaIndex === null) return; + const newVal = editingMetaValue.trim(); + if (!newVal) return; + setLocalMetas(prev => prev.map((m, i) => i === editingMetaIndex ? newVal : m)); + setEditingMetaIndex(null); + setEditingMetaValue(''); + }; + + const handleDeleteMeta = (index: number) => { + setLocalMetas(prev => prev.filter((_, i) => i !== index)); + }; + + // Post metas to server for a given class id (uses ClassService) + const handleCreateMetasOnServer = async (classObj: Class) => { + try { + if (!classObj || !classObj.id) { + onError('Classe inválida'); + return; + } + + const data = await ClassService.setMetas(classObj.id, localMetas); + + // Show server message if provided + const serverMessage = data && (data.message || data.msg) ? (data.message || data.msg) : 'Metas criadas com sucesso!'; + if (onSuccess) { + onSuccess(serverMessage); + } else { + onError(serverMessage); + } + + // Refresh classes list and close metas panel + await loadAllStudents(); + onClassUpdated(); + setEnrollmentPanelClass(null); + setMetaPanelClass(null); + } catch (error) { + onError((error as Error).message); + } + }; + // Handle edit button click const handleEdit = (classObj: Class) => { setEditingClass(classObj); @@ -323,6 +393,15 @@ const Classes: React.FC = ({ > Enroll + + + ))} @@ -437,6 +516,78 @@ const Classes: React.FC = ({ )} + + {/* Metas Modal (separate from enrollment modal) */} + {metaPanelClass && ( +
+
+
+

Metas for {metaPanelClass.topic}

+ +
+
+ {metaPanelClass.metas && metaPanelClass.metas.length > 0 ? ( +
+

Existing Metas

+
    + {metaPanelClass.metas.map((meta, idx) => ( +
  • + {meta} +
  • + ))} +
+
+ +
+
+ ) : ( + <> +
+ setEditingMetaValue(e.target.value)} + /> + + {editingMetaIndex !== null && ( + + )} +
+ +
    + {localMetas.length === 0 ? ( +
  • No local metas added yet
  • + ) : ( + localMetas.map((meta, idx) => ( +
  • + {meta} +
    + + +
    +
  • + )) + )} +
+ +
+ + +
+ + )} +
+
+
+ )} ); }; diff --git a/client/src/components/Evaluations.tsx b/client/src/components/Evaluations.tsx index 1b992a1d..e6e5bfec 100644 --- a/client/src/components/Evaluations.tsx +++ b/client/src/components/Evaluations.tsx @@ -18,15 +18,10 @@ const Evaluations: React.FC = ({ onError }) => { const [selectedClass, setSelectedClass] = useState(null); const [isLoading, setIsLoading] = useState(false); - // Predefined evaluation goals - const evaluationGoals = [ - 'Requirements', - 'Configuration Management', - 'Project Management', - 'Design', - 'Tests', - 'Refactoring' - ]; + // Use metas provided by the selected class; fallback to empty array + const evaluationGoals = selectedClass && Array.isArray(selectedClass.metas) && selectedClass.metas.length > 0 + ? selectedClass.metas + : []; const loadClasses = useCallback(async () => { try { @@ -127,7 +122,7 @@ const Evaluations: React.FC = ({ onError }) => { )} - {selectedClass && selectedClass.enrollments.length === 0 && ( + {selectedClass && selectedClass.enrollments.length === 0 && (
= ({ onError }) => {
)} - {selectedClass && selectedClass.enrollments.length > 0 && ( + {selectedClass && selectedClass.enrollments.length > 0 && (
{/*Componente de importacao de notas de uma planilha, vai reagir as mudacas do classId */}
@@ -155,9 +150,13 @@ const Evaluations: React.FC = ({ onError }) => { Student - {evaluationGoals.map(goal => ( - {goal} - ))} + {evaluationGoals.length > 0 ? ( + evaluationGoals.map(goal => ( + {goal} + )) + ) : ( + No metas defined + )} @@ -173,7 +172,8 @@ const Evaluations: React.FC = ({ onError }) => { return ( {student.name} - {evaluationGoals.map(goal => { + {evaluationGoals.length > 0 ? ( + evaluationGoals.map(goal => { const currentGrade = studentEvaluations[goal] || ''; return ( @@ -190,7 +190,10 @@ const Evaluations: React.FC = ({ onError }) => { ); - })} + }) + ) : ( + No metas + )} ); })} diff --git a/client/src/features/criacao-metas.feature b/client/src/features/criacao-metas.feature index 8a8ab92b..c2c5840d 100644 --- a/client/src/features/criacao-metas.feature +++ b/client/src/features/criacao-metas.feature @@ -12,12 +12,6 @@ Feature: Cadastro de Metas para Turmas THEN vejo a notificação "Metas criadas com sucesso!" AND a listagem de metas da turma "engenharia-de-software-e-sistemas" exibe os itens com títulos "Requisitos" e "Testes de software" - Scenario: Tentar criar metas com array vazio (sem títulos) - GIVEN não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" - WHEN eu tento submeter as metas com um array vazio - THEN vejo a mensagem de erro "As metas de uma turma não devem ser vazias!" - AND nenhuma meta é criada na turma "engenharia-de-software-e-sistemas" - Scenario: Tentar criar metas com títulos duplicados no mesmo envio GIVEN não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" WHEN eu envio as metas da turma com os títulos: diff --git a/client/src/services/ClassService.ts b/client/src/services/ClassService.ts index aa9fc5fa..9615e3d5 100644 --- a/client/src/services/ClassService.ts +++ b/client/src/services/ClassService.ts @@ -1,4 +1,4 @@ -import { Class } from '../types/Class'; +import { Class, CreateClassRequest, UpdateClassRequest } from '../types/Class'; import { ReportData } from '../types/Report'; const API_BASE_URL = 'http://localhost:3005'; @@ -20,7 +20,7 @@ class ClassService { } } - static async addClass(classData: Omit): Promise { + static async addClass(classData: CreateClassRequest): Promise { try { const response = await fetch(`${API_BASE_URL}/api/classes`, { method: 'POST', @@ -42,7 +42,7 @@ class ClassService { } } - static async updateClass(classId: string, classData: Omit): Promise { + static async updateClass(classId: string, classData: UpdateClassRequest): Promise { try { const response = await fetch(`${API_BASE_URL}/api/classes/${classId}`, { method: 'PUT', @@ -96,6 +96,27 @@ class ClassService { } } + static async setMetas(classId: string, metas: string[]): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/classes/${classId}/metas`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metas }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to set metas'); + } + + return data; + } catch (error) { + console.error('Error setting metas:', error); + throw error; + } + } + } export default ClassService; \ No newline at end of file diff --git a/client/src/types/Class.ts b/client/src/types/Class.ts index adb818d8..e4e53a6f 100644 --- a/client/src/types/Class.ts +++ b/client/src/types/Class.ts @@ -6,6 +6,8 @@ export interface Class { topic: string; semester: number; year: number; + metas: string[]; + metasLocked?: boolean; especificacaoDoCalculoDaMedia: EspecificacaoDoCalculoDaMedia; enrollments: Enrollment[]; } @@ -15,12 +17,14 @@ export interface CreateClassRequest { semester: number; year: number; especificacaoDoCalculoDaMedia: EspecificacaoDoCalculoDaMedia; + metas?: string[]; } export interface UpdateClassRequest { topic?: string; semester?: number; year?: number; + metas?: string[]; } // Helper function to generate class ID From b8e4d27a5c3917f91e918f5a80a3a854c093a025 Mon Sep 17 00:00:00 2001 From: basb Date: Sat, 6 Dec 2025 16:27:08 -0300 Subject: [PATCH 05/12] primeiro teste de gui implementado --- client/src/App.css | 2 +- client/src/components/Classes.tsx | 36 +- client/src/features/criacao-metas.feature | 52 +- .../step-definitions/criacao-metas.steps.ts | 448 ++++++++++++++++++ 4 files changed, 481 insertions(+), 57 deletions(-) create mode 100644 client/src/step-definitions/criacao-metas.steps.ts diff --git a/client/src/App.css b/client/src/App.css index 1a8eab7d..6745f602 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -833,7 +833,7 @@ tbody tr:last-child td { /* Create metas: same visual as primary submit button */ .meta-create-btn { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; + color: rgb(255, 255, 255); padding: 0.75rem 1.5rem; border-radius: 6px; font-weight: 600; diff --git a/client/src/components/Classes.tsx b/client/src/components/Classes.tsx index 877b90ee..46335a17 100644 --- a/client/src/components/Classes.tsx +++ b/client/src/components/Classes.tsx @@ -517,15 +517,22 @@ const Classes: React.FC = ({
)} - {/* Metas Modal (separate from enrollment modal) */} + {/* Metas Modal reusing enrollment modal design */} {metaPanelClass && ( -
-
-
+
+
+

Metas for {metaPanelClass.topic}

- +
-
+ +
{metaPanelClass.metas && metaPanelClass.metas.length > 0 ? (

Existing Metas

@@ -536,9 +543,6 @@ const Classes: React.FC = ({ ))} -
- -
) : ( <> @@ -577,13 +581,19 @@ const Classes: React.FC = ({ )) )} + + )} -
+
+ {metaPanelClass.metas && metaPanelClass.metas.length > 0 ? ( + + ) : ( + <> -
- - )} + + )} +
diff --git a/client/src/features/criacao-metas.feature b/client/src/features/criacao-metas.feature index c2c5840d..d8a4b623 100644 --- a/client/src/features/criacao-metas.feature +++ b/client/src/features/criacao-metas.feature @@ -1,46 +1,12 @@ -Feature: Cadastro de Metas para Turmas +@gui +Feature: Criacao de metas Background: - GIVEN a turma "engenharia-de-software-e-sistemas" existe no sistema - AND estou na página de criação de metas da turma "engenharia-de-software-e-sistemas" + Given a turma "engenharia-de-software-e-sistemas" existe no sistema + And estou na página de criação de metas da turma "engenharia-de-software-e-sistemas" Scenario: Criar metas com sucesso - GIVEN não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" - WHEN eu envio as metas da turma com os títulos: - | Requisitos | - | Testes de software | - AND eu submeto a criação de metas - THEN vejo a notificação "Metas criadas com sucesso!" - AND a listagem de metas da turma "engenharia-de-software-e-sistemas" exibe os itens com títulos "Requisitos" e "Testes de software" - - Scenario: Tentar criar metas com títulos duplicados no mesmo envio - GIVEN não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" - WHEN eu envio as metas da turma com os títulos: - | Requisitos | - | Requisitos | - AND eu submeto o formulário de criação de metas - THEN vejo a mensagem de erro "Metas não podem conter duplicatas!" - AND nenhuma meta é criada na turma "engenharia-de-software-e-sistemas" - - Scenario: Atualizar metas para serem criadas - GIVEN as seguintes metas já foram adicionadas para a criação na turma "engenharia-de-software-e-sistemas": - | Requisitos | - | Testes de software | - |Refatoração | - WHEN eu edito as metas adicionadas para a criação, alterando os títulos para: - | Gerência de projetos | - | Testes de software | - | Refatoração | - THEN eu vejo a lista de metas adicionadas para criação atualizada com os títulos: - | Gerência de projetos | - | Testes de software | - | Refatoração | - - Scenario: Remover metas antes de serem criadas - GIVEN as seguintes metas já foram adicionadas para a criação na turma "engenharia-de-software-e-sistemas": - | Requisitos | - | Testes de software | - | Refatoração | - WHEN eu removo a meta com o título "Testes de software" da lista de metas adicionadas para criação - THEN eu vejo a lista de metas adicionadas para criação atualizada com os títulos: - | Requisitos | - | Refatoração | + Given não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" + When adiciono as metas "Requisitos" e "Testes de software" para a turma "engenharia-de-software-e-sistemas" + And eu submeto a criação de metas + Then vejo a notificação "Metas criadas com sucesso!" + And a listagem de metas da turma "engenharia-de-software-e-sistemas" exibe os itens com títulos "Requisitos" e "Testes de software" diff --git a/client/src/step-definitions/criacao-metas.steps.ts b/client/src/step-definitions/criacao-metas.steps.ts new file mode 100644 index 00000000..8c58ce67 --- /dev/null +++ b/client/src/step-definitions/criacao-metas.steps.ts @@ -0,0 +1,448 @@ +import { Given, When, Then, Before, After, DataTable, setDefaultTimeout } from '@cucumber/cucumber'; +import { Browser, Page, launch } from 'puppeteer'; +import expect from 'expect'; + +// Set default timeout for all steps +setDefaultTimeout(30 * 1000); // 30 seconds + +// Helper function to format CPF like the frontend does +function formatCPF(value: string): string { + const digits = value.replace(/\D/g, ''); + if (digits.length <= 11) { + return digits.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'); + } + return digits.slice(0, 11).replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'); +} + +let browser: Browser; +let page: Page; +const baseUrl = 'http://localhost:3004'; +const serverUrl = 'http://localhost:3005'; +// track classes created during tests to clean up later +const createdClassIds: string[] = []; + +// small helper sleep (avoids using page.waitForTimeout which may not exist on types) +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +Before({ tags: '@gui' }, async function () { + browser = await launch({ + headless: false, // Set to true for CI/CD + slowMo: 50 // Slow down actions for visibility + }); + page = await browser.newPage(); + await page.setViewport({ width: 1280, height: 720 }); +}); + +Given('a turma {string} existe no sistema', async function (string) { + // Ensure the backend has the turma (create if missing) and open the Classes UI + const slug = string; + const topic = slug.replace(/-/g, ' '); + + try { + // Check all classes and find one with matching topic (case-insensitive) + const resp = await fetch(`${serverUrl}/api/classes`); + if (resp.ok) { + const classesList = await resp.json(); + // delete any existing classes that match the topic to ensure a fresh start + const matches = classesList.filter((c: any) => (c.topic || '').toLowerCase() === topic.toLowerCase() || (c.topic || '').toLowerCase().includes(topic.toLowerCase())); + for (const m of matches) { + try { + await fetch(`${serverUrl}/api/classes/${encodeURIComponent(m.id)}`, { method: 'DELETE' }); + } catch { + // ignore deletion errors, continue + } + } + + // create a new class for the test + const year = new Date().getFullYear(); + const createResp = await fetch(`${serverUrl}/api/classes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic, semester: 1, year }) + }); + if (createResp.ok) { + const created = await createResp.json(); + if (created && created.id) createdClassIds.push(created.id); + } else { + throw new Error(`Erro ao criar turma ${topic}: ${createResp.status}`); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(`Aviso: não foi possível verificar/criar turma via API (${error}). Tentando via UI.`); + } + + // Open app and go to Classes tab, then find the class row and open Metas modal + await page.goto(baseUrl, { waitUntil: 'networkidle2' }).catch(() => undefined); + // click Classes tab + const classesTab = await page.$('[data-testid="classes-tab"]'); + if (classesTab) { + await classesTab.click(); + } + + // wait for classes table and find matching row by topic text + await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => undefined); + const rows = await page.$$('table tbody tr'); + for (const row of rows) { + const topicCell = await row.$('td strong'); + if (!topicCell) continue; + const txt = (await page.evaluate(el => (el.textContent || '').trim(), topicCell)) || ''; + if (txt.toLowerCase().includes(topic.toLowerCase())) { + // click Metas button inside this row + const metasBtn = await row.$('.metas-btn'); + if (metasBtn) { + await metasBtn.click().catch(() => undefined); + // wait for metas modal + await page.waitForSelector('.enrollment-overlay .enrollment-modal', { timeout: 3000 }).catch(() => undefined); + } + break; + } + } + + return; +}); + +// Cleanup created classes after tests +After({ tags: '@gui' }, async function () { + // delete classes created during the test run + for (const id of createdClassIds) { + try { + await fetch(`${serverUrl}/api/classes/${encodeURIComponent(id)}`, { method: 'DELETE' }); + } catch { + // ignore + } + } + + // close browser if open + if (browser) { + await browser.close().catch(() => undefined); + } +}); + +Given('estou na página de criação de metas da turma {string}', async function (string) { + const slug = string; + + // Open Classes tab and open the Metas modal for the given class + const topic = slug.replace(/-/g, ' '); + await page.goto(baseUrl, { waitUntil: 'networkidle2' }).catch(() => undefined); + const classesTab = await page.$('[data-testid="classes-tab"]'); + if (classesTab) await classesTab.click().catch(() => undefined); + await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => undefined); + + const rows = await page.$$('table tbody tr'); + for (const row of rows) { + const topicCell = await row.$('td strong'); + if (!topicCell) continue; + const txt = (await page.evaluate(el => (el.textContent || '').trim(), topicCell)) || ''; + if (txt.toLowerCase().includes(topic.toLowerCase())) { + const metasBtn = await row.$('.metas-btn'); + if (metasBtn) { + await metasBtn.click().catch(() => undefined); + // wait for metas modal + await page.waitForSelector('.enrollment-overlay .enrollment-modal', { timeout: 3000 }).catch(() => undefined); + } + break; + } + } + + // ensure modal has the specific input + await page.waitForSelector('.enrollment-overlay .enrollment-modal input[placeholder="New meta"]', { timeout: 3000 }).catch(() => undefined); + return; +}); + +Given('não existe nenhuma meta cadastrada na turma {string}', async function (string) { + const slug = string; + // Ensure metas modal is open for this class (reuse previous step behavior) + await page.waitForSelector('.enrollment-overlay .enrollment-modal', { timeout: 3000 }).catch(() => undefined); + const modal = await page.$('.enrollment-overlay .enrollment-modal'); + if (!modal) return; + + // Remove any local metas present (click delete buttons inside modal) + const deleteButtons = await modal.$$('.meta-delete-btn'); + for (const b of deleteButtons) { + await b.click().catch(() => undefined); + await sleep(150); + } + + // If there is an existing-metas list (server-side metas), we cannot delete via UI; ensure it's empty or test will fail later + const existingCount = await modal.$$eval('.existing-metas li', els => els.length).catch(() => 0); + if (existingCount > 0) { + // log for debugging; do not throw + // eslint-disable-next-line no-console + console.log('Aviso: turma já possui metas no servidor; tests podem falhar se metas estiverem lockadas.'); + } + + return; +}); + +When('adiciono as metas {string} e {string} para a turma {string}', async function (string, string2, string3) { + const title1 = string; + const title2 = string2; + const slug = string3; + // Work inside the modal to avoid touching other inputs + await page.waitForSelector('.enrollment-overlay .enrollment-modal', { timeout: 3000 }); + const modal = await page.$('.enrollment-overlay .enrollment-modal'); + if (!modal) throw new Error('Metas modal not open'); + + async function addToModal(title: string) { + if (!modal) throw new Error('Metas modal not open'); + const input = await modal.$('input[placeholder="New meta"]'); + if (!input) throw new Error('Meta input not found in modal'); + await input.click({ clickCount: 3 }).catch(() => undefined); + await input.focus(); + await page.keyboard.down('Control'); + await page.keyboard.press('A'); + await page.keyboard.up('Control'); + await page.keyboard.press('Backspace'); + await input.type(title); + + const addBtn = await modal.$('.meta-add-btn'); + if (addBtn) { + await addBtn.click().catch(() => undefined); + } else { + // try button by text inside modal + const modalButtons = await modal.$$('button'); + for (const b of modalButtons) { + const txt = await page.evaluate(el => (el.textContent || '').trim(), b); + if (/add meta|add/i.test(txt)) { await b.click().catch(() => undefined); break; } + } + } + + // wait until the modal list contains the title + await page.waitForFunction((t) => { + const modalEl = document.querySelector('.enrollment-overlay .enrollment-modal'); + if (!modalEl) return false; + return Array.from(modalEl.querySelectorAll('.local-metas-list li, .local-meta-item, .local-metas-list span')).some(n => (n.textContent || '').includes(t)); + }, { timeout: 2000 }, title); + } + + await addToModal(title1); + await addToModal(title2); + + return; +}); + + +When('eu submeto a criação de metas', async function () { + // Submit inside modal + const modal = await page.$('.enrollment-overlay .enrollment-modal'); + if (!modal) throw new Error('Metas modal not open'); + const createBtn = await modal.$('.meta-create-btn'); + if (createBtn) { + await createBtn.click().catch(() => undefined); + } else { + // try buttons by text inside modal + const modalButtons = await modal.$$('button'); + for (const b of modalButtons) { + const txt = await page.evaluate(el => (el.textContent || '').trim(), b); + if (/create metas|create meta|create/i.test(txt)) { await b.click().catch(() => undefined); break; } + } + } + // Prefer capturing the server response for the metas POST (reliable even if UI toast is fleeting) + try { + const resp = await page.waitForResponse((response) => { + try { + const url = response.url(); + return response.request().method() === 'POST' && url.includes('/metas'); + } catch { + return false; + } + }, { timeout: 5000 }); + + try { + const json = await resp.json(); + // @ts-ignore + this.lastNotification = (json && (json.message || json.msg)) ? (json.message || json.msg) : ''; + } catch { + // ignore JSON parse errors + // @ts-ignore + this.lastNotification = ''; + } + } catch { + // If we didn't catch the network response, fallback to capturing visible notification briefly + const notifSelectors = ['[data-testid="toast"]', '.toast', '.notification', '.alert-success', '.toastr', '[role="alert"]', '[aria-live]']; + let captured = ''; + for (const s of notifSelectors) { + try { + await page.waitForSelector(s, { timeout: 1500 }); + const t = await page.$$eval(s, els => els.map(e => (e.textContent || '').trim()).find(t => t && t.length > 0) || ''); + if (t) { captured = t; break; } + } catch { + // try next selector + } + } + if (!captured) { + try { + captured = await page.evaluate(() => (document.body && document.body.innerText) ? (document.body.innerText || '').trim() : ''); + } catch { + captured = ''; + } + } + // @ts-ignore + this.lastNotification = captured || ''; + } + return; +}); + +Then('vejo a notificação {string}', async function (string) { + const expected = string; + const expectedLower = expected.toLowerCase(); + + // Try multiple approaches to find notification text, with slightly longer timeouts + const notifSelectors = [ + '[data-testid="toast"]', + '.toast', + '.notification', + '.alert-success', + '.toastr', + '[role="alert"]', + '[aria-live]' + ]; + + let foundText = ''; + + // 1) Check explicit selectors + for (const s of notifSelectors) { + try { + await page.waitForSelector(s, { timeout: 2000 }); + // get the first visible matching element's text + const t = await page.$$eval(s, els => els.map(e => (e.textContent || '').trim()).find(t => t && t.length > 0) || ''); + if (t && t.length > 0) { foundText = t; break; } + } catch { + // no element for this selector, continue + } + } + + // 2) Inspect common toast containers by role/aria-live if still empty + if (!foundText) { + try { + const aria = await page.$$eval('[aria-live], [role="status"], [role="alert"]', els => els.map(e => (e.textContent || '').trim()).find(t => t && t.length > 0) || ''); + if (aria) foundText = aria; + } catch { + // ignore + } + } + + // 3) Final fallback: scan visible text nodes for keywords (longer timeout) + if (!foundText) { + // first, wait a bit for any notification text to appear anywhere in the body + try { + await page.waitForFunction((exp) => (document && document.body && document.body.innerText || '').toLowerCase().includes(exp), { timeout: 5000 }, expectedLower); + } catch { + // ignore timeout, we'll still try to read text + } + + foundText = await page.evaluate(() => { + function visible(n: Element) { + const style = window.getComputedStyle(n as HTMLElement); + return style && style.visibility !== 'hidden' && style.display !== 'none' && (n as HTMLElement).offsetParent !== null; + } + const nodes = Array.from(document.querySelectorAll('body *')) as Element[]; + for (const n of nodes) { + if (!visible(n)) continue; + const txt = (n.textContent || '').trim(); + if (!txt) continue; + if (/metas criadas com sucesso|metas criadas|criado|sucesso|erro/i.test(txt)) return txt; + } + // as a last resort, return the whole body text + return (document && document.body && (document.body.innerText || '').trim()) || ''; + }); + } + + // normalize whitespace and assert + const normalized = (foundText || '').replace(/\s+/g, ' ').trim(); + const foundLower = normalized.toLowerCase(); + if (foundLower.includes(expectedLower)) { + expect(true).toBeTruthy(); + return; + } + + // fallback: remove accents and punctuation and compare + const strip = (s: string) => { + // basic accent removal + const accentMap: { [k: string]: string } = { + 'à':'a','á':'a','â':'a','ã':'a','ä':'a','å':'a', + 'ç':'c', + 'è':'e','é':'e','ê':'e','ë':'e', + 'ì':'i','í':'i','î':'i','ï':'i', + 'ñ':'n', + 'ò':'o','ó':'o','ô':'o','õ':'o','ö':'o', + 'ù':'u','ú':'u','û':'u','ü':'u', + 'ý':'y','ÿ':'y' + }; + let out = s.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + // replace known accents + out = out.split('').map(ch => accentMap[ch] || ch).join(''); + // remove non-alphanumeric (keep spaces) + out = out.replace(/[^a-z0-9\s]/gi, ''); + return out.toLowerCase().replace(/\s+/g, ' ').trim(); + }; + + const strippedFound = strip(foundLower); + const strippedExpected = strip(expectedLower); + // final assertion: prefer lastNotification captured during submit if available + // @ts-ignore + const lastNotif = (this && this.lastNotification) ? (this.lastNotification as string) : ''; + if (lastNotif) { + const ln = lastNotif.replace(/\s+/g, ' ').trim().toLowerCase(); + if (ln.includes(expectedLower) || strip(ln).includes(strip(expectedLower))) { + expect(true).toBeTruthy(); + return; + } + } + + // otherwise use the found text from DOM + if (strippedFound.includes(strippedExpected)) { + expect(true).toBeTruthy(); + return; + } + + // nothing matched: fail with the collected text for debugging + throw new Error(`Notification not found. Collected text: "${foundLower}"`); +}); + + + +Then('a listagem de metas da turma {string} exibe os itens com títulos {string} e {string}', async function (string, string2, string3) { + const slug = string; + const title1 = string2; + const title2 = string3; + + // After creation, metas are visible in the modal's existing-metas or local-metas-list; navigate to classes page to view + await page.goto(baseUrl, { waitUntil: 'networkidle2' }).catch(() => undefined); + const classesTab = await page.$('[data-testid="classes-tab"]'); + if (classesTab) await classesTab.click().catch(() => undefined); + await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => undefined); + + // open metas modal for the class again + const rows = await page.$$('table tbody tr'); + for (const row of rows) { + const topicCell = await row.$('td strong'); + if (!topicCell) continue; + const txt = (await page.evaluate(el => (el.textContent || '').trim(), topicCell)) || ''; + if (txt.toLowerCase().includes(slug.replace(/-/g, ' ').toLowerCase())) { + const metasBtn = await row.$('.metas-btn'); + if (metasBtn) { + await metasBtn.click().catch(() => undefined); + await sleep(500); + } + break; + } + } + + // wait a bit for list to render + await sleep(500); + + // In the metas modal, check local-metas-list and existing-metas + const metasText = await page.evaluate(() => Array.from(document.querySelectorAll('.local-metas-list li, .existing-metas li, .local-meta-item, .local-metas-list .local-meta-item, .local-metas-list span')).map(n => (n.textContent || '').trim())); + const found1 = metasText.some(t => t.includes(title1)); + const found2 = metasText.some(t => t.includes(title2)); + + expect(found1).toBeTruthy(); + expect(found2).toBeTruthy(); + + return; +}); + From a219f3bbfa44dce0979415ac7633cc4d0a558259 Mon Sep 17 00:00:00 2001 From: basb Date: Mon, 8 Dec 2025 15:03:13 -0300 Subject: [PATCH 06/12] =?UTF-8?q?implementa=C3=A7=C3=A3o=20dos=20testes=20?= =?UTF-8?q?de=20gui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/cucumber.js | 2 + client/src/App.tsx | 2 +- client/src/components/Classes.tsx | 5 +- client/src/features/criacao-metas.feature | 47 ++ .../step-definitions/criacao-metas.steps.ts | 658 +++++++----------- .../step-definitions/server-student-steps.ts | 2 +- client/src/step-definitions/setup.ts | 26 + client/src/step-definitions/student-steps.ts | 342 ++++----- 8 files changed, 529 insertions(+), 555 deletions(-) create mode 100644 client/src/step-definitions/setup.ts diff --git a/client/cucumber.js b/client/cucumber.js index d4ecbe35..ef8250b2 100644 --- a/client/cucumber.js +++ b/client/cucumber.js @@ -1,3 +1,5 @@ +process.env.TS_NODE_PROJECT = 'tsconfig.test.json'; + module.exports = { default: { require: [ diff --git a/client/src/App.tsx b/client/src/App.tsx index ffdf9d70..63a9df11 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -113,7 +113,7 @@ const App: React.FC = () => { const handleSuccess = (message: string) => { setSuccess(message); // Clear success after a short timeout - setTimeout(() => setSuccess(''), 4000); + setTimeout(() => setSuccess(''), 8000); }; return ( diff --git a/client/src/components/Classes.tsx b/client/src/components/Classes.tsx index 46335a17..a789535a 100644 --- a/client/src/components/Classes.tsx +++ b/client/src/components/Classes.tsx @@ -184,12 +184,9 @@ const Classes: React.FC = ({ }; // Metas UI handlers + // Add meta locally; do not block duplicates here — server is responsible for uniqueness const handleAddMetaLocally = (meta: string) => { if (!meta || !meta.trim()) return; - if (localMetas.includes(meta)) { - onError('Meta já adicionada'); - return; - } setLocalMetas(prev => [...prev, meta]); }; diff --git a/client/src/features/criacao-metas.feature b/client/src/features/criacao-metas.feature index d8a4b623..cf698f99 100644 --- a/client/src/features/criacao-metas.feature +++ b/client/src/features/criacao-metas.feature @@ -1,5 +1,6 @@ @gui Feature: Criacao de metas + Background: Given a turma "engenharia-de-software-e-sistemas" existe no sistema And estou na página de criação de metas da turma "engenharia-de-software-e-sistemas" @@ -10,3 +11,49 @@ Feature: Criacao de metas And eu submeto a criação de metas Then vejo a notificação "Metas criadas com sucesso!" And a listagem de metas da turma "engenharia-de-software-e-sistemas" exibe os itens com títulos "Requisitos" e "Testes de software" + + Scenario: Tentar criar metas sem título + When tento adicionar uma meta sem título para a turma "engenharia-de-software-e-sistemas" + Then eu vejo que não está disponível a opção de submissão de criação de metas + And a listagem de metas da turma "engenharia-de-software-e-sistemas" permanece vazia + + Scenario: Tentar criar metas duplicadas + Given não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" + When adiciono as metas "Requisitos" e "Requisitos" para a turma "engenharia-de-software-e-sistemas" + And eu submeto a criação de metas + Then vejo a notificação "Metas não podem conter duplicatas!" + And a listagem de metas da turma "engenharia-de-software-e-sistemas" permanece vazia + + Scenario: Cancelar a criação de metas para uma turma e criar para outra + Given não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" + And a turma "inteligencia-artificial" existe no sistema + And não existe nenhuma meta cadastrada na turma "inteligencia-artificial" + When adiciono as metas "Requisitos" e "Testes de software" para a turma "engenharia-de-software-e-sistemas" + And cancelo a criação de metas para a turma "engenharia-de-software-e-sistemas" + And adiciono as metas "Redes Neurais" e "Aprendizado de Máquina" para a turma "inteligencia-artificial" + And eu submeto a criação de metas + Then vejo a notificação "Metas criadas com sucesso!" + And a listagem de metas da turma "engenharia-de-software-e-sistemas" permanece vazia + And a listagem de metas da turma "inteligencia-artificial" exibe os itens com títulos "Redes Neurais" e "Aprendizado de Máquina" + + Scenario: Editar metas para serem cadastradas + Given não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" + When adiciono as metas "Requisitos" e "Testes de software" para a turma "engenharia-de-software-e-sistemas" + And edito a meta "Testes de software" para "Validação de Software" na turma "engenharia-de-software-e-sistemas" + And eu submeto a criação de metas + Then vejo a notificação "Metas criadas com sucesso!" + And a listagem de metas da turma "engenharia-de-software-e-sistemas" exibe os itens com títulos "Requisitos" e "Validação de Software" + + Scenario: Deletar metas para serem cadastradas + Given não existe nenhuma meta cadastrada na turma "engenharia-de-software-e-sistemas" + When adiciono as metas "Requisitos" e "Testes de software" para a turma "engenharia-de-software-e-sistemas" + And deleto a meta "Testes de software" na turma "engenharia-de-software-e-sistemas" + And eu submeto a criação de metas + Then vejo a notificação "Metas criadas com sucesso!" + And a listagem de metas da turma "engenharia-de-software-e-sistemas" exibe o item com título "Requisitos" + + Scenario: Turmas com metas já cadastradas não permitem criação de novas metas + Given a turma "sistemas-distribuidos" existe no sistema + And a turma "sistemas-distribuidos" possui as metas "Comunicação" e "Sincronização" cadastradas + When tento acessar a página de criação de metas da turma "sistemas-distribuidos" + Then eu vejo que não está disponível a opção de criação de metas para a turma "sistemas-distribuidos" \ No newline at end of file diff --git a/client/src/step-definitions/criacao-metas.steps.ts b/client/src/step-definitions/criacao-metas.steps.ts index 8c58ce67..8cdb451a 100644 --- a/client/src/step-definitions/criacao-metas.steps.ts +++ b/client/src/step-definitions/criacao-metas.steps.ts @@ -1,448 +1,320 @@ -import { Given, When, Then, Before, After, DataTable, setDefaultTimeout } from '@cucumber/cucumber'; +import { Given, When, Then, Before, After, setDefaultTimeout } from '@cucumber/cucumber'; import { Browser, Page, launch } from 'puppeteer'; import expect from 'expect'; +import { scope } from './setup'; -// Set default timeout for all steps -setDefaultTimeout(30 * 1000); // 30 seconds +// setDefaultTimeout(10 * 1000); -// Helper function to format CPF like the frontend does -function formatCPF(value: string): string { - const digits = value.replace(/\D/g, ''); - if (digits.length <= 11) { - return digits.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'); - } - return digits.slice(0, 11).replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'); -} - -let browser: Browser; -let page: Page; -const baseUrl = 'http://localhost:3004'; -const serverUrl = 'http://localhost:3005'; -// track classes created during tests to clean up later +const BASE = 'http://localhost:3004'; +const API = 'http://localhost:3005'; const createdClassIds: string[] = []; - -// small helper sleep (avoids using page.waitForTimeout which may not exist on types) -function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); +let currentOpenTopic = ''; + +const SELECTORS = { + classesTab: '[data-testid="classes-tab"]', + tableRows: 'table tbody tr', + modal: '.enrollment-overlay .enrollment-modal', + metaInput: '.enrollment-overlay .enrollment-modal input[placeholder="New meta"]', + metaAddBtn: '.meta-add-btn', + metaCreateBtn: '.meta-create-btn', + modalClose: '.modal-close-btn', + metaItems: '.local-metas-list li, .existing-metas li, .local-meta-item, .local-metas-list span', + notification: '.success-message, .alert-success, [role="alert"], [aria-live]' +}; + +function toTopic(slug: string) { return slug.replace(/-/g, ' '); } +async function sleep(ms = 200) { return new Promise(r => setTimeout(r, ms)); } + +async function clickIf(selector: string, opts = { timeout: 1000 }) { + const el = await scope.page.waitForSelector(selector, { timeout: opts.timeout }).catch(() => null); + if (el) await el.click(); + return el; } -Before({ tags: '@gui' }, async function () { - browser = await launch({ - headless: false, // Set to true for CI/CD - slowMo: 50 // Slow down actions for visibility - }); - page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 720 }); -}); - -Given('a turma {string} existe no sistema', async function (string) { - // Ensure the backend has the turma (create if missing) and open the Classes UI - const slug = string; - const topic = slug.replace(/-/g, ' '); - - try { - // Check all classes and find one with matching topic (case-insensitive) - const resp = await fetch(`${serverUrl}/api/classes`); - if (resp.ok) { - const classesList = await resp.json(); - // delete any existing classes that match the topic to ensure a fresh start - const matches = classesList.filter((c: any) => (c.topic || '').toLowerCase() === topic.toLowerCase() || (c.topic || '').toLowerCase().includes(topic.toLowerCase())); - for (const m of matches) { - try { - await fetch(`${serverUrl}/api/classes/${encodeURIComponent(m.id)}`, { method: 'DELETE' }); - } catch { - // ignore deletion errors, continue - } - } - - // create a new class for the test - const year = new Date().getFullYear(); - const createResp = await fetch(`${serverUrl}/api/classes`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, semester: 1, year }) - }); - if (createResp.ok) { - const created = await createResp.json(); - if (created && created.id) createdClassIds.push(created.id); - } else { - throw new Error(`Erro ao criar turma ${topic}: ${createResp.status}`); - } - } - } catch (error) { - // eslint-disable-next-line no-console - console.log(`Aviso: não foi possível verificar/criar turma via API (${error}). Tentando via UI.`); - } - - // Open app and go to Classes tab, then find the class row and open Metas modal - await page.goto(baseUrl, { waitUntil: 'networkidle2' }).catch(() => undefined); - // click Classes tab - const classesTab = await page.$('[data-testid="classes-tab"]'); - if (classesTab) { - await classesTab.click(); +async function openMetasModalForTopic(topic: string) { + // avoid reopening if already open for same topic + if (currentOpenTopic === topic) return; + + // if modal open for another topic, close it first + const existingModal = await scope.page.$(SELECTORS.modal); + if (existingModal && currentOpenTopic && currentOpenTopic !== topic) { + const closeBtn = await existingModal.$(SELECTORS.modalClose); + if (closeBtn) await closeBtn.click().catch(() => {}); + currentOpenTopic = ''; + await scope.page.waitForSelector(SELECTORS.modal, { timeout: 1000 }).catch(() => {}); } - // wait for classes table and find matching row by topic text - await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => undefined); - const rows = await page.$$('table tbody tr'); + await scope.page.goto(BASE, { waitUntil: 'networkidle2' }); + await clickIf(SELECTORS.classesTab, { timeout: 2000 }); + await scope.page.waitForSelector(SELECTORS.tableRows, { timeout: 2000 }); + const rows = await scope.page.$$(SELECTORS.tableRows); for (const row of rows) { - const topicCell = await row.$('td strong'); - if (!topicCell) continue; - const txt = (await page.evaluate(el => (el.textContent || '').trim(), topicCell)) || ''; + const head = await row.$('td strong'); + if (!head) continue; + const txt = (await scope.page.evaluate(el => (el.textContent || '').trim(), head)) || ''; if (txt.toLowerCase().includes(topic.toLowerCase())) { - // click Metas button inside this row - const metasBtn = await row.$('.metas-btn'); - if (metasBtn) { - await metasBtn.click().catch(() => undefined); - // wait for metas modal - await page.waitForSelector('.enrollment-overlay .enrollment-modal', { timeout: 3000 }).catch(() => undefined); + const btn = await row.$('.metas-btn'); + if (btn) { + await btn.click(); + await scope.page.waitForSelector(SELECTORS.modal, { timeout: 2000 }); + currentOpenTopic = topic; } break; } } +} - return; -}); - -// Cleanup created classes after tests -After({ tags: '@gui' }, async function () { - // delete classes created during the test run - for (const id of createdClassIds) { - try { - await fetch(`${serverUrl}/api/classes/${encodeURIComponent(id)}`, { method: 'DELETE' }); - } catch { - // ignore +async function ensureFreshClass(topic: string) { + try { + const resp = await fetch(`${API}/api/classes`); + if (!resp.ok) return; + const list = await resp.json(); + const matches = list.filter((c: any) => ((c.topic || '').toLowerCase() === topic.toLowerCase())); + for (const m of matches) { + await fetch(`${API}/api/classes/${encodeURIComponent(m.id)}`, { method: 'DELETE' }).catch(() => {}); } - } + const create = await fetch(`${API}/api/classes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ topic, semester: 1, year: new Date().getFullYear() }) + }); + if (create.ok) { + const c = await create.json(); + if (c && c.id) createdClassIds.push(c.id); + } + } catch (e) { /* ignore network issues in setup */ } +} - // close browser if open - if (browser) { - await browser.close().catch(() => undefined); +async function addMetasToModal(titles: string[]) { + for (const title of titles) { + await scope.page.waitForSelector(SELECTORS.metaInput, { timeout: 1500 }); + await scope.page.click(SELECTORS.metaInput, { clickCount: 3 }); + await scope.page.focus(SELECTORS.metaInput); + await scope.page.keyboard.down('Control'); await scope.page.keyboard.press('A'); await scope.page.keyboard.up('Control'); + await scope.page.keyboard.press('Backspace'); + await scope.page.type(SELECTORS.metaInput, title); + await scope.page.click(SELECTORS.metaAddBtn).catch(() => {}); + // wait until a metas item with the title appears + await scope.page.waitForFunction((t, sel) => Array.from(document.querySelectorAll(sel)).some(n => (n.textContent||'').includes(t)), { timeout: 1500 }, title, SELECTORS.metaItems).catch(() => {}); + await sleep(100); } -}); +} -Given('estou na página de criação de metas da turma {string}', async function (string) { - const slug = string; +async function captureNotificationNow() { + const sel = await scope.page.waitForSelector(SELECTORS.notification, { timeout: 1200 }).catch(() => null); + if (!sel) return ''; + return await scope.page.evaluate(el => (el.textContent || '').trim(), sel); +} - // Open Classes tab and open the Metas modal for the given class - const topic = slug.replace(/-/g, ' '); - await page.goto(baseUrl, { waitUntil: 'networkidle2' }).catch(() => undefined); - const classesTab = await page.$('[data-testid="classes-tab"]'); - if (classesTab) await classesTab.click().catch(() => undefined); - await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => undefined); +async function getMetasList() { + return await scope.page.evaluate((sel) => Array.from(document.querySelectorAll(sel)) + .map(n => (n.textContent || '').trim()) + .filter(t => t && t !== 'No local metas added yet'), SELECTORS.metaItems) + .catch(() => []); +} - const rows = await page.$$('table tbody tr'); - for (const row of rows) { - const topicCell = await row.$('td strong'); - if (!topicCell) continue; - const txt = (await page.evaluate(el => (el.textContent || '').trim(), topicCell)) || ''; - if (txt.toLowerCase().includes(topic.toLowerCase())) { - const metasBtn = await row.$('.metas-btn'); - if (metasBtn) { - await metasBtn.click().catch(() => undefined); - // wait for metas modal - await page.waitForSelector('.enrollment-overlay .enrollment-modal', { timeout: 3000 }).catch(() => undefined); - } - break; - } +After({ tags: '@gui' }, async function () { + for (const id of createdClassIds) { + try { await fetch(`${API}/api/classes/${encodeURIComponent(id)}`, { method: 'DELETE' }); } catch {} } - - // ensure modal has the specific input - await page.waitForSelector('.enrollment-overlay .enrollment-modal input[placeholder="New meta"]', { timeout: 3000 }).catch(() => undefined); - return; + currentOpenTopic = ''; }); -Given('não existe nenhuma meta cadastrada na turma {string}', async function (string) { - const slug = string; - // Ensure metas modal is open for this class (reuse previous step behavior) - await page.waitForSelector('.enrollment-overlay .enrollment-modal', { timeout: 3000 }).catch(() => undefined); - const modal = await page.$('.enrollment-overlay .enrollment-modal'); - if (!modal) return; - - // Remove any local metas present (click delete buttons inside modal) - const deleteButtons = await modal.$$('.meta-delete-btn'); - for (const b of deleteButtons) { - await b.click().catch(() => undefined); - await sleep(150); - } - - // If there is an existing-metas list (server-side metas), we cannot delete via UI; ensure it's empty or test will fail later - const existingCount = await modal.$$eval('.existing-metas li', els => els.length).catch(() => 0); - if (existingCount > 0) { - // log for debugging; do not throw - // eslint-disable-next-line no-console - console.log('Aviso: turma já possui metas no servidor; tests podem falhar se metas estiverem lockadas.'); - } - - return; +Given('a turma {string} existe no sistema', async function (slug: string) { + await ensureFreshClass(toTopic(slug)); }); -When('adiciono as metas {string} e {string} para a turma {string}', async function (string, string2, string3) { - const title1 = string; - const title2 = string2; - const slug = string3; - // Work inside the modal to avoid touching other inputs - await page.waitForSelector('.enrollment-overlay .enrollment-modal', { timeout: 3000 }); - const modal = await page.$('.enrollment-overlay .enrollment-modal'); - if (!modal) throw new Error('Metas modal not open'); - - async function addToModal(title: string) { - if (!modal) throw new Error('Metas modal not open'); - const input = await modal.$('input[placeholder="New meta"]'); - if (!input) throw new Error('Meta input not found in modal'); - await input.click({ clickCount: 3 }).catch(() => undefined); - await input.focus(); - await page.keyboard.down('Control'); - await page.keyboard.press('A'); - await page.keyboard.up('Control'); - await page.keyboard.press('Backspace'); - await input.type(title); - - const addBtn = await modal.$('.meta-add-btn'); - if (addBtn) { - await addBtn.click().catch(() => undefined); - } else { - // try button by text inside modal - const modalButtons = await modal.$$('button'); - for (const b of modalButtons) { - const txt = await page.evaluate(el => (el.textContent || '').trim(), b); - if (/add meta|add/i.test(txt)) { await b.click().catch(() => undefined); break; } - } - } +Given('estou na página de criação de metas da turma {string}', async function (slug: string) { + await openMetasModalForTopic(toTopic(slug)); + await scope.page.waitForSelector(SELECTORS.metaInput, { timeout: 2000 }); +}); - // wait until the modal list contains the title - await page.waitForFunction((t) => { - const modalEl = document.querySelector('.enrollment-overlay .enrollment-modal'); - if (!modalEl) return false; - return Array.from(modalEl.querySelectorAll('.local-metas-list li, .local-meta-item, .local-metas-list span')).some(n => (n.textContent || '').includes(t)); - }, { timeout: 2000 }, title); +Given('não existe nenhuma meta cadastrada na turma {string}', async function (slug: string) { + const topic = toTopic(slug); + await openMetasModalForTopic(topic); + const modal = await scope.page.$(SELECTORS.modal); + if (modal) { + const closeBtn = await modal.$(SELECTORS.modalClose); + if (closeBtn) await closeBtn.click(); + currentOpenTopic = ''; } - - await addToModal(title1); - await addToModal(title2); - - return; + await ensureFreshClass(topic); }); +When('adiciono as metas {string} e {string} para a turma {string}', async function (t1: string, t2: string, slug: string) { + await openMetasModalForTopic(toTopic(slug)); + await addMetasToModal([t1, t2]); +}); When('eu submeto a criação de metas', async function () { - // Submit inside modal - const modal = await page.$('.enrollment-overlay .enrollment-modal'); - if (!modal) throw new Error('Metas modal not open'); - const createBtn = await modal.$('.meta-create-btn'); - if (createBtn) { - await createBtn.click().catch(() => undefined); - } else { - // try buttons by text inside modal - const modalButtons = await modal.$$('button'); - for (const b of modalButtons) { - const txt = await page.evaluate(el => (el.textContent || '').trim(), b); - if (/create metas|create meta|create/i.test(txt)) { await b.click().catch(() => undefined); break; } - } - } - // Prefer capturing the server response for the metas POST (reliable even if UI toast is fleeting) - try { - const resp = await page.waitForResponse((response) => { - try { - const url = response.url(); - return response.request().method() === 'POST' && url.includes('/metas'); - } catch { - return false; - } - }, { timeout: 5000 }); - - try { - const json = await resp.json(); - // @ts-ignore - this.lastNotification = (json && (json.message || json.msg)) ? (json.message || json.msg) : ''; - } catch { - // ignore JSON parse errors - // @ts-ignore - this.lastNotification = ''; - } - } catch { - // If we didn't catch the network response, fallback to capturing visible notification briefly - const notifSelectors = ['[data-testid="toast"]', '.toast', '.notification', '.alert-success', '.toastr', '[role="alert"]', '[aria-live]']; - let captured = ''; - for (const s of notifSelectors) { - try { - await page.waitForSelector(s, { timeout: 1500 }); - const t = await page.$$eval(s, els => els.map(e => (e.textContent || '').trim()).find(t => t && t.length > 0) || ''); - if (t) { captured = t; break; } - } catch { - // try next selector - } - } - if (!captured) { - try { - captured = await page.evaluate(() => (document.body && document.body.innerText) ? (document.body.innerText || '').trim() : ''); - } catch { - captured = ''; - } - } - // @ts-ignore - this.lastNotification = captured || ''; - } - return; + const modal = await scope.page.$(SELECTORS.modal); + if (!modal) throw new Error('Modal não está aberto'); + const createBtn = await modal.$(SELECTORS.metaCreateBtn); + if (!createBtn) throw new Error('Botão de criação de metas não encontrado.'); + await createBtn.click(); + // GUI-only notification capture (short wait) + (this as any).lastNotification = await captureNotificationNow(); + // modal likely closed after submit + currentOpenTopic = ''; }); -Then('vejo a notificação {string}', async function (string) { - const expected = string; - const expectedLower = expected.toLowerCase(); - - // Try multiple approaches to find notification text, with slightly longer timeouts - const notifSelectors = [ - '[data-testid="toast"]', - '.toast', - '.notification', - '.alert-success', - '.toastr', - '[role="alert"]', - '[aria-live]' - ]; - - let foundText = ''; +Then('vejo a notificação {string}', async function (expected: string) { + const captured: string = (this as any).lastNotification || ''; + if (captured === expected) return expect(true).toBeTruthy(); + // fallback: quick scan of body + try { + await scope.page.waitForFunction((exp) => document.body && (document.body.innerText || '').includes(exp), { timeout: 1000 }, expected); + return expect(true).toBeTruthy(); + } catch {} + throw new Error(`Notificação não encontrada na GUI. Esperado: "${expected}", Capturado: "${captured}"`); +}); - // 1) Check explicit selectors - for (const s of notifSelectors) { - try { - await page.waitForSelector(s, { timeout: 2000 }); - // get the first visible matching element's text - const t = await page.$$eval(s, els => els.map(e => (e.textContent || '').trim()).find(t => t && t.length > 0) || ''); - if (t && t.length > 0) { foundText = t; break; } - } catch { - // no element for this selector, continue - } +Then('a listagem de metas da turma {string} exibe os itens com títulos {string} e {string}', async function (slug: string, t1: string, t2: string) { + const topic = toTopic(slug); + let metas: string[] = []; + for (let i = 0; i < 3; i++) { + await openMetasModalForTopic(topic); + metas = await getMetasList(); + if (metas.some(m => m.includes(t1)) && metas.some(m => m.includes(t2))) return; + await sleep(500); } + throw new Error(`Metas não encontradas. Esperado: "${t1}", "${t2}". Lista atual: [${metas.join(', ')}]`); +}); - // 2) Inspect common toast containers by role/aria-live if still empty - if (!foundText) { - try { - const aria = await page.$$eval('[aria-live], [role="status"], [role="alert"]', els => els.map(e => (e.textContent || '').trim()).find(t => t && t.length > 0) || ''); - if (aria) foundText = aria; - } catch { - // ignore - } - } +When('tento adicionar uma meta sem título para a turma {string}', async function (slug: string) { + const topic = toTopic(slug); + await openMetasModalForTopic(topic); + const modal = await scope.page.$(SELECTORS.modal); + if (!modal) throw new Error('Modal não aberto'); + const input = await modal.$('input[placeholder="New meta"]'); + if (!input) throw new Error('Input de meta não encontrado'); + // ensure empty + await input.click({ clickCount: 3 }); + await input.focus(); + await scope.page.keyboard.down('Control'); await scope.page.keyboard.press('A'); await scope.page.keyboard.up('Control'); await scope.page.keyboard.press('Backspace'); + const addBtn = await modal.$(SELECTORS.metaAddBtn); + if (addBtn) await addBtn.click(); + // store state for next assertion + const createBtn = await modal.$(SELECTORS.metaCreateBtn); + const disabled = createBtn ? await scope.page.evaluate(el => (el as HTMLButtonElement).disabled, createBtn) : true; + (this as any).lastCreateDisabled = disabled; +}); - // 3) Final fallback: scan visible text nodes for keywords (longer timeout) - if (!foundText) { - // first, wait a bit for any notification text to appear anywhere in the body - try { - await page.waitForFunction((exp) => (document && document.body && document.body.innerText || '').toLowerCase().includes(exp), { timeout: 5000 }, expectedLower); - } catch { - // ignore timeout, we'll still try to read text - } +Then('eu vejo que não está disponível a opção de submissão de criação de metas', async function () { + const disabled = (this as any).lastCreateDisabled; + if (disabled) return expect(true).toBeTruthy(); + // fallback: check currently on screen + const createBtn = await scope.page.$(SELECTORS.metaCreateBtn); + const isDisabled = createBtn ? await scope.page.evaluate(el => (el as HTMLButtonElement).disabled, createBtn) : true; + if (isDisabled) return expect(true).toBeTruthy(); + throw new Error('Opção de submissão de criação de metas está disponível'); +}); - foundText = await page.evaluate(() => { - function visible(n: Element) { - const style = window.getComputedStyle(n as HTMLElement); - return style && style.visibility !== 'hidden' && style.display !== 'none' && (n as HTMLElement).offsetParent !== null; - } - const nodes = Array.from(document.querySelectorAll('body *')) as Element[]; - for (const n of nodes) { - if (!visible(n)) continue; - const txt = (n.textContent || '').trim(); - if (!txt) continue; - if (/metas criadas com sucesso|metas criadas|criado|sucesso|erro/i.test(txt)) return txt; - } - // as a last resort, return the whole body text - return (document && document.body && (document.body.innerText || '').trim()) || ''; - }); - } +Then('a listagem de metas da turma {string} permanece vazia', async function (slug: string) { + const topic = toTopic(slug); + await openMetasModalForTopic(topic); + const metas = await getMetasList(); + if (metas.length === 0) return expect(true).toBeTruthy(); + throw new Error(`Lista de metas não está vazia: [${metas.join(', ')}]`); +}); - // normalize whitespace and assert - const normalized = (foundText || '').replace(/\s+/g, ' ').trim(); - const foundLower = normalized.toLowerCase(); - if (foundLower.includes(expectedLower)) { - expect(true).toBeTruthy(); - return; +When('cancelo a criação de metas para a turma {string}', async function (slug: string) { + await openMetasModalForTopic(toTopic(slug)); + const modal = await scope.page.$(SELECTORS.modal); + if (!modal) throw new Error('Modal não aberto'); + const cancelBtn = await modal.$('.cancel-btn'); + if (cancelBtn) await cancelBtn.click(); else { + const closeBtn = await modal.$(SELECTORS.modalClose); + if (closeBtn) await closeBtn.click(); } + // ensure modal is gone + await scope.page.waitForSelector(SELECTORS.modal, { timeout: 1000 }).catch(() => {}); + const still = await scope.page.$(SELECTORS.modal); + if (still) throw new Error('Modal ainda visível após cancelar'); + currentOpenTopic = ''; +}); - // fallback: remove accents and punctuation and compare - const strip = (s: string) => { - // basic accent removal - const accentMap: { [k: string]: string } = { - 'à':'a','á':'a','â':'a','ã':'a','ä':'a','å':'a', - 'ç':'c', - 'è':'e','é':'e','ê':'e','ë':'e', - 'ì':'i','í':'i','î':'i','ï':'i', - 'ñ':'n', - 'ò':'o','ó':'o','ô':'o','õ':'o','ö':'o', - 'ù':'u','ú':'u','û':'u','ü':'u', - 'ý':'y','ÿ':'y' - }; - let out = s.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); - // replace known accents - out = out.split('').map(ch => accentMap[ch] || ch).join(''); - // remove non-alphanumeric (keep spaces) - out = out.replace(/[^a-z0-9\s]/gi, ''); - return out.toLowerCase().replace(/\s+/g, ' ').trim(); - }; - - const strippedFound = strip(foundLower); - const strippedExpected = strip(expectedLower); - // final assertion: prefer lastNotification captured during submit if available - // @ts-ignore - const lastNotif = (this && this.lastNotification) ? (this.lastNotification as string) : ''; - if (lastNotif) { - const ln = lastNotif.replace(/\s+/g, ' ').trim().toLowerCase(); - if (ln.includes(expectedLower) || strip(ln).includes(strip(expectedLower))) { - expect(true).toBeTruthy(); - return; +When('edito a meta {string} para {string} na turma {string}', async function (oldTitle: string, newTitle: string, slug: string) { + // operate on currently-open modal; open only if missing + const modalPresent = await scope.page.$(SELECTORS.modal); + if (!modalPresent) await openMetasModalForTopic(toTopic(slug)); + // find local meta item containing oldTitle + const handles = await scope.page.$$(SELECTORS.metaItems); + let found = false; + for (const h of handles) { + const txt = (await scope.page.evaluate(el => (el.textContent || '').trim(), h)); + if (txt.includes(oldTitle)) { + found = true; + const editBtn = await h.$('.meta-edit-btn'); + if (!editBtn) throw new Error('Botão de editar não encontrado'); + await editBtn.click(); + await scope.page.waitForFunction((val) => { + const input = document.querySelector('.enrollment-overlay .enrollment-modal input[placeholder="New meta"]') as HTMLInputElement; + return input && input.value === val; + }, { timeout: 1000 }, oldTitle); + const input = await scope.page.$(SELECTORS.metaInput); + if (!input) throw new Error('Input de meta não encontrado ao editar'); + await input.click({ clickCount: 3 }); + await input.focus(); + await scope.page.keyboard.down('Control'); await scope.page.keyboard.press('A'); await scope.page.keyboard.up('Control'); await scope.page.keyboard.press('Backspace'); + await input.type(newTitle); + const saveBtn = await scope.page.$(SELECTORS.metaAddBtn); + if (saveBtn) await saveBtn.click(); + // wait for notification or similar feedback + await sleep(500); + // ensure modal closed + currentOpenTopic = ''; + break; } } - - // otherwise use the found text from DOM - if (strippedFound.includes(strippedExpected)) { - expect(true).toBeTruthy(); - return; - } - - // nothing matched: fail with the collected text for debugging - throw new Error(`Notification not found. Collected text: "${foundLower}"`); + if (!found) throw new Error(`Meta com título "${oldTitle}" não encontrada para edição`); }); - - -Then('a listagem de metas da turma {string} exibe os itens com títulos {string} e {string}', async function (string, string2, string3) { - const slug = string; - const title1 = string2; - const title2 = string3; - - // After creation, metas are visible in the modal's existing-metas or local-metas-list; navigate to classes page to view - await page.goto(baseUrl, { waitUntil: 'networkidle2' }).catch(() => undefined); - const classesTab = await page.$('[data-testid="classes-tab"]'); - if (classesTab) await classesTab.click().catch(() => undefined); - await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => undefined); - - // open metas modal for the class again - const rows = await page.$$('table tbody tr'); - for (const row of rows) { - const topicCell = await row.$('td strong'); - if (!topicCell) continue; - const txt = (await page.evaluate(el => (el.textContent || '').trim(), topicCell)) || ''; - if (txt.toLowerCase().includes(slug.replace(/-/g, ' ').toLowerCase())) { - const metasBtn = await row.$('.metas-btn'); - if (metasBtn) { - await metasBtn.click().catch(() => undefined); - await sleep(500); - } +When('deleto a meta {string} na turma {string}', async function (title: string, slug: string) { + const modalPresent = await scope.page.$(SELECTORS.modal); + if (!modalPresent) await openMetasModalForTopic(toTopic(slug)); + const items = await scope.page.$$(SELECTORS.metaItems); + let found = false; + for (const item of items) { + const txt = (await scope.page.evaluate(el => (el.textContent || '').trim(), item)); + if (txt.includes(title)) { + found = true; + const del = await item.$('.meta-delete-btn'); + if (!del) throw new Error('Botão de deletar não encontrado'); + await del.click(); + await sleep(200); break; } } + if (!found) throw new Error('Meta para deletar não encontrada: ' + title); +}); - // wait a bit for list to render - await sleep(500); - - // In the metas modal, check local-metas-list and existing-metas - const metasText = await page.evaluate(() => Array.from(document.querySelectorAll('.local-metas-list li, .existing-metas li, .local-meta-item, .local-metas-list .local-meta-item, .local-metas-list span')).map(n => (n.textContent || '').trim())); - const found1 = metasText.some(t => t.includes(title1)); - const found2 = metasText.some(t => t.includes(title2)); +Then('a listagem de metas da turma {string} exibe o item com título {string}', async function (slug: string, title: string) { + await openMetasModalForTopic(toTopic(slug)); + const metas = await getMetasList(); + if (metas.some(m => m.includes(title))) return expect(true).toBeTruthy(); + throw new Error('Meta não encontrada: ' + title); +}); - expect(found1).toBeTruthy(); - expect(found2).toBeTruthy(); +Given('a turma {string} possui as metas {string} e {string} cadastradas', async function (slug: string, m1: string, m2: string) { + const topic = toTopic(slug); + await ensureFreshClass(topic); + const resp = await fetch(`${API}/api/classes`); + const list = resp.ok ? await resp.json() : []; + const cls = list.find((c:any)=> (c.topic || '').toLowerCase() === topic.toLowerCase()); + if (!cls) throw new Error('Classe não encontrada: ' + topic); + await fetch(`${API}/api/classes/${encodeURIComponent(cls.id)}/metas`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ metas: [m1,m2] }) }); +}); - return; +When('tento acessar a página de criação de metas da turma {string}', async function (slug: string) { + await openMetasModalForTopic(toTopic(slug)); }); +Then('eu vejo que não está disponível a opção de criação de metas para a turma {string}', async function (slug: string) { + await openMetasModalForTopic(toTopic(slug)); + const createBtn = await scope.page.$(SELECTORS.metaCreateBtn); + if (!createBtn) return expect(true).toBeTruthy(); + const disabled = await scope.page.evaluate(el => (el as HTMLButtonElement).disabled, createBtn); + if (disabled) return expect(true).toBeTruthy(); + throw new Error('Opção de criação de metas está disponível para turma com metas existentes'); +}); \ No newline at end of file diff --git a/client/src/step-definitions/server-student-steps.ts b/client/src/step-definitions/server-student-steps.ts index bd994ca6..5b028626 100644 --- a/client/src/step-definitions/server-student-steps.ts +++ b/client/src/step-definitions/server-student-steps.ts @@ -2,7 +2,7 @@ import { Given, When, Then, After, DataTable, setDefaultTimeout } from '@cucumbe import expect from 'expect'; // Set default timeout for all steps -setDefaultTimeout(30 * 1000); // 30 seconds +// setDefaultTimeout(30 * 1000); // 30 seconds const serverUrl = 'http://localhost:3005'; diff --git a/client/src/step-definitions/setup.ts b/client/src/step-definitions/setup.ts new file mode 100644 index 00000000..f0ebfce5 --- /dev/null +++ b/client/src/step-definitions/setup.ts @@ -0,0 +1,26 @@ +import { Before, After, setDefaultTimeout } from '@cucumber/cucumber'; +import { Browser, Page, launch } from 'puppeteer'; + +setDefaultTimeout(30 * 1000); + +export const scope = { + browser: null as unknown as Browser, + page: null as unknown as Page, +}; + +Before({ tags: '@gui' }, async function () { + scope.browser = await launch({ + headless: false, + slowMo: 50, + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + const pages = await scope.browser.pages(); + scope.page = pages.length > 0 ? pages[0] : await scope.browser.newPage(); + await scope.page.setViewport({ width: 1280, height: 720 }); +}); + +After({ tags: '@gui' }, async function () { + if (scope.browser) { + await scope.browser.close(); + } +}); diff --git a/client/src/step-definitions/student-steps.ts b/client/src/step-definitions/student-steps.ts index a2c3af79..5d597acc 100644 --- a/client/src/step-definitions/student-steps.ts +++ b/client/src/step-definitions/student-steps.ts @@ -1,9 +1,10 @@ import { Given, When, Then, Before, After, DataTable, setDefaultTimeout } from '@cucumber/cucumber'; import { Browser, Page, launch } from 'puppeteer'; import expect from 'expect'; +import { scope } from './setup'; // Set default timeout for all steps -setDefaultTimeout(30 * 1000); // 30 seconds +// setDefaultTimeout(30 * 1000); // 30 seconds // Helper function to format CPF like the frontend does function formatCPF(value: string): string { @@ -14,41 +15,30 @@ function formatCPF(value: string): string { return digits.slice(0, 11).replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'); } -let browser: Browser; -let page: Page; const baseUrl = 'http://localhost:3004'; const serverUrl = 'http://localhost:3005'; // Test data to clean up let testStudentCPF: string; -Before({ tags: '@gui' }, async function () { - browser = await launch({ - headless: false, // Set to true for CI/CD - slowMo: 50 // Slow down actions for visibility - }); - page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 720 }); -}); - After({ tags: '@gui' }, async function () { // Clean up test student if it exists by using the GUI delete function if (testStudentCPF) { try { // Navigate to Students area - await page.goto(baseUrl); - await page.waitForSelector('.students-list table', { timeout: 5000 }); + await scope.page.goto(baseUrl); + await scope.page.waitForSelector('.students-list table', { timeout: 5000 }); // Look for our test student in the table and delete it if found - const studentRows = await page.$$('[data-testid^="student-row-"]'); + const studentRows = await scope.page.$$('[data-testid^="student-row-"]'); for (const row of studentRows) { const cpfCell = await row.$('[data-testid="student-cpf"]'); if (cpfCell) { - const cpf = await page.evaluate(el => el.textContent, cpfCell); + const cpf = await scope.page.evaluate(el => el.textContent, cpfCell); // Check for both plain and formatted CPF if (cpf === testStudentCPF || cpf === formatCPF(testStudentCPF)) { // Set up dialog handler before clicking delete - page.once('dialog', async (dialog) => { + scope.page.once('dialog', async (dialog) => { console.log(`GUI cleanup: Confirming deletion dialog: ${dialog.message()}`); await dialog.accept(); // Confirm deletion }); @@ -69,16 +59,12 @@ After({ tags: '@gui' }, async function () { console.log('GUI cleanup: Student may not exist or GUI unavailable'); } } - - if (browser) { - await browser.close(); - } }); Given('the student management system is running', async function () { - await page.goto(baseUrl); - await page.waitForSelector('h1', { timeout: 10000 }); - const title = await page.$eval('h1', el => el.textContent); + await scope.page.goto(baseUrl); + await scope.page.waitForSelector('h1', { timeout: 10000 }); + const title = await scope.page.$eval('h1', el => el.textContent); expect(title || '').toContain('Teaching Assistant React'); }); @@ -96,173 +82,217 @@ Given('there is no student with CPF {string} in the system', async function (cpf const formattedCPF = formatCPF(cpf); // Navigate to the application and check if student exists through GUI - await page.goto(baseUrl); - await page.waitForSelector('.students-list', { timeout: 10000 }); + await scope.page.goto(baseUrl); + await scope.page.waitForSelector('.students-list', { timeout: 10000 }); - // Try to find and delete the student if it exists (cleanup before test) - const studentRows = await page.$$('[data-testid^="student-row-"]'); - for (const row of studentRows) { - const cpfCell = await row.$('[data-testid="student-cpf"]'); - if (cpfCell) { - const displayedCPF = await page.evaluate(el => el.textContent, cpfCell); - // Check for both plain and formatted CPF - if (displayedCPF === cpf || displayedCPF === formattedCPF) { - // Student exists, delete it for clean test state - // Set up dialog handler before clicking delete - page.once('dialog', async (dialog) => { - console.log(`GUI cleanup: Confirming deletion dialog: ${dialog.message()}`); - await dialog.accept(); // Confirm deletion - }); - - const deleteButton = await row.$(`[data-testid="delete-student-${displayedCPF}"]`); - if (deleteButton) { - await deleteButton.click(); - // Wait for deletion to complete - await new Promise(resolve => setTimeout(resolve, 1000)); - console.log(`GUI cleanup: Removed existing student with CPF: ${displayedCPF}`); - break; - } + // Check if student exists in the table + const studentExists = await scope.page.evaluate((targetCpf) => { + const rows = document.querySelectorAll('[data-testid^="student-row-"]'); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const cpfCell = row.querySelector('[data-testid="student-cpf"]'); + if (cpfCell && (cpfCell.textContent === targetCpf)) { + return true; } } - } - - // Verify student doesn't exist by checking the GUI - await page.reload(); // Refresh to ensure clean state - await page.waitForSelector('.students-list', { timeout: 5000 }); + return false; + }, formattedCPF); - const updatedRows = await page.$$('[data-testid^="student-row-"]'); - for (const row of updatedRows) { - const cpfCell = await row.$('[data-testid="student-cpf"]'); - if (cpfCell) { - const displayedCPF = await page.evaluate(el => el.textContent, cpfCell); - if (displayedCPF === cpf || displayedCPF === formattedCPF) { - throw new Error(`Student with CPF ${displayedCPF} still exists in the system after cleanup`); - } + if (studentExists) { + // Delete the student if found + scope.page.once('dialog', async (dialog) => { + await dialog.accept(); + }); + + const deleteButton = await scope.page.$(`[data-testid="delete-student-${formattedCPF}"]`); + if (deleteButton) { + await deleteButton.click(); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for deletion } } }); +When('I navigate to the Students page', async function () { + // Click on the Students tab/link + const studentsLink = await scope.page.$('a[href="/students"]'); + if (studentsLink) { + await studentsLink.click(); + } else { + // Fallback to direct navigation + await scope.page.goto(`${baseUrl}/students`); + } + await scope.page.waitForSelector('.students-list', { timeout: 5000 }); +}); + When('I navigate to the Students area', async function () { - // Click on the Students tab - const studentsTab = await page.$('[data-testid="students-tab"]'); - if (studentsTab) { - const isActive = await page.evaluate(el => el?.classList.contains('active'), studentsTab); - - if (!isActive) { - await studentsTab.click(); - } + // Click on the Students tab/link + const studentsLink = await scope.page.$('a[href="/students"]'); + if (studentsLink) { + await studentsLink.click(); + } else { + // Fallback to direct navigation + await scope.page.goto(`${baseUrl}/students`); } + await scope.page.waitForSelector('.students-list', { timeout: 5000 }); +}); + +When('I enter the student details:', async function (dataTable: DataTable) { + const data = dataTable.rowsHash(); - // Wait for the student form to be visible - await page.waitForSelector('[data-testid="student-form"]', { timeout: 5000 }); + // Fill in the form + await scope.page.type('input[name="name"]', data.name); + await scope.page.type('input[name="cpf"]', data.cpf); + await scope.page.type('input[name="email"]', data.email); }); When('I provide the student information:', async function (dataTable: DataTable) { const data = dataTable.rowsHash(); - // Fill in the name field using semantic ID - await page.waitForSelector('#name'); - await page.click('#name'); - await page.type('#name', data.name); - - // Fill in the CPF field using semantic ID - await page.click('#cpf'); - await page.type('#cpf', data.cpf); - - // Fill in the email field using semantic ID - await page.click('#email'); - await page.type('#email', data.email); + // Fill in the form + await scope.page.type('input[name="name"]', data.name); + await scope.page.type('input[name="cpf"]', data.cpf); + await scope.page.type('input[name="email"]', data.email); }); -When('I send the student information', async function () { - // Click the submit button using semantic test ID - const submitButton = await page.$('[data-testid="submit-student-button"]'); - expect(submitButton).toBeTruthy(); - - await submitButton?.click(); +When('I click the {string} button', async function (buttonText: string) { + // Find button by text content + const button = await scope.page.evaluateHandle((text) => { + const buttons = Array.from(document.querySelectorAll('button')); + return buttons.find(b => b.textContent?.includes(text)); + }, buttonText); - // Wait for the information to be processed and student to appear - await new Promise(resolve => setTimeout(resolve, 2000)); + if (button) { + const element = button.asElement(); + if (element) { + await (element as any).click(); + } + } else { + throw new Error(`Button with text "${buttonText}" not found`); + } }); -Then('I should see {string} in the student list', async function (studentName: string) { - // Wait for the student list to update - await page.waitForSelector('.students-list table', { timeout: 10000 }); - - // Find the student row that matches our test student's CPF and verify the name - const studentRows = await page.$$('[data-testid^="student-row-"]'); - let foundStudent = null; - - for (const row of studentRows) { - const cpfCell = await row.$('[data-testid="student-cpf"]'); - if (cpfCell) { - const cpf = await page.evaluate(el => el.textContent, cpfCell); - if (cpf === formatCPF(testStudentCPF) || cpf === testStudentCPF) { - foundStudent = row; - break; - } +When('I send the student information', async function () { + const registerButton = await scope.page.$('button[type="submit"]'); + if (registerButton) { + await registerButton.click(); + // Wait for the student list to update or a success message, instead of navigation + // Assuming the list updates on the same page + try { + await scope.page.waitForFunction( + () => document.querySelectorAll('[data-testid^="student-row-"]').length > 0, + { timeout: 5000 } + ); + } catch (e) { + // Ignore timeout if list doesn't update immediately, subsequent steps will verify } + } else { + throw new Error('Register button not found'); } +}); + +Then('I should see the student {string} in the list', async function (name: string) { + await scope.page.waitForFunction( + (studentName) => { + const rows = document.querySelectorAll('[data-testid^="student-row-"]'); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.textContent?.includes(studentName)) { + return true; + } + } + return false; + }, + { timeout: 5000 }, + name + ); +}); + +Then('I should see {string} in the student list', async function (name: string) { + await scope.page.waitForFunction( + (studentName) => { + const rows = document.querySelectorAll('[data-testid^="student-row-"]'); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (row.textContent?.includes(studentName)) { + return true; + } + } + return false; + }, + { timeout: 5000 }, + name + ); +}); + +Then('the student should have CPF {string}', async function (cpf: string) { + const formattedCPF = formatCPF(cpf); + const cpfFound = await scope.page.evaluate((targetCpf) => { + const cells = document.querySelectorAll('[data-testid="student-cpf"]'); + return Array.from(cells).some(cell => cell.textContent === targetCpf); + }, formattedCPF); - expect(foundStudent).toBeTruthy(); + expect(cpfFound).toBe(true); +}); + +Then('the student should have email {string}', async function (email: string) { + const emailFound = await scope.page.evaluate((targetEmail) => { + const cells = document.querySelectorAll('[data-testid="student-email"]'); + return Array.from(cells).some(cell => cell.textContent === targetEmail); + }, email); - // Verify the name matches exactly for this specific student - const nameCell = await foundStudent!.$('[data-testid="student-name"]'); - const actualName = await page.evaluate(el => el.textContent, nameCell!); - expect(actualName).toBe(studentName); + expect(emailFound).toBe(true); }); -Then('the student should have CPF {string}', async function (expectedCPF: string) { - // Wait for the student list to update - await page.waitForSelector('.students-list table', { timeout: 10000 }); +When('I click the delete button for student with CPF {string}', async function (cpf: string) { + const formattedCPF = formatCPF(cpf); - // Find all student information from the current test student - const studentRows = await page.$$('[data-testid^="student-row-"]'); - let foundStudent = null; + // Setup dialog handler + scope.page.once('dialog', async (dialog) => { + await dialog.accept(); + }); - // First, find the student row that matches our test CPF - for (const row of studentRows) { - const cpfCell = await row.$('[data-testid="student-cpf"]'); - if (cpfCell) { - const cpf = await page.evaluate(el => el.textContent, cpfCell); - if (cpf === expectedCPF || cpf === testStudentCPF || cpf === formatCPF(testStudentCPF)) { - foundStudent = row; - break; - } - } + const deleteButton = await scope.page.$(`[data-testid="delete-student-${formattedCPF}"]`); + if (!deleteButton) { + throw new Error(`Delete button for student ${formattedCPF} not found`); } - expect(foundStudent).toBeTruthy(); - - // Verify the CPF matches exactly - const cpfCell = await foundStudent!.$('[data-testid="student-cpf"]'); - const actualCPF = await page.evaluate(el => el.textContent, cpfCell!); - expect(actualCPF).toBe(expectedCPF); + await deleteButton.click(); }); -Then('the student should have email {string}', async function (expectedEmail: string) { - // Wait for the student list to update - await page.waitForSelector('.students-list table', { timeout: 10000 }); +Then('I should not see the student with CPF {string} in the list', async function (cpf: string) { + const formattedCPF = formatCPF(cpf); - // Find the student row that matches our test student's CPF - const studentRows = await page.$$('[data-testid^="student-row-"]'); - let foundStudent = null; + // Wait for element to disappear + await scope.page.waitForFunction( + (targetCpf) => { + const cells = document.querySelectorAll('[data-testid="student-cpf"]'); + return !Array.from(cells).some(cell => cell.textContent === targetCpf); + }, + { timeout: 5000 }, + formattedCPF + ); +}); + +When('I try to register a student with incomplete details:', async function (dataTable: DataTable) { + const data = dataTable.rowsHash(); - for (const row of studentRows) { - const cpfCell = await row.$('[data-testid="student-cpf"]'); - if (cpfCell) { - const cpf = await page.evaluate(el => el.textContent, cpfCell); - if (cpf === formatCPF(testStudentCPF) || cpf === testStudentCPF) { - foundStudent = row; - break; - } - } - } + // Fill in only provided fields + if (data.name) await scope.page.type('input[name="name"]', data.name); + if (data.cpf) await scope.page.type('input[name="cpf"]', data.cpf); + if (data.email) await scope.page.type('input[name="email"]', data.email); - expect(foundStudent).toBeTruthy(); + // Click register + const registerButton = await scope.page.$('button[type="submit"]'); + if (registerButton) await registerButton.click(); +}); + +Then('I should see an error message', async function () { + // Check for HTML5 validation or custom error message + // This is a simplified check - in a real app you'd check specific error elements + const hasError = await scope.page.evaluate(() => { + const inputs = document.querySelectorAll('input:invalid'); + return inputs.length > 0; + }); - // Verify the email matches exactly for this specific student - const emailCell = await foundStudent!.$('[data-testid="student-email"]'); - const actualEmail = await page.evaluate(el => el.textContent, emailCell!); - expect(actualEmail).toBe(expectedEmail); + expect(hasError).toBe(true); }); \ No newline at end of file From 80fb97d5f6c68a83471675bc804537b1a5067dfa Mon Sep 17 00:00:00 2001 From: basb Date: Mon, 8 Dec 2025 16:09:41 -0300 Subject: [PATCH 07/12] =?UTF-8?q?implementa=C3=A7=C3=A3o=20dos=20testes=20?= =?UTF-8?q?de=20server,=20e=20refatora=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/server-criacao-metas.feature | 45 +++++ .../server-criacao-metas.steps.ts | 184 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 client/src/features/server-criacao-metas.feature create mode 100644 client/src/step-definitions/server-criacao-metas.steps.ts diff --git a/client/src/features/server-criacao-metas.feature b/client/src/features/server-criacao-metas.feature new file mode 100644 index 00000000..d3dd4098 --- /dev/null +++ b/client/src/features/server-criacao-metas.feature @@ -0,0 +1,45 @@ +@server +Feature: Gerenciamento de Metas no Servidor + Como um sistema + Eu quero gerenciar as metas das turmas através da API + Para que os dados das metas sejam armazenados e validados corretamente + + Background: + Given o servidor está disponível + + Scenario: Criar metas para uma turma via API com sucesso + Given a turma "engenharia-de-software-e-sistemas" existe no servidor + And não existem metas cadastradas para a turma "engenharia-de-software-e-sistemas" no servidor + When eu envio uma requisição para criar as metas para a turma "engenharia-de-software-e-sistemas": + | titulo | + | Requisitos | + | Testes de software | + Then a requisição deve ser aceita com sucesso + And o servidor deve conter as metas "Requisitos" e "Testes de software" associadas à turma "engenharia-de-software-e-sistemas" + + Scenario: Tentar criar meta com título vazio via API + Given a turma "engenharia-de-software-e-sistemas" existe no servidor + When eu envio uma requisição para criar as metas para a turma "engenharia-de-software-e-sistemas": + | titulo | + | | + | Testes de software | + Then a requisição deve ser rejeitada com erro de validação + And não devem existir metas cadastradas para a turma "engenharia-de-software-e-sistemas" no servidor + + Scenario: Tentar criar metas duplicadas na mesma requisição via API + Given a turma "engenharia-de-software-e-sistemas" existe no servidor + When eu envio uma requisição para criar as metas para a turma "engenharia-de-software-e-sistemas": + | titulo | + | Requisitos | + | Requisitos | + Then a requisição deve ser rejeitada com erro de conflito ou validação + And não devem existir metas cadastradas para a turma "engenharia-de-software-e-sistemas" no servidor + + Scenario: Tentar criar metas para uma turma que já possui metas + Given a turma "sistemas-distribuidos" existe no servidor + And a turma "sistemas-distribuidos" já possui as metas "Comunicação" e "Sincronização" no servidor + When eu envio uma requisição para criar as metas para a turma "sistemas-distribuidos": + | titulo | + | Nova Meta A | + Then a requisição deve ser rejeitada pois a turma já possui metas + And as metas da turma "sistemas-distribuidos" devem permanecer "Comunicação" e "Sincronização" \ No newline at end of file diff --git a/client/src/step-definitions/server-criacao-metas.steps.ts b/client/src/step-definitions/server-criacao-metas.steps.ts new file mode 100644 index 00000000..1c6e1854 --- /dev/null +++ b/client/src/step-definitions/server-criacao-metas.steps.ts @@ -0,0 +1,184 @@ +import { Given, When, Then, DataTable } from '@cucumber/cucumber'; +import expect from 'expect'; + +// Mocking axios since it's not installed +const axios = { + get: async (url: string) => { + const res = await fetch(url); + if (!res.ok) throw new Error(`Request failed with status ${res.status}`); + return { data: await res.json(), status: res.status }; + }, + post: async (url: string, data: any) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) { + const error: any = new Error(`Request failed with status ${res.status}`); + error.response = { status: res.status }; + throw error; + } + // Handle empty response body (e.g. 204 No Content) or non-JSON response + const text = await res.text(); + const dataJson = text ? JSON.parse(text) : {}; + return { data: dataJson, status: res.status }; + }, + delete: async (url: string) => { + const res = await fetch(url, { method: 'DELETE' }); + if (!res.ok) throw new Error(`Request failed with status ${res.status}`); + return { status: res.status }; + }, + isAxiosError: (error: any) => !!error.response +}; + +const serverUrl = 'http://localhost:3005/api'; // Updated to include /api prefix based on other files +let lastResponse: any | undefined; +let lastError: any | undefined; + +// --- Helpers --- + +async function resetClass(turmaId: string) { + try { + // First, find the class ID by topic + const res = await axios.get(`${serverUrl}/classes`); + const cls = res.data.find((c: any) => (c.topic || '').toLowerCase() === turmaId.replace(/-/g, ' ').toLowerCase()); + + if (cls) { + // Delete the class using its ID + await axios.delete(`${serverUrl}/classes/${encodeURIComponent(cls.id)}`); + } + } catch (error) { + // Ignore if class doesn't exist or fetch fails + } + + // Recreate the class without goals + try { + await axios.post(`${serverUrl}/classes`, { + topic: turmaId.replace(/-/g, ' '), + semester: 1, + year: new Date().getFullYear() + }); + } catch (error) { + // Ignore if creation fails + } +} + +// --- Step Definitions --- + +Given('o servidor está disponível', async function () { + try { + await axios.get(`${serverUrl}/classes`); + } catch (error) { + console.warn('Aviso: Servidor pode não estar disponível.'); + } +}); + +Given('a turma {string} existe no servidor', async function (turmaId: string) { + // Ensure class exists (create if not) + try { + // Always reset class to ensure clean state for each scenario + await resetClass(turmaId); + } catch (e) {} +}); + +Given('não existem metas cadastradas para a turma {string} no servidor', async function (turmaId: string) { + // Since goals are permanent, we MUST recreate the class to ensure no goals + await resetClass(turmaId); +}); + +Given('a turma {string} já possui as metas {string} e {string} no servidor', async function (turmaId: string, meta1: string, meta2: string) { + // Reset class to ensure clean state + await resetClass(turmaId); + + // Find the class ID (since we recreate it, ID might change or we use topic as ID lookup) + const res = await axios.get(`${serverUrl}/classes`); + const cls = res.data.find((c: any) => (c.topic || '').toLowerCase() === turmaId.replace(/-/g, ' ').toLowerCase()); + + if (cls) { + // Add goals + await axios.post(`${serverUrl}/classes/${cls.id}/metas`, { + metas: [meta1, meta2] + }); + } +}); + +When('eu envio uma requisição para criar as metas para a turma {string}:', async function (turmaId: string, dataTable: DataTable) { + const metas = dataTable.hashes().map(row => row.titulo); // API expects array of strings, not objects + + lastResponse = undefined; + lastError = undefined; + + try { + // Need to find class ID first + const res = await axios.get(`${serverUrl}/classes`); + const cls = res.data.find((c: any) => (c.topic || '').toLowerCase() === turmaId.replace(/-/g, ' ').toLowerCase()); + + if (cls) { + lastResponse = await axios.post(`${serverUrl}/classes/${cls.id}/metas`, { + metas: metas + }); + } else { + throw new Error(`Class ${turmaId} not found`); + } + } catch (error) { + // Capture error for validation steps + lastError = error; + // Do not rethrow if we expect an error (which we do in some scenarios) + // But if it's a network error or unexpected, it might be good to know. + // For now, we store it in lastError and let the Then steps assert it. + } +}); + +Then('a requisição deve ser aceita com sucesso', function () { + expect(lastResponse).toBeDefined(); + expect([200, 201]).toContain(lastResponse?.status); +}); + +Then('a requisição deve ser rejeitada com erro de validação', function () { + expect(lastError).toBeDefined(); + // Accept 400 (Bad Request) or 409 (Conflict) or 422 (Unprocessable Entity) + expect([400, 409, 422]).toContain(lastError?.response?.status); +}); + +Then('a requisição deve ser rejeitada com erro de conflito ou validação', function () { + expect(lastError).toBeDefined(); + expect([400, 409]).toContain(lastError?.response?.status); +}); + +Then('a requisição deve ser rejeitada pois a turma já possui metas', function () { + expect(lastError).toBeDefined(); + expect([400, 403, 409]).toContain(lastError?.response?.status); +}); + +Then('o servidor deve conter as metas {string} e {string} associadas à turma {string}', async function (meta1: string, meta2: string, turmaId: string) { + const res = await axios.get(`${serverUrl}/classes`); + const cls = res.data.find((c: any) => (c.topic || '').toLowerCase() === turmaId.replace(/-/g, ' ').toLowerCase()); + + expect(cls).toBeDefined(); + // Assuming the class object contains the metas or we need to fetch them + // Based on other files, it seems metas might be part of the class object or fetched separately? + // Let's assume they are in the class object for now or fetch if needed. + // If the API returns metas in the class object: + const metas = cls.metas || []; + // If metas are strings: + expect(metas).toContain(meta1); + expect(metas).toContain(meta2); +}); + +Then('não devem existir metas cadastradas para a turma {string} no servidor', async function (turmaId: string) { + const res = await axios.get(`${serverUrl}/classes`); + const cls = res.data.find((c: any) => (c.topic || '').toLowerCase() === turmaId.replace(/-/g, ' ').toLowerCase()); + const metas = cls ? (cls.metas || []) : []; + expect(metas).toHaveLength(0); +}); + +Then('as metas da turma {string} devem permanecer {string} e {string}', async function (turmaId: string, meta1: string, meta2: string) { + const res = await axios.get(`${serverUrl}/classes`); + const cls = res.data.find((c: any) => (c.topic || '').toLowerCase() === turmaId.replace(/-/g, ' ').toLowerCase()); + const metas = cls ? (cls.metas || []) : []; + + expect(metas).toHaveLength(2); + expect(metas).toContain(meta1); + expect(metas).toContain(meta2); +}); \ No newline at end of file From bcc39dac0887a2b1284812b16a107f2f78d6193f Mon Sep 17 00:00:00 2001 From: basb Date: Mon, 8 Dec 2025 16:11:48 -0300 Subject: [PATCH 08/12] =?UTF-8?q?comitando=20as=20altera=C3=A7=C3=B5es=20n?= =?UTF-8?q?o=20servidor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 457 +++++++++++++++-------------------- server/data/app-data.json | 174 +------------ server/src/models/Class.ts | 11 +- server/src/models/Classes.ts | 17 ++ server/src/server.ts | 35 ++- 5 files changed, 240 insertions(+), 454 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5f18ab05..012c33f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,11 +26,13 @@ "react-scripts": "5.0.1" }, "devDependencies": { - "@cucumber/cucumber": "^12.2.0", + "@cucumber/cucumber": "^12.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/cucumber": "^6.0.1", "@types/jest": "^30.0.0", + "@types/node": "^24.10.1", "@types/puppeteer": "^5.4.7", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", @@ -10311,16 +10313,6 @@ "node": ">=10" } }, - "client/node_modules/semver": { - "version": "7.7.3", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "client/node_modules/send": { "version": "0.19.0", "license": "MIT", @@ -12862,31 +12854,29 @@ } }, "node_modules/@cucumber/ci-environment": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-10.0.1.tgz", - "integrity": "sha512-/+ooDMPtKSmvcPMDYnMZt4LuoipfFfHaYspStI4shqw8FyKcfQAmekz6G+QKWjQQrvM+7Hkljwx58MEwPCwwzg==", - "dev": true, - "license": "MIT" + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/ci-environment/-/ci-environment-12.0.0.tgz", + "integrity": "sha512-SqCEnbCNl3zCXCFpqGUuoaSNhLC0jLw4tKeFcAxTw9MD/QRlJjeAC/fyvVLFuXuSq0OunJlFfxLu+Z3HE+oLPg==", + "dev": true }, "node_modules/@cucumber/cucumber": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-12.2.0.tgz", - "integrity": "sha512-b7W4snvXYi1T2puUjxamASCCNhNzVSzb/fQUuGSkdjm/AFfJ24jo8kOHQyOcaoArCG71sVQci4vkZaITzl/V1w==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber/-/cucumber-12.3.0.tgz", + "integrity": "sha512-36cIyplE1iDl12s4k6lBVpceua8tKLklFTf7CUITPrNHTLlQ/KBr7NYUUHviPzCbj2Ox3BPTZ6qkSLd6WMvVQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@cucumber/ci-environment": "10.0.1", + "@cucumber/ci-environment": "12.0.0", "@cucumber/cucumber-expressions": "18.0.1", - "@cucumber/gherkin": "34.0.0", - "@cucumber/gherkin-streams": "5.0.1", - "@cucumber/gherkin-utils": "9.2.0", - "@cucumber/html-formatter": "21.14.0", - "@cucumber/junit-xml-formatter": "0.8.1", + "@cucumber/gherkin": "37.0.0", + "@cucumber/gherkin-streams": "6.0.0", + "@cucumber/gherkin-utils": "10.0.0", + "@cucumber/html-formatter": "22.2.0", + "@cucumber/junit-xml-formatter": "0.9.0", "@cucumber/message-streams": "4.0.1", - "@cucumber/messages": "28.1.0", + "@cucumber/messages": "31.0.0", "@cucumber/pretty-formatter": "1.0.1", - "@cucumber/tag-expressions": "6.2.0", + "@cucumber/tag-expressions": "8.1.0", "assertion-error-formatter": "^3.0.0", "capital-case": "^1.0.4", "chalk": "^4.1.2", @@ -12895,7 +12885,7 @@ "debug": "^4.3.4", "error-stack-parser": "^2.1.4", "figures": "^3.2.0", - "glob": "^11.0.0", + "glob": "^13.0.0", "has-ansi": "^4.0.1", "indent-string": "^4.0.0", "is-installed-globally": "^0.4.0", @@ -12903,19 +12893,19 @@ "knuth-shuffle-seeded": "^1.0.6", "lodash.merge": "^4.6.2", "lodash.mergewith": "^4.6.2", - "luxon": "3.7.1", + "luxon": "3.7.2", "mime": "^3.0.0", "mkdirp": "^3.0.0", "mz": "^2.7.0", "progress": "^2.0.3", - "read-package-up": "^11.0.0", - "semver": "7.7.2", + "read-package-up": "^12.0.0", + "semver": "7.7.3", "string-argv": "0.3.1", "supports-color": "^8.1.1", "type-fest": "^4.41.0", "util-arity": "^1.1.0", "yaml": "^2.2.2", - "yup": "1.7.0" + "yup": "1.7.1" }, "bin": { "cucumber-js": "bin/cucumber.js" @@ -12937,6 +12927,16 @@ "regexp-match-indices": "1.0.2" } }, + "node_modules/@cucumber/cucumber/node_modules/@cucumber/messages": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-31.0.0.tgz", + "integrity": "sha512-Dqhatp4AjMsH9SREfWz3Q8nlGuwJMTW7YAW5L3OzRId86ZUEu/a8vIL1RO2c0agQefuBS2SVH9fEZ66ovrMYRA==", + "dev": true, + "dependencies": { + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2" + } + }, "node_modules/@cucumber/cucumber/node_modules/mkdirp": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", @@ -12954,24 +12954,21 @@ } }, "node_modules/@cucumber/gherkin": { - "version": "34.0.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-34.0.0.tgz", - "integrity": "sha512-659CCFsrsyvuBi/Eix1fnhSheMnojSfnBcqJ3IMPNawx7JlrNJDcXYSSdxcUw3n/nG05P+ptCjmiZY3i14p+tA==", + "version": "37.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-37.0.0.tgz", + "integrity": "sha512-vKJVJ6h4HCktG870wgYUUskNpFxbFI0WmAkVLPTz1LlLwJX7/KOBqFcr2/L3u0pPoHjbLRW+IpbiXLT2T13/wg==", "dev": true, - "license": "MIT", - "peer": true, "dependencies": { - "@cucumber/messages": ">=19.1.4 <29" + "@cucumber/messages": ">=31.0.0 <32" } }, "node_modules/@cucumber/gherkin-streams": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin-streams/-/gherkin-streams-5.0.1.tgz", - "integrity": "sha512-/7VkIE/ASxIP/jd4Crlp4JHXqdNFxPGQokqWqsaCCiqBiu5qHoKMxcWNlp9njVL/n9yN4S08OmY3ZR8uC5x74Q==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-streams/-/gherkin-streams-6.0.0.tgz", + "integrity": "sha512-HLSHMmdDH0vCr7vsVEURcDA4WwnRLdjkhqr6a4HQ3i4RFK1wiDGPjBGVdGJLyuXuRdJpJbFc6QxHvT8pU4t6jw==", "dev": true, - "license": "MIT", "dependencies": { - "commander": "9.1.0", + "commander": "14.0.0", "source-map-support": "0.5.21" }, "bin": { @@ -12984,26 +12981,24 @@ } }, "node_modules/@cucumber/gherkin-streams/node_modules/commander": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.1.0.tgz", - "integrity": "sha512-i0/MaqBtdbnJ4XQs4Pmyb+oFQl+q0lsAmokVUH92SlSw4fkeAcG3bVon+Qt7hmtF+u3Het6o4VgrcY3qAoEB6w==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", "dev": true, - "license": "MIT", "engines": { - "node": "^12.20.0 || >=14" + "node": ">=20" } }, "node_modules/@cucumber/gherkin-utils": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-9.2.0.tgz", - "integrity": "sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-10.0.0.tgz", + "integrity": "sha512-BcujlDT343GXXNrMPl3ws6Il3zs8dQw3Yp/d3HnOJF8i2snGGgiapoTbko7MdvAt7ivDL7SDo+e1d5Cnpl3llA==", "dev": true, - "license": "MIT", "dependencies": { - "@cucumber/gherkin": "^31.0.0", - "@cucumber/messages": "^27.0.0", + "@cucumber/gherkin": "^34.0.0", + "@cucumber/messages": "^29.0.0", "@teppeis/multimaps": "3.0.0", - "commander": "13.1.0", + "commander": "14.0.0", "source-map-support": "^0.5.21" }, "bin": { @@ -13011,97 +13006,71 @@ } }, "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { - "version": "31.0.0", - "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-31.0.0.tgz", - "integrity": "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==", + "version": "34.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-34.0.0.tgz", + "integrity": "sha512-659CCFsrsyvuBi/Eix1fnhSheMnojSfnBcqJ3IMPNawx7JlrNJDcXYSSdxcUw3n/nG05P+ptCjmiZY3i14p+tA==", "dev": true, - "license": "MIT", "dependencies": { - "@cucumber/messages": ">=19.1.4 <=26" + "@cucumber/messages": ">=19.1.4 <29" } }, "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", - "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-28.1.0.tgz", + "integrity": "sha512-2LzZtOwYKNlCuNf31ajkrekoy2M4z0Z1QGiPH40n4gf5t8VOUFb7m1ojtR4LmGvZxBGvJZP8voOmRqDWzBzYKA==", "dev": true, - "license": "MIT", "dependencies": { "@types/uuid": "10.0.0", "class-transformer": "0.5.1", "reflect-metadata": "0.2.2", - "uuid": "10.0.0" - } - }, - "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" + "uuid": "11.1.0" } }, "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/messages": { - "version": "27.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", - "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-29.0.1.tgz", + "integrity": "sha512-aAvIYfQD6/aBdF8KFQChC3CQ1Q+GX9orlR6GurGiX6oqaCnBkxA4WU3OQUVepDynEFrPayerqKRFcAMhdcXReQ==", "dev": true, - "license": "MIT", "dependencies": { - "@types/uuid": "10.0.0", "class-transformer": "0.5.1", - "reflect-metadata": "0.2.2", - "uuid": "11.0.5" + "reflect-metadata": "0.2.2" } }, "node_modules/@cucumber/gherkin-utils/node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", "dev": true, - "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, - "node_modules/@cucumber/gherkin-utils/node_modules/uuid": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", - "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-31.0.0.tgz", + "integrity": "sha512-Dqhatp4AjMsH9SREfWz3Q8nlGuwJMTW7YAW5L3OzRId86ZUEu/a8vIL1RO2c0agQefuBS2SVH9fEZ66ovrMYRA==", "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" + "dependencies": { + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2" } }, "node_modules/@cucumber/html-formatter": { - "version": "21.14.0", - "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.14.0.tgz", - "integrity": "sha512-vQqbmQZc0QiN4c+cMCffCItpODJlOlYtPG7pH6We096dBOa7u0ttDMjT6KrMAnQlcln54rHL46r408IFpuznAw==", + "version": "22.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-22.2.0.tgz", + "integrity": "sha512-fUNC/KngTIz+hAQ2Yr4XjdYq+MO60PwK9SidxBQ54jNI1Vw7erlgsPq0TOWneCIvdjU3qp+YDqYG1hw3zuUuDA==", "dev": true, - "license": "MIT", "peerDependencies": { "@cucumber/messages": ">=18" } }, "node_modules/@cucumber/junit-xml-formatter": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.8.1.tgz", - "integrity": "sha512-FT1Y96pyd9/ifbE9I7dbkTCjkwEdW9C0MBobUZoKD13c8EnWAt0xl1Yy/v/WZLTk4XfCLte1DATtLx01jt+YiA==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.9.0.tgz", + "integrity": "sha512-WF+A7pBaXpKMD1i7K59Nk5519zj4extxY4+4nSgv5XLsGXHDf1gJnb84BkLUzevNtp2o2QzMG0vWLwSm8V5blw==", "dev": true, - "license": "MIT", "dependencies": { - "@cucumber/query": "^13.0.2", + "@cucumber/query": "^14.0.1", "@teppeis/multimaps": "^3.0.0", "luxon": "^3.5.0", "xmlbuilder": "^15.1.1" @@ -13115,7 +13084,6 @@ "resolved": "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz", "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, - "license": "MIT", "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" @@ -13166,11 +13134,10 @@ } }, "node_modules/@cucumber/query": { - "version": "13.6.0", - "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-13.6.0.tgz", - "integrity": "sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==", + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-14.6.0.tgz", + "integrity": "sha512-bPbfpkDsFCBn95erh3un76QViPqGAo3T7iYews0yA3/JRNoV009s7acxxY+f+OMABPFl0TJVIZlvqX+KayQ+Eg==", "dev": true, - "license": "MIT", "dependencies": { "@teppeis/multimaps": "3.0.0", "lodash.sortby": "^4.7.0" @@ -13180,11 +13147,10 @@ } }, "node_modules/@cucumber/tag-expressions": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-6.2.0.tgz", - "integrity": "sha512-KIF0eLcafHbWOuSDWFw0lMmgJOLdDRWjEL1kfXEWrqHmx2119HxVAr35WuEd9z542d3Yyg+XNqSr+81rIKqEdg==", - "dev": true, - "license": "MIT" + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-8.1.0.tgz", + "integrity": "sha512-UFeOVUyc711/E7VHjThxMwg3jbGod9TlbM1gxNixX/AGDKg82Eha4cE0tKki3GGUs7uB2NyI+hQAuhB8rL2h5A==", + "dev": true }, "node_modules/@emnapi/core": { "version": "1.7.1", @@ -13225,7 +13191,6 @@ "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", "dev": true, - "license": "MIT", "engines": { "node": "20 || >=22" } @@ -13235,7 +13200,6 @@ "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "dev": true, - "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" }, @@ -14029,19 +13993,6 @@ "node": ">=18" } }, - "node_modules/@puppeteer/browsers/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@sinclair/typebox": { "version": "0.34.41", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", @@ -14074,7 +14025,6 @@ "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz", "integrity": "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==", "dev": true, - "license": "MIT", "engines": { "node": ">=14" } @@ -14289,6 +14239,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cucumber": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/cucumber/-/cucumber-6.0.1.tgz", + "integrity": "sha512-+GZV6xfN0MeN9shDCdny8GbC8N0+U6uca8cjyaJndcwmrUhwS6qOU2vmYn0d71EOwJF568/v3SxJ8VKxuZNYRw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", @@ -14426,8 +14383,7 @@ "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/@types/puppeteer": { "version": "5.4.7", @@ -16235,7 +16191,6 @@ "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -16419,22 +16374,15 @@ } }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", "dev": true, - "license": "ISC", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, "engines": { "node": "20 || >=22" }, @@ -16570,25 +16518,17 @@ } }, "node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", "dev": true, - "license": "ISC", "dependencies": { - "lru-cache": "^10.0.1" + "lru-cache": "^11.1.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -16691,7 +16631,6 @@ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -16904,22 +16843,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jest": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", @@ -18323,21 +18246,19 @@ } }, "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "ISC", "engines": { "node": "20 || >=22" } }, "node_modules/luxon": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz", - "integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" } @@ -18499,7 +18420,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -18643,18 +18563,17 @@ "license": "MIT" }, "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^9.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -18874,7 +18793,6 @@ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" @@ -18973,8 +18891,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/proxy-agent": { "version": "6.5.0", @@ -19132,38 +19049,51 @@ "license": "MIT" }, "node_modules/read-package-up": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", - "integrity": "sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", "dev": true, - "license": "MIT", "dependencies": { - "find-up-simple": "^1.0.0", - "read-pkg": "^9.0.0", - "type-fest": "^4.6.0" + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" }, "engines": { - "node": ">=18" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-package-up/node_modules/type-fest": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.0.tgz", + "integrity": "sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g==", + "dev": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.0.0.tgz", + "integrity": "sha512-A70UlgfNdKI5NSvTTfHzLQj7NJRpJ4mT5tGafkllJ4wh71oYuGm/pzphHcmW4s35iox56KSK721AihodoXSc/A==", "dev": true, - "license": "MIT", "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.2.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -19174,7 +19104,6 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", @@ -19187,6 +19116,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-pkg/node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.0.tgz", + "integrity": "sha512-d9CwU93nN0IA1QL+GSNDdwLAu1Ew5ZjTwupvedwg3WdfoH6pIDvYQ2hV0Uc2nKBLPq7NB5apCx57MLS5qlmO5g==", + "dev": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -19338,10 +19294,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "bin": { "semver": "bin/semver.js" }, @@ -19546,7 +19501,6 @@ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, - "license": "Apache-2.0", "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -19556,15 +19510,13 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" + "dev": true }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, - "license": "MIT", "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -19574,8 +19526,7 @@ "version": "3.0.22", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz", "integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==", - "dev": true, - "license": "CC0-1.0" + "dev": true }, "node_modules/sprintf-js": { "version": "1.0.3", @@ -19843,6 +19794,18 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", @@ -19996,8 +19959,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/tmpl": { "version": "1.0.5", @@ -20021,8 +19983,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/tree-kill": { "version": "1.2.2", @@ -20097,19 +20058,6 @@ } } }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -20244,11 +20192,10 @@ "license": "MIT" }, "node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -20396,7 +20343,6 @@ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, - "license": "Apache-2.0", "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" @@ -20522,7 +20468,6 @@ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", "dev": true, - "license": "MIT", "engines": { "node": ">=8.0" } @@ -20627,11 +20572,10 @@ } }, "node_modules/yup": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.0.tgz", - "integrity": "sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "dev": true, - "license": "MIT", "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", @@ -20644,7 +20588,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=12.20" }, diff --git a/server/data/app-data.json b/server/data/app-data.json index fbadf223..a6dc2693 100644 --- a/server/data/app-data.json +++ b/server/data/app-data.json @@ -21,177 +21,5 @@ "email": "jr@gmail.com" } ], - "classes": [ - { - "topic": "Engenharia de Software e Sistemas", - "semester": 1, - "year": 2025, - "especificacaoDoCalculoDaMedia": { - "pesosDosConceitos": { - "MA": 10, - "MPA": 7, - "MANA": 0 - }, - "pesosDasMetas": { - "Gerência de Configuração": 1, - "Gerência de Projeto": 1, - "Qualidade de Software": 1 - } - }, - "enrollments": [ - { - "studentCPF": "11111111111", - "evaluations": [ - { - "goal": "Requirements", - "grade": "MPA" - }, - { - "goal": "Configuration Management", - "grade": "MA" - }, - { - "goal": "Project Management", - "grade": "MANA" - }, - { - "goal": "Design", - "grade": "MA" - }, - { - "goal": "Tests", - "grade": "MPA" - }, - { - "goal": "Refactoring", - "grade": "MA" - } - ] - }, - { - "studentCPF": "55555555555", - "evaluations": [ - { - "goal": "Requirements", - "grade": "MANA" - }, - { - "goal": "Configuration Management", - "grade": "MPA" - }, - { - "goal": "Project Management", - "grade": "MPA" - }, - { - "goal": "Design", - "grade": "MA" - }, - { - "goal": "Refactoring", - "grade": "MA" - }, - { - "goal": "Tests", - "grade": "MPA" - } - ] - }, - { - "studentCPF": "22222222222", - "evaluations": [ - { - "goal": "Configuration Management", - "grade": "MANA" - }, - { - "goal": "Requirements", - "grade": "MA" - }, - { - "goal": "Project Management", - "grade": "MPA" - } - ] - } - ] - }, - { - "topic": "Engenharia de Software e Sistemas", - "semester": 2, - "year": 2025, - "especificacaoDoCalculoDaMedia": { - "pesosDosConceitos": { - "MA": 10, - "MPA": 7, - "MANA": 0 - }, - "pesosDasMetas": { - "Gerência de Configuração": 1, - "Gerência de Projeto": 1, - "Qualidade de Software": 1 - } - }, - "enrollments": [ - { - "studentCPF": "22222222222", - "evaluations": [ - { - "goal": "Requirements", - "grade": "MPA" - }, - { - "goal": "Configuration Management", - "grade": "MA" - }, - { - "goal": "Project Management", - "grade": "MPA" - }, - { - "goal": "Design", - "grade": "MA" - }, - { - "goal": "Refactoring", - "grade": "MA" - }, - { - "goal": "Tests", - "grade": "MA" - } - ] - }, - { - "studentCPF": "33333333333", - "evaluations": [ - { - "goal": "Requirements", - "grade": "MA" - }, - { - "goal": "Configuration Management", - "grade": "MPA" - }, - { - "goal": "Project Management", - "grade": "MANA" - }, - { - "goal": "Design", - "grade": "MA" - }, - { - "goal": "Tests", - "grade": "MANA" - }, - { - "goal": "Refactoring", - "grade": "MA" - } - ] - } - ] - } - ] + "classes": [] } \ No newline at end of file diff --git a/server/src/models/Class.ts b/server/src/models/Class.ts index 33fb7a1e..0b1c9679 100644 --- a/server/src/models/Class.ts +++ b/server/src/models/Class.ts @@ -67,11 +67,8 @@ export class Class { getEspecificacaoDoCalculoDaMedia(): EspecificacaoDoCalculoDaMedia { return this.especificacaoDoCalculoDaMedia; } - - /** - * Define as metas em lote. Só pode ser chamada uma vez; depois disso as metas ficam imutáveis. - * Lança Error se as metas já estiverem lockadas ou se o array for inválido. - */ + + // Metas management setMetas(metas: string[]): void { if (this.metasLocked) { throw new Error('Metas já foram definidas para a turma e não podem ser alteradas!'); @@ -79,6 +76,10 @@ export class Class { if (!Array.isArray(metas) || metas.length === 0) { throw new Error('As metas de uma turma não devem ser vazias!'); } + // Check for empty strings in metas + if (metas.some(m => !m || m.trim() === '')) { + throw new Error('Metas não podem ter títulos vazios!'); + } // verificar se array tem duplicatas const hasDuplicates = metas.length !== new Set(metas).size; if (hasDuplicates) { diff --git a/server/src/models/Classes.ts b/server/src/models/Classes.ts index b51640ed..4bb39818 100644 --- a/server/src/models/Classes.ts +++ b/server/src/models/Classes.ts @@ -119,7 +119,24 @@ export class Classes { } }); }); + return students; } + + getClassMetas(id: string): any[] { + const classObj = this.findClassById(id); + if (!classObj) { + throw new Error('Class not found'); + } + return classObj.getMetas(); + } + + addClassMetas(id: string, metas: any[]): void { + const classObj = this.findClassById(id); + if (!classObj) { + throw new Error('Turma não encontrada!'); + } + classObj.setMetas(metas); + } } \ No newline at end of file diff --git a/server/src/server.ts b/server/src/server.ts index 5b4e0abc..6c90482e 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -360,13 +360,15 @@ app.delete('/api/classes/:id', (req: Request, res: Response) => { app.get('/api/classes/:id/metas', (req: Request, res: Response) => { try { const { id } = req.params; - const classObj = classes.findClassById(id); - // checar se a turma existe - if (!classObj) { - return res.status(404).json({ error: 'Class not found' }); + try { + const metas = classes.getClassMetas(id); + res.json({ metas }); + } catch (error) { + if ((error as Error).message === 'Class not found') { + return res.status(404).json({ error: 'Class not found' }); + } + throw error; } - const metas = classObj.getMetas(); - res.json({ metas }); } catch (error) { res.status(400).json({ error: (error as Error).message }); } @@ -378,22 +380,17 @@ app.post('/api/classes/:id/metas', (req: Request, res: Response) => { const { id } = req.params; const { metas } = req.body; - // verificar se a turma existe - const classObj = classes.findClassById(id); - if (!classObj) { - return res.status(404).json({ error: 'Turma não encontrada!' }); - } - try { - classObj.setMetas(metas); + classes.addClassMetas(id, metas); + triggerSave(); // Save to file after adding metas + res.status(201).json({ message: 'Metas criadas com sucesso!' }); } catch (err) { - return res.status(409).json({ error: (err as Error).message }); + const errorMessage = (err as Error).message; + if (errorMessage === 'Turma não encontrada!') { + return res.status(404).json({ error: errorMessage }); + } + return res.status(409).json({ error: errorMessage }); } - - triggerSave(); // Save to file after adding metas - - // retornar uma mensagem de metas criadas com sucesso - res.status(201).json({ message: 'Metas criadas com sucesso!' }); } catch (error) { res.status(400).json({ error: (error as Error).message }); } From ff2bc28c28d7d0de9da6f93bf054840801d80b34 Mon Sep 17 00:00:00 2001 From: BrunoMog <140001165+BrunoMog@users.noreply.github.com> Date: Tue, 9 Dec 2025 11:29:59 -0300 Subject: [PATCH 09/12] Update server/src/models/Classes.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/src/models/Classes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/models/Classes.ts b/server/src/models/Classes.ts index 4bb39818..a873bb5c 100644 --- a/server/src/models/Classes.ts +++ b/server/src/models/Classes.ts @@ -135,7 +135,7 @@ export class Classes { addClassMetas(id: string, metas: any[]): void { const classObj = this.findClassById(id); if (!classObj) { - throw new Error('Turma não encontrada!'); + throw new Error('Class not found'); } classObj.setMetas(metas); } From 5f8c5a8d4fd6d62ff50554b336316981841d3daf Mon Sep 17 00:00:00 2001 From: basb Date: Tue, 9 Dec 2025 12:07:10 -0300 Subject: [PATCH 10/12] =?UTF-8?q?altera=C3=A7=C3=B5es=20sugeridas=20no=20p?= =?UTF-8?q?r=20pelo=20copilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 2 +- client/src/components/Classes.tsx | 8 +- .../step-definitions/criacao-metas.steps.ts | 5 +- client/src/step-definitions/student-steps.ts | 3 - server/data/app-data.json | 43 +++++++++- server/src/models/Class.ts | 79 ++++++++++++------- server/src/models/Classes.ts | 2 +- server/src/server.ts | 4 +- 8 files changed, 102 insertions(+), 44 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 63a9df11..f8af5f6c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -113,7 +113,7 @@ const App: React.FC = () => { const handleSuccess = (message: string) => { setSuccess(message); // Clear success after a short timeout - setTimeout(() => setSuccess(''), 8000); + setTimeout(() => setSuccess(''), 5000); }; return ( diff --git a/client/src/components/Classes.tsx b/client/src/components/Classes.tsx index a789535a..ad8baaf2 100644 --- a/client/src/components/Classes.tsx +++ b/client/src/components/Classes.tsx @@ -163,7 +163,7 @@ const Classes: React.FC = ({ // After creating a class, prepare localMetas for metas flow if (created && created.id) { setLocalMetas([]); - // open metas panel automatically for the newly created class + // open enrollment panel automatically for the newly created class setEnrollmentPanelClass(created); } onClassAdded(); @@ -418,8 +418,9 @@ const Classes: React.FC = ({ className="close-modal-btn" onClick={handleCloseEnrollmentPanel} title="Close" + aria-label="Close" > - × + ×
@@ -524,8 +525,9 @@ const Classes: React.FC = ({ className="close-modal-btn" onClick={() => setMetaPanelClass(null)} title="Close" + aria-label="Close" > - × + ×
diff --git a/client/src/step-definitions/criacao-metas.steps.ts b/client/src/step-definitions/criacao-metas.steps.ts index 8cdb451a..23c3b85b 100644 --- a/client/src/step-definitions/criacao-metas.steps.ts +++ b/client/src/step-definitions/criacao-metas.steps.ts @@ -1,10 +1,7 @@ -import { Given, When, Then, Before, After, setDefaultTimeout } from '@cucumber/cucumber'; -import { Browser, Page, launch } from 'puppeteer'; +import { Given, When, Then, After } from '@cucumber/cucumber'; import expect from 'expect'; import { scope } from './setup'; -// setDefaultTimeout(10 * 1000); - const BASE = 'http://localhost:3004'; const API = 'http://localhost:3005'; const createdClassIds: string[] = []; diff --git a/client/src/step-definitions/student-steps.ts b/client/src/step-definitions/student-steps.ts index 5d597acc..1da5076e 100644 --- a/client/src/step-definitions/student-steps.ts +++ b/client/src/step-definitions/student-steps.ts @@ -3,9 +3,6 @@ import { Browser, Page, launch } from 'puppeteer'; import expect from 'expect'; import { scope } from './setup'; -// Set default timeout for all steps -// setDefaultTimeout(30 * 1000); // 30 seconds - // Helper function to format CPF like the frontend does function formatCPF(value: string): string { const digits = value.replace(/\D/g, ''); diff --git a/server/data/app-data.json b/server/data/app-data.json index a6dc2693..c98cb147 100644 --- a/server/data/app-data.json +++ b/server/data/app-data.json @@ -21,5 +21,46 @@ "email": "jr@gmail.com" } ], - "classes": [] + "classes": [ + { + "topic": "engenharia software", + "semester": 1, + "year": 2025, + "metas": [ + "Requisitos", + "Testes de software" + ], + "metasLocked": false, + "especificacaoDoCalculoDaMedia": { + "pesosDosConceitos": { + "MA": 10, + "MPA": 7, + "MANA": 0 + }, + "pesosDasMetas": { + "Gerência de Configuração": 1, + "Gerência de Projeto": 1, + "Qualidade de Software": 1 + } + }, + "enrollments": [ + { + "studentCPF": "11111111111", + "evaluations": [] + }, + { + "studentCPF": "22222222222", + "evaluations": [] + }, + { + "studentCPF": "33333333333", + "evaluations": [] + }, + { + "studentCPF": "55555555555", + "evaluations": [] + } + ] + } + ] } \ No newline at end of file diff --git a/server/src/models/Class.ts b/server/src/models/Class.ts index 0b1c9679..ba9fafec 100644 --- a/server/src/models/Class.ts +++ b/server/src/models/Class.ts @@ -11,14 +11,23 @@ export class Class { private metas: string[]; private metasLocked: boolean; - constructor(topic: string, semester: number, year: number, metas: string[] = [], especificacaoDoCalculoDaMedia: EspecificacaoDoCalculoDaMedia, enrollments: Enrollment[] = []) { + // Update constructor to accept metasLocked (optional, default false) + constructor( + topic: string, + semester: number, + year: number, + metas: string[] = [], + especificacaoDoCalculoDaMedia: EspecificacaoDoCalculoDaMedia, + enrollments: Enrollment[] = [], + metasLocked: boolean = false // Add this parameter + ) { this.topic = topic; this.semester = semester; this.year = year; this.especificacaoDoCalculoDaMedia = especificacaoDoCalculoDaMedia; - this.enrollments = enrollments; this.metas = metas; - this.metasLocked = metas.length > 0; + this.enrollments = enrollments; + this.metasLocked = metasLocked; // Initialize properly } // Getters @@ -86,7 +95,7 @@ export class Class { throw new Error('Metas não podem conter duplicatas!'); } this.metas = metas; - this.metasLocked = true; + this.metasLocked = true; // Lock it immediately after setting } // Enrollment management @@ -126,7 +135,7 @@ export class Class { return this.enrollments.map(enrollment => enrollment.getStudent()); } - // Convert to JSON for API responses + // Convert to JSON for API responses AND persistence toJSON() { return { id: this.getClassId(), @@ -134,35 +143,47 @@ export class Class { semester: this.semester, year: this.year, metas: this.metas, - metasLocked: this.metasLocked, - especificacaoDoCalculoDaMedia: this.especificacaoDoCalculoDaMedia.toJSON(), + metasLocked: this.metasLocked, // Persist the state + // Check if toJSON exists (it might be a plain object if loaded incorrectly previously) + especificacaoDoCalculoDaMedia: this.especificacaoDoCalculoDaMedia.toJSON ? this.especificacaoDoCalculoDaMedia.toJSON() : this.especificacaoDoCalculoDaMedia, enrollments: this.enrollments.map(enrollment => enrollment.toJSON()) }; } // Create Class from JSON object - static fromJSON(data: { topic: string; semester: number; year: number; metas: string[]; especificacaoDoCalculoDaMedia: any, enrollments: any[] }, allStudents: Student[]): Class { - const enrollments = data.enrollments - ? data.enrollments.map((enrollmentData: any) => { - const student = allStudents.find(s => s.getCPF() === enrollmentData.student.cpf); - if (!student) { - throw new Error(`Student with CPF ${enrollmentData.student.cpf} not found`); - } - return Enrollment.fromJSON(enrollmentData, student); - }) - : []; + static fromJSON(data: { + topic: string; + semester: number; + year: number; + metas: string[]; + especificacaoDoCalculoDaMedia: any, + enrollments: any[], + metasLocked?: boolean // Add type definition here + }, allStudents: Student[]): Class { + const enrollments = data.enrollments.map(e => { + const studentCpf = e.student ? (e.student.cpf || e.student._cpf) : null; + const student = allStudents.find(s => s.getCPF() === studentCpf); + + if (!student) { + // If student is not found (data inconsistency), we throw an error + throw new Error(`Student with CPF ${studentCpf} not found for enrollment in class ${data.topic}`); + } + + return Enrollment.fromJSON(e, student); + }); - // Novo carregamento do EspecificacaoDoCalculoDaMedia - const especificacaoDoCalculoDaMedia = EspecificacaoDoCalculoDaMedia.fromJSON(data.especificacaoDoCalculoDaMedia); - - const metas = Array.isArray(data.metas) ? data.metas : []; - const cls = new Class(data.topic, data.semester, data.year, metas, especificacaoDoCalculoDaMedia, enrollments); - // Se o JSON já veio com metas e quisermos respeitar lock vindo do arquivo, checar data.metasLocked - if ((data as any).metasLocked) { - // forçar lock mesmo que metas tenha sido passada vazia (já tratada acima) - (cls as any).metasLocked = true; - } - - return cls; + const isLocked = data.metasLocked !== undefined + ? data.metasLocked + : (data.metas && data.metas.length > 0); + + return new Class( + data.topic, + data.semester, + data.year, + data.metas, + data.especificacaoDoCalculoDaMedia, + enrollments, + isLocked + ); } } diff --git a/server/src/models/Classes.ts b/server/src/models/Classes.ts index 4bb39818..4d64eeb1 100644 --- a/server/src/models/Classes.ts +++ b/server/src/models/Classes.ts @@ -127,7 +127,7 @@ export class Classes { getClassMetas(id: string): any[] { const classObj = this.findClassById(id); if (!classObj) { - throw new Error('Class not found'); + throw new Error('Turma não encontrada!'); } return classObj.getMetas(); } diff --git a/server/src/server.ts b/server/src/server.ts index 6c90482e..9c8b676e 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -364,8 +364,8 @@ app.get('/api/classes/:id/metas', (req: Request, res: Response) => { const metas = classes.getClassMetas(id); res.json({ metas }); } catch (error) { - if ((error as Error).message === 'Class not found') { - return res.status(404).json({ error: 'Class not found' }); + if ((error as Error).message === 'Turma não encontrada!') { + return res.status(404).json({ error: 'Turma não encontrada!' }); } throw error; } From 7a9ec756e62c9ab360db569a90f600bdd2abb258 Mon Sep 17 00:00:00 2001 From: BrunoMog <140001165+BrunoMog@users.noreply.github.com> Date: Wed, 10 Dec 2025 06:09:18 -0300 Subject: [PATCH 11/12] Update server/src/models/Classes.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- server/src/models/Classes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/models/Classes.ts b/server/src/models/Classes.ts index fa729bc1..4d64eeb1 100644 --- a/server/src/models/Classes.ts +++ b/server/src/models/Classes.ts @@ -135,7 +135,7 @@ export class Classes { addClassMetas(id: string, metas: any[]): void { const classObj = this.findClassById(id); if (!classObj) { - throw new Error('Class not found'); + throw new Error('Turma não encontrada!'); } classObj.setMetas(metas); } From 04513f0ad27e0b22f0493364a1fd23762bc5fcad Mon Sep 17 00:00:00 2001 From: Bruno Antonio dos Santos Bezerra Date: Wed, 10 Dec 2025 09:30:10 -0300 Subject: [PATCH 12/12] =?UTF-8?q?testes=20unit=C3=A1rios=20de=20metas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/__tests__/metas.test.ts | 59 ++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 server/src/__tests__/metas.test.ts diff --git a/server/src/__tests__/metas.test.ts b/server/src/__tests__/metas.test.ts new file mode 100644 index 00000000..5a28f566 --- /dev/null +++ b/server/src/__tests__/metas.test.ts @@ -0,0 +1,59 @@ +import { Classes } from '../models/Classes'; +import { Class } from '../models/Class'; + +describe('Metas - Classes', () => { + let classes: Classes; + const specStub: any = { toJSON: () => ({}) }; + + beforeEach(() => { + classes = new Classes(); + }); + + test('deve criar metas para uma turma com sucesso', () => { + const turma = new Class('engenharia-de-software-e-sistemas', 1, 2024, [], specStub); + classes.addClass(turma); + + const metas = ['Requisitos', 'Testes de software']; + classes.addClassMetas(turma.getClassId(), metas); + + expect(classes.getClassMetas(turma.getClassId())).toEqual(metas); + expect(turma.isMetasLocked()).toBe(true); + }); + + test('deve rejeitar meta com título vazio e não persistir metas', () => { + const turma = new Class('engenharia-de-software-e-sistemas', 1, 2024, [], specStub); + classes.addClass(turma); + + expect(() => { + classes.addClassMetas(turma.getClassId(), ['', 'Testes de software']); + }).toThrow('Metas não podem ter títulos vazios!'); + + expect(classes.getClassMetas(turma.getClassId())).toEqual([]); + expect(turma.isMetasLocked()).toBe(false); + }); + + test('deve rejeitar metas duplicadas na mesma requisição e não persistir metas', () => { + const turma = new Class('engenharia-de-software-e-sistemas', 1, 2024, [], specStub); + classes.addClass(turma); + + expect(() => { + classes.addClassMetas(turma.getClassId(), ['Requisitos', 'Requisitos']); + }).toThrow('Metas não podem conter duplicatas!'); + + expect(classes.getClassMetas(turma.getClassId())).toEqual([]); + expect(turma.isMetasLocked()).toBe(false); + }); + + test('deve rejeitar tentativa de criar metas para turma que já possui metas', () => { + const metasExistentes = ['Comunicação', 'Sincronização']; + const turma = new Class('sistemas-distribuidos', 2, 2024, metasExistentes, specStub, [], true); + classes.addClass(turma); + + expect(() => { + classes.addClassMetas(turma.getClassId(), ['Nova Meta A']); + }).toThrow('Metas já foram definidas para a turma e não podem ser alteradas!'); + + expect(classes.getClassMetas(turma.getClassId())).toEqual(metasExistentes); + expect(turma.isMetasLocked()).toBe(true); + }); +}); \ No newline at end of file