diff --git a/.eslintrc.json b/.eslintrc.json index cfcd7b02..653c6503 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -56,6 +56,12 @@ "plugin:@angular-eslint/template/accessibility" ], "rules": {} + }, + { + "files": ["**/*.spec.ts"], + "rules": { + "@typescript-eslint/no-explicit-any": "off" + } } ] } diff --git a/angular.json b/angular.json index 292d8e4c..a4df0436 100644 --- a/angular.json +++ b/angular.json @@ -79,7 +79,14 @@ "defaultConfiguration": "development" }, "test": { - "builder": "@angular/build:unit-test" + "builder": "@angular/build:unit-test", + "options": { + "tsConfig": "tsconfig.spec.json", + "coverage": true, + "coverageExclude": [ + "**/*.html" + ] + } }, "lint": { "builder": "@angular-eslint/builder:lint", diff --git a/src/app/custom_types/means-graph.type.ts b/src/app/custom_types/means-graph.type.ts index 0cf1b912..d683a3c8 100644 --- a/src/app/custom_types/means-graph.type.ts +++ b/src/app/custom_types/means-graph.type.ts @@ -2,3 +2,5 @@ import { AcademicYear } from '@values/years'; export type Means = [number[], number[][]]; export type MeansPerYear = Record; + +export type DisclaimerType = 'info' | 'warning'; diff --git a/src/app/enums/chart-typology.enum.ts b/src/app/enums/chart-typology.enum.ts new file mode 100644 index 00000000..1a4bdc76 --- /dev/null +++ b/src/app/enums/chart-typology.enum.ts @@ -0,0 +1,7 @@ +export const GraphSelection = { + CDS_GENERAL: 'cds_general', + TEACHINGS_CDS: 'teaching_cds', + YEAR: 'cds_year', +} as const; + +export type GraphSelectionType = (typeof GraphSelection)[keyof typeof GraphSelection]; diff --git a/src/app/interfaces/cds.interface.ts b/src/app/interfaces/cds.interface.ts index 762e524d..0922fdd2 100644 --- a/src/app/interfaces/cds.interface.ts +++ b/src/app/interfaces/cds.interface.ts @@ -1,5 +1,4 @@ import { Teaching } from './teaching.interface'; -import { GraphView } from './graph-config.interface'; import { MeansPerYear } from '@c_types/means-graph.type'; export interface CDS { @@ -18,10 +17,5 @@ export interface CDS { export interface AllCdsInfoResp { teachings: Teaching[]; - coarse: MeansPerYear; - graphs: { - cds_stats: GraphView; - // cds_stats_by_year: ChartData, - // cds_techings: ChartData - }; + courses: MeansPerYear; } diff --git a/src/app/interfaces/graph-config.interface.ts b/src/app/interfaces/graph-config.interface.ts index ce912376..c0a85167 100644 --- a/src/app/interfaces/graph-config.interface.ts +++ b/src/app/interfaces/graph-config.interface.ts @@ -1,6 +1,30 @@ +import { DisclaimerType } from '@c_types/means-graph.type'; +import { GraphSelectionType } from '@enums/chart-typology.enum'; import { ChartData, ChartType } from 'chart.js'; export interface GraphView { type: ChartType; data: ChartData; } + +export interface GraphSelectionBtn { + value: GraphSelectionType; + active: boolean; + description: string; + label: string; + icon: string; +} + +export interface SelectOption { + value: string | number; + label: string; +} + +export interface DisclaimerInfo { + title: string; + description: string; + type: DisclaimerType; + icon: string; + isAccordion: boolean; + isOpen: boolean; +} diff --git a/src/app/mappers/graph.mapper.ts b/src/app/mappers/graph.mapper.ts new file mode 100644 index 00000000..d262e1f0 --- /dev/null +++ b/src/app/mappers/graph.mapper.ts @@ -0,0 +1,56 @@ +import { MeansPerYear } from '@c_types/means-graph.type'; +import { GraphView } from '@interfaces/graph-config.interface'; +import { SchedaOpis } from '@interfaces/opis-record.interface'; +import { typedKeys } from '@utils/object-helpers.utils'; +import { ACADEMIC_YEARS, AcademicYear } from '@values/years'; + +/** + * Pure mapping class: transforms pre-computed data into GraphView objects + * compatible with ng-chart. No injected dependencies, no state. + * Use static methods directly wherever needed (service, component, resolver). + */ +export class GraphMapper { + private static buildLineGraph(means: MeansPerYear): GraphView { + const labels: AcademicYear[] = []; + const v1: number[] = []; + const v2: number[] = []; + const v3: number[] = []; + + for (const year of typedKeys(means)) { + const [yearMeans] = means[year]; + labels.push(year); + v1.push(yearMeans[0]); + v2.push(yearMeans[1]); + v3.push(yearMeans[2]); + } + + return { + type: 'line', + data: { + labels, + datasets: [ + { label: 'V1', data: [...v1] }, + { label: 'V2', data: [...v2] }, + { label: 'V3', data: [...v3] }, + ], + }, + }; + } + + static groupByYear( + items: T[], + getScheda: (item: T) => SchedaOpis, + ): Record { + const initial = Object.fromEntries( + ACADEMIC_YEARS.map((year) => [year, []]), + ) as unknown as Record; + + return items.reduce((acc, item) => { + const year = item.anno_accademico as AcademicYear; + acc[year]?.push(getScheda(item)); + return acc; + }, initial); + } + static toCdsGeneralGraph = GraphMapper.buildLineGraph; + static toTeachingGraph = GraphMapper.buildLineGraph; +} diff --git a/src/app/mocks/scheda-mock.ts b/src/app/mocks/scheda-mock.ts new file mode 100644 index 00000000..d693ff9d --- /dev/null +++ b/src/app/mocks/scheda-mock.ts @@ -0,0 +1,29 @@ +import { SchedaOpis } from "@interfaces/opis-record.interface"; + +export const exampleSchedaOpis: SchedaOpis = { + id: 1, + anno_accademico: '2023/2024', + totale_schede: 10, + totale_schede_nf: 0, + femmine: 0, + femmine_nf: 0, + fc: 0, + inatt: 0, + inatt_nf: 0, + eta: null, + anno_iscr: null, + num_studenti: null, + ragg_uni: null, + studio_gg: null, + studio_tot: null, + domande: [ + [10, 0, 0, 0], // Q1 → d=10, V1 += (10/10)*1 = 1 + [0, 10, 0, 0], // Q2 → d=40, V2 += (40/10)*1 = 4 + [0, 0, 10, 0], // Q3 → d=70, V3 += (70/10)*1 = 7 + ], + domande_nf: null, + motivo_nf: null, + sugg: null, + sugg_nf: null, + id_insegnamento: 1, +}; \ No newline at end of file diff --git a/src/app/pages/department/department.html b/src/app/pages/department/department.html index c0fa890d..2857f130 100644 --- a/src/app/pages/department/department.html +++ b/src/app/pages/department/department.html @@ -1,32 +1,51 @@
@if (department() !== null) {
-
+
Torna indietro

{{ department()?.anno_accademico }}

-
+

{{ department()?.nome }}

-
+
@if (cdsList.isLoading()) { } @else {
- @if (isCdsSelected) { - +
+ @if (isCdsSelected()) { + + } +

( {{ cds().classe }} ) {{ cds().nome }}

+
+ + @if (isCdsSelected()) { +
+ @for (graphBtn of graphBtns(); track $index) { + + } +
} -

( {{ cds().classe }} ) {{ cds().nome }}

- @if (!isCdsSelected) { + @if (!isCdsSelected()) {
    @let allCds = cdsList.value(); @@ -43,8 +62,9 @@

    ( {{ cds().classe }} ) {{ cds().nome }}

    }
- @if (isCdsSelected) { - + @if (isCdsSelected()) { + + } }
diff --git a/src/app/pages/department/department.scss b/src/app/pages/department/department.scss index b5176f81..70b5634d 100644 --- a/src/app/pages/department/department.scss +++ b/src/app/pages/department/department.scss @@ -9,7 +9,6 @@ display: inline-flex; align-items: center; justify-content: space-between; - width: 100%; a { margin-bottom: 1rem; @@ -43,10 +42,6 @@ padding-right: 1.5rem; } } - - @media (min-width: 1300px) { - width: 70%; - } } &-bottom { @@ -85,7 +80,6 @@ } @media (min-width: 1300px) { - width: 70%; padding: 3rem 1rem; } } @@ -96,7 +90,6 @@ display: flex; flex-direction: column; align-items: start; - width: 90%; margin: 0 auto; h3 { @@ -111,12 +104,28 @@ .cds { &-title { - display: inline-flex; - align-items: start; - justify-content: start; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 1.7rem; + width: 100%; margin-top: 1rem; margin-bottom: 1.2rem; + + &-name { + display: inline-flex; + align-items: start; + gap: 1rem; + } + + &-graph { + display: inline-flex; + align-items: center; + justify-content: space-between; + + @media (min-width: 800px) { + justify-content: space-evenly; + } + } } &-list { @@ -159,9 +168,5 @@ } } } - - @media (min-width: 1300px) { - width: 70%; - } } } diff --git a/src/app/pages/department/department.spec.ts b/src/app/pages/department/department.spec.ts index 348e0e30..571d2d6b 100644 --- a/src/app/pages/department/department.spec.ts +++ b/src/app/pages/department/department.spec.ts @@ -1,21 +1,42 @@ -import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; import { exampleCDS } from '@mocks/cds-mock'; import { exampleDepartment } from '@mocks/department-mock'; import { CdsService } from '@services/cds/cds.service'; import { DepartmentsService } from '@services/departments/departments.service'; +import { GraphService } from '@services/graph/graph.service'; +import { QuestionService } from '@services/questions/questions.service'; +import { CdsSelectedSection } from '@sections/cds-selected-section/cds-selected-section'; +import { Disclaimers } from '@cards/disclaimer/disclaimers'; import { NO_CHOICE_CDS } from '@values/no-choice-cds'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { DepartmentPageComponent } from './department'; +import { of } from 'rxjs'; +import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; -describe('Dipartimento', () => { +@Component({ + selector: 'opis-cds-selected-section', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockCdsSelectedSection {} + +@Component({ + selector: 'opis-disclaimers', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockDisclaimers { + readonly disclaimers = input([]); +} + +describe('DepartmentPageComponent', () => { let component: DepartmentPageComponent; let fixture: ComponentFixture; // eslint-disable-next-line @typescript-eslint/no-explicit-any - let mockDepartmentsService: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockCdsService: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let mockGraphService: any; const mockDepartment = exampleDepartment; const mockCDS = exampleCDS; @@ -29,15 +50,26 @@ describe('Dipartimento', () => { status: () => 'success', }; - mockDepartmentsService = { + const mockDepartmentsService = { getCdsDepartment: vi.fn(() => mockResource), }; + mockCdsService = { cdsSelected: signal(NO_CHOICE_CDS), - getInfoCds: vi.fn(() => mockResource), + getInfoCds: mockResource, + isLoading: signal(false), + }; + + mockGraphService = { + graphKeySelected: signal('cds_general'), + graphBtns: signal([]), + manageGraphSelection: vi.fn(() => mockResource), + }; + + const mockQuestionService = { + loadQuestionsWeights: vi.fn(() => of(null)), }; - // Mock localStorage vi.spyOn(Storage.prototype, 'getItem').mockReturnValue(JSON.stringify(mockDepartment)); vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(vi.fn()); @@ -46,9 +78,16 @@ describe('Dipartimento', () => { providers: [ { provide: DepartmentsService, useValue: mockDepartmentsService }, { provide: CdsService, useValue: mockCdsService }, + { provide: GraphService, useValue: mockGraphService }, + { provide: QuestionService, useValue: mockQuestionService }, provideRouter([]), ], - }).compileComponents(); + }) + .overrideComponent(DepartmentPageComponent, { + remove: { imports: [CdsSelectedSection, Disclaimers] }, + add: { imports: [MockCdsSelectedSection, MockDisclaimers] }, + }) + .compileComponents(); fixture = TestBed.createComponent(DepartmentPageComponent); component = fixture.componentInstance; @@ -57,31 +96,90 @@ describe('Dipartimento', () => { it('[DEPARTMENT]: created', () => expect(component).toBeTruthy()); - it('should initialize departmentData from localStorage', () => { + it('[DEPARTMENT]: should initialize department from localStorage on ngOnInit', () => { component.ngOnInit(); expect(component['department']()).toEqual(mockDepartment); }); - it('should throw if no department in localStorage', () => { + it('[DEPARTMENT]: should throw if no department found in localStorage', () => { vi.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce(null); expect(() => component.ngOnInit()).toThrow( 'Impossibile recuperare le info del dipartimento selezionato', ); }); - it('should update isCdsSelected when cds changes', async () => { + it('[DEPARTMENT]: should set isCdsSelected to true when a CDS is selected', async () => { component['selectCds'](mockCDS); - fixture.detectChanges(); await fixture.whenStable(); - expect(component['isCdsSelected']).toBe(true); + expect(component['isCdsSelected']()).toBe(true); expect(mockCdsService.cdsSelected()).toEqual(mockCDS); }); - it('should reset cdsSelected and remove localStorage on ngOnDestroy', () => { + it('[DEPARTMENT]: should set isCdsSelected to false when NO_CHOICE_VALUE is selected', async () => { + mockCdsService.cdsSelected.set(mockCDS); + component['selectCds'](NO_CHOICE_CDS); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component['isCdsSelected']()).toBe(false); + }); + + it('[DEPARTMENT]: should reset graphKey to "cds_general" when deselecting a CDS', () => { + component['selectCds'](NO_CHOICE_CDS); + expect(mockGraphService.graphKeySelected()).toBe('cds_general'); + }); + + it('[DEPARTMENT]: should update graphKeySelected on selectGraphType', () => { + component['selectGraphType']('teaching_cds'); + expect(mockGraphService.graphKeySelected()).toBe('teaching_cds'); + }); + + it('[DEPARTMENT]: should update graphKeySelected to "cds_year"', () => { + component['selectGraphType']('cds_year'); + expect(mockGraphService.graphKeySelected()).toBe('cds_year'); + }); + + it('[DEPARTMENT]: should remove "department" from localStorage on ngOnDestroy', () => { component.ngOnDestroy(); expect(window.localStorage.removeItem).toHaveBeenCalledWith('department'); + }); + + it('[DEPARTMENT]: should reset cdsSelected to NO_CHOICE_VALUE on ngOnDestroy', () => { + mockCdsService.cdsSelected.set(mockCDS); + component.ngOnDestroy(); expect(mockCdsService.cdsSelected()).toEqual(NO_CHOICE_CDS); }); + + it('[DEPARTMENT]: should reflect cdsService.isLoading', () => { + expect(component['isLoading']()).toBe(false); + }); + + it('[DEPARTMENT]: should reflect graphService.graphBtns', () => { + const btns = [{ value: 'cds_general', label: 'Generale', active: true, icon: 'bar_chart' }]; + mockGraphService.graphBtns.set(btns); + fixture.detectChanges(); + expect(component['graphBtns']()).toEqual(btns); + }); + + it('[DEPARTMENT]: should NOT reset graphKey when selecting a valid CDS', () => { + mockGraphService.graphKeySelected.set('teaching_cds'); + component['selectCds'](mockCDS); + expect(mockGraphService.graphKeySelected()).toBe('teaching_cds'); + }); + + it('[DEPARTMENT]: should return NO_CHOICE_VALUE when no CDS is selected', () => { + expect(component['cds']()).toEqual(NO_CHOICE_CDS); + }); + + it('[DEPARTMENT]: should return false for isCdsSelected when no CDS is selected', () => { + expect(component['isCdsSelected']()).toBe(false); + }); + + it('[DEPARTMENT]: should return null for department before ngOnInit', () => { + vi.spyOn(Storage.prototype, 'getItem').mockReturnValueOnce(null); + const freshFixture = TestBed.createComponent(DepartmentPageComponent); + expect(freshFixture.componentInstance['department']()).toBeNull(); + }); }); diff --git a/src/app/pages/department/department.ts b/src/app/pages/department/department.ts index 304c2743..d66fec94 100644 --- a/src/app/pages/department/department.ts +++ b/src/app/pages/department/department.ts @@ -2,8 +2,6 @@ import { ChangeDetectionStrategy, Component, computed, - effect, - EffectRef, inject, OnDestroy, OnInit, @@ -14,16 +12,20 @@ import { DepartmentsService } from '@services/departments/departments.service'; import { RouterLink } from '@angular/router'; import { CdsService } from '@services/cds/cds.service'; import { CDS } from '@interfaces/cds.interface'; -import { NO_CHOICE_CDS } from '@values/no-choice-cds'; +import { NO_CHOICE_CDS, NO_SELECTION_CDS_ID } from '@values/no-choice-cds'; import { CdsSelectedSection } from '@sections/cds-selected-section/cds-selected-section'; import { QuestionService } from '@services/questions/questions.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { IconComponent } from '@shared-ui/icon/icon'; import { Loader } from '@shared-ui/loader/loader'; +import { GraphService } from '@services/graph/graph.service'; +import { GraphSelectionType } from '@enums/chart-typology.enum'; +import { Disclaimers } from '@cards/disclaimer/disclaimers'; +import { OpisGroup_Disclaimers } from '@values/disclaimers.value'; @Component({ selector: 'opis-department', - imports: [RouterLink, Loader, IconComponent, CdsSelectedSection], + imports: [RouterLink, Loader, IconComponent, CdsSelectedSection, Disclaimers], templateUrl: './department.html', styleUrl: './department.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -32,18 +34,22 @@ export class DepartmentPageComponent implements OnInit, OnDestroy { private readonly _departmentService = inject(DepartmentsService); private readonly _cdsService = inject(CdsService); private readonly _questionService = inject(QuestionService); + private readonly _graphService = inject(GraphService); private readonly departmentData = signal(null); protected readonly NO_CHOICE_VALUE = NO_CHOICE_CDS; + protected readonly GroupsDisclaimers = OpisGroup_Disclaimers; - protected isCdsSelected = false; + protected readonly isCdsSelected = computed(() => this.cds().id !== NO_SELECTION_CDS_ID); protected readonly department = computed(() => this.departmentData() ?? null); protected readonly cds = computed(() => this._cdsService.cdsSelected() ?? this.NO_CHOICE_VALUE); protected cdsList = this._departmentService.getCdsDepartment(this.departmentData); + protected readonly graphBtns = computed(this._graphService.graphBtns); + protected readonly isLoading = this._cdsService.isLoading; + constructor() { - this.manageListVisibility(); this.retrieveQuestions(); } @@ -75,11 +81,15 @@ export class DepartmentPageComponent implements OnInit, OnDestroy { this.departmentData.set(correctDepFormat); } - private manageListVisibility(): EffectRef { - return effect(() => (this.isCdsSelected = this.cds().id !== this.NO_CHOICE_VALUE.id)); - } - protected selectCds(newCds: CDS): void { + if (newCds.id === NO_SELECTION_CDS_ID) { + this.selectGraphType('cds_general'); + } + this._cdsService.cdsSelected.set(newCds); } + + protected selectGraphType(newGraph: GraphSelectionType): void { + this._graphService.graphKeySelected.set(newGraph); + } } diff --git a/src/app/pages/login/login.html b/src/app/pages/login/login.html new file mode 100644 index 00000000..147cfc4f --- /dev/null +++ b/src/app/pages/login/login.html @@ -0,0 +1 @@ +

login works!

diff --git a/src/app/pages/login/login.scss b/src/app/pages/login/login.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/login/login.spec.ts b/src/app/pages/login/login.spec.ts new file mode 100644 index 00000000..1ee4152d --- /dev/null +++ b/src/app/pages/login/login.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Login } from './login'; + +describe('Login', () => { + let component: Login; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Login], + }).compileComponents(); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/login/login.ts b/src/app/pages/login/login.ts new file mode 100644 index 00000000..3ce56445 --- /dev/null +++ b/src/app/pages/login/login.ts @@ -0,0 +1,10 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'opis-login', + imports: [], + templateUrl: './login.html', + styleUrl: './login.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Login {} diff --git a/src/app/services/cds/cds.service.spec.ts b/src/app/services/cds/cds.service.spec.ts index 5f3ae825..315524af 100644 --- a/src/app/services/cds/cds.service.spec.ts +++ b/src/app/services/cds/cds.service.spec.ts @@ -1,8 +1,140 @@ -import { describe, expect, it } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; +import { firstValueFrom } from 'rxjs'; import { CdsService } from './cds.service'; +import { GraphService } from '@services/graph/graph.service'; +import { exampleCDS } from '@mocks/cds-mock'; +import { env } from '@env'; +// ─── Mock GraphService ──────────────────────────────────────────────────────── +const buildMockGraphService = () => ({ + graphKeySelected: signal('cds_general'), + graphBtns: signal([]), + manageGraphSelection: { isLoading: signal(false) }, + computeMeansPerYear: vi.fn().mockReturnValue({}), +}); + +// ─── Suite ──────────────────────────────────────────────────────────────────── describe('CdsService', () => { - it('should be defined', () => { - expect(CdsService).toBeDefined(); + let service: CdsService; + let httpMock: HttpTestingController; + let mockGraphService: ReturnType; + + beforeEach(() => { + mockGraphService = buildMockGraphService(); + + TestBed.configureTestingModule({ + providers: [ + CdsService, + provideHttpClient(), + provideHttpClientTesting(), + { provide: GraphService, useValue: mockGraphService }, + ], + }); + + service = TestBed.inject(CdsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + // ── cdsSelected ─────────────────────────────────────────────────────────── + it('[CDS-SERVICE]: should initialize cdsSelected as null', () => { + expect(service.cdsSelected()).toBeNull(); + }); + + it('[CDS-SERVICE]: should update cdsSelected', () => { + service.cdsSelected.set(exampleCDS); + expect(service.cdsSelected()).toEqual(exampleCDS); + }); + + // ── isLoading ───────────────────────────────────────────────────────────── + it('[CDS-SERVICE]: should reflect true when graphService is loading', () => { + mockGraphService.manageGraphSelection.isLoading.set(true); + expect(service.isLoading()).toBe(true); + }); + + // ── teachingCdsApi ──────────────────────────────────────────────────────── + it('[CDS-SERVICE]: should call teachings API with correct URL', async () => { + const promise = firstValueFrom(service['teachingCdsApi'](exampleCDS.id)); + + const req = httpMock.expectOne(`${env.api_url}/cds/with-id/${exampleCDS.id}/insegnamenti`); + expect(req.request.method).toBe('GET'); + req.flush([{ id: 1, nome: 'Matematica' }]); + + await promise; + }); + + it('[CDS-SERVICE]: should throw when teachings API returns empty array', async () => { + const promise = firstValueFrom(service['teachingCdsApi'](exampleCDS.id)); + + httpMock.expectOne(`${env.api_url}/cds/with-id/${exampleCDS.id}/insegnamenti`).flush([]); + + await expect(promise).rejects.toThrow('Nessun insegnamento trovato'); + }); + + // ── cdsStatsApi ─────────────────────────────────────────────────────────── + it('[CDS-SERVICE]: should call stats API with correct URL', async () => { + const promise = firstValueFrom(service['cdsStatsApi'](exampleCDS.unict_id)); + + const req = httpMock.expectOne(`${env.api_url}/cds/coarse/${exampleCDS.unict_id}/schedeopis`); + expect(req.request.method).toBe('GET'); + req.flush([{ insegnamenti: [] }]); + + await promise; + }); + + it('[CDS-SERVICE]: should throw when stats API returns empty array', async () => { + const promise = firstValueFrom(service['cdsStatsApi'](exampleCDS.unict_id)); + + httpMock.expectOne(`${env.api_url}/cds/coarse/${exampleCDS.unict_id}/schedeopis`).flush([]); + + await expect(promise).rejects.toThrow('Schede OPIS non trovate'); + }); + + // ── extractValidSchedeOpis ──────────────────────────────────────────────── + it('[CDS-SERVICE]: should extract only valid SchedeOpis', () => { + const cdsList = [ + { + insegnamenti: [ + { schedeopis: { domande: [[1, 2, 3, 4]], totale_schede: 10 } }, + { schedeopis: null }, + ], + }, + { insegnamenti: [{ schedeopis: { domande: null } }] }, + ] as any; + + expect(service['extractValidSchedeOpis'](cdsList).length).toBe(1); + }); + + it('[CDS-SERVICE]: should return empty array when no valid SchedeOpis exist', () => { + const cdsList = [{ insegnamenti: [{ schedeopis: null }] }] as any; + expect(service['extractValidSchedeOpis'](cdsList)).toEqual([]); + }); + + // ── updateCDS ───────────────────────────────────────────────────────────── + it('[CDS-SERVICE]: should call PUT with correct URL and token', async () => { + const token = 'test-token'; + const promise = firstValueFrom(service.updateCDS(exampleCDS, token)); + + const url = new URL(`${env.api_url}/cds/with-id/${exampleCDS.id}`); + url.searchParams.set('scostamento_numerosita', String(exampleCDS.scostamento_numerosita)); + url.searchParams.set('scostamento_media', String(exampleCDS.scostamento_media)); + + const req = httpMock.expectOne(url.toString()); + expect(req.request.method).toBe('PUT'); + expect(req.request.headers.get('Authorization')).toBe(token); + req.flush({}); + + await promise; + }); + + it('[CDS-SERVICE]: should complete successfully on updateCDS', async () => { + const promise = firstValueFrom(service.updateCDS(exampleCDS, 'token')); + httpMock.expectOne(() => true).flush({}); + await expect(promise).resolves.toBeDefined(); }); }); diff --git a/src/app/services/cds/cds.service.ts b/src/app/services/cds/cds.service.ts index 81090730..dd70b7af 100644 --- a/src/app/services/cds/cds.service.ts +++ b/src/app/services/cds/cds.service.ts @@ -1,16 +1,15 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { inject, Injectable, ResourceRef, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; import { MeansPerYear } from '@c_types/means-graph.type'; import { env } from '@env'; import { AllCdsInfoResp, CDS } from '@interfaces/cds.interface'; import { SchedaOpis } from '@interfaces/opis-record.interface'; import { Teaching } from '@interfaces/teaching.interface'; +import { GraphMapper } from '@mappers/graph.mapper'; import { GraphService } from '@services/graph/graph.service'; -import { typedKeys } from '@utils/object-helpers.utils'; import { DELAY_API_MS } from '@values/delay-api'; -import { AcademicYear } from '@values/years'; -import { catchError, delay, forkJoin, map, Observable, throwError } from 'rxjs'; +import { delay, forkJoin, map, Observable, throwError } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class CdsService { @@ -19,51 +18,36 @@ export class CdsService { private readonly _graphService = inject(GraphService); readonly cdsSelected = signal(null); - + readonly isLoading = computed( + () => this.getInfoCds.isLoading() || this._graphService.manageGraphSelection.isLoading(), + ); + + /** + * Extracts valid SchedaOpis from a CDS list. + * Each CDS in the /coarse response represents one academic year, + * and each teaching has a single schedeopis (or null) for that year. + */ private extractValidSchedeOpis(cdsList: CDS[]): SchedaOpis[] { return cdsList .flatMap((cds) => cds.insegnamenti) - .filter((insegnamento) => insegnamento.schedeopis != null) - .flatMap((insegnamento) => insegnamento.schedeopis) - .filter((schedaopis) => schedaopis.domande != null); + .map((insegnamento) => insegnamento.schedeopis) + .filter((scheda): scheda is SchedaOpis => scheda?.domande != null); } - private groupByYears(schede: SchedaOpis[]): Record { - return schede.reduce( - (acc, scheda) => { - const year = scheda.anno_accademico as AcademicYear; - if (!acc[year]) acc[year] = []; - acc[year].push(scheda); - return acc; - }, - {} as Record, - ); - } - - private formatAllYearsCdsStats(resp: CDS[]): MeansPerYear { - const cdsSchede = this.extractValidSchedeOpis(resp); - const schedeByYears = this.groupByYears(cdsSchede); - - const vCds = {} as MeansPerYear; + private computeCdsMeans(cdsList: CDS[]): MeansPerYear { + const cdsSchede = this.extractValidSchedeOpis(cdsList); + const schedeByYears = GraphMapper.groupByYear(cdsSchede, (scheda) => scheda); - for (const year of typedKeys(schedeByYears)) { - const allSchede = schedeByYears[year]; - - vCds[year] = this._graphService.elaborateFormulaFor(allSchede); - } - - return vCds; + return this._graphService.computeMeansPerYear(schedeByYears); } private teachingCdsApi(cds: number): Observable { const url = `${this.BASE_URL}/with-id/${cds}/insegnamenti`; return this._http.get(url).pipe( - map((teaching) => { - if (!teaching?.length) { - throw new Error('Nessun insegnamento trovato'); - } - return teaching; + map((teachings) => { + if (!teachings?.length) throw new Error('Nessun insegnamento trovato'); + return teachings; }), ); } @@ -72,55 +56,34 @@ export class CdsService { const url = `${this.BASE_URL}/coarse/${unictCdsId}/schedeopis`; return this._http.get(url).pipe( - map((coarse) => { - if (!coarse) throw new Error('Schede OPIS non trovate'); - return this.formatAllYearsCdsStats(coarse); + map((rawCourse) => { + if (!rawCourse?.length) throw new Error('Schede OPIS non trovate'); + return this.computeCdsMeans(rawCourse); }), ); } - public getInfoCds(): ResourceRef { - return rxResource({ - params: () => this.cdsSelected(), - stream: ({ params }) => { - if (!params?.id || !params?.unict_id) { - return throwError(() => new Error('Id or Unict_id missing!')); - } + readonly getInfoCds = rxResource({ + params: this.cdsSelected, + stream: ({ params }) => { + if (!params?.id || !params?.unict_id) { + return throwError(() => new Error('Id or Unict_id missing!')); + } - return forkJoin([this.teachingCdsApi(params.id), this.cdsStatsApi(params.unict_id)]).pipe( - delay(DELAY_API_MS), - map(([teachings, coarse]) => { - const respDTO: AllCdsInfoResp = { - teachings, - coarse, - graphs: { - cds_stats: this._graphService.formatCDSGraph(coarse), - }, - }; - return respDTO; - }), - catchError((err) => throwError(() => err)), - ); - }, - }); - } + return forkJoin([this.teachingCdsApi(params.id), this.cdsStatsApi(params.unict_id)]).pipe( + delay(DELAY_API_MS), + map(([teachings, courses]) => ({ teachings, courses })), + ); + }, + }); - // TODO ??? - public updateCDS(cds: CDS, token: string) { - const httpOptions = { - headers: new HttpHeaders({ Authorization: token }), - }; + updateCDS(cds: CDS, token: string): Observable { + const url = new URL(`${this.BASE_URL}/with-id/${cds.id}`); + url.searchParams.set('scostamento_numerosita', String(cds.scostamento_numerosita)); + url.searchParams.set('scostamento_media', String(cds.scostamento_media)); - return this._http.put( - this.BASE_URL + - '/with-id/' + - cds.id + - '?scostamento_numerosita=' + - cds.scostamento_numerosita + - '&scostamento_media=' + - cds.scostamento_media, - {}, - httpOptions, - ); + const headers = new HttpHeaders({ Authorization: token }); + + return this._http.put(url.toString(), {}, { headers }); } } diff --git a/src/app/services/departments/departments.service.ts b/src/app/services/departments/departments.service.ts index f84352b1..324180b5 100644 --- a/src/app/services/departments/departments.service.ts +++ b/src/app/services/departments/departments.service.ts @@ -15,9 +15,9 @@ export class DepartmentsService { private readonly BASE_URL = env.api_url + '/dipartimento'; private readonly _http = inject(HttpClient); - public readonly logoAlreadyAnimated = signal(false); - public readonly canStartUserFlow = signal(false); - public readonly selectedYear = signal('2020/2021'); + readonly logoAlreadyAnimated = signal(false); + readonly canStartUserFlow = signal(false); + readonly selectedYear = signal('2020/2021'); private departmentsApi(year: AcademicYear): Observable { const url = `${this.BASE_URL}?anno_accademico=${year}`; @@ -44,14 +44,14 @@ export class DepartmentsService { return this._http.get(url).pipe(delay(DELAY_API_MS)); } - public getDepartmentByYear(): ResourceRef { + getDepartmentByYear(): ResourceRef { return rxResource({ params: () => this.selectedYear(), stream: ({ params }) => this.departmentsApi(params), }); } - public getCdsDepartment( + getCdsDepartment( department: WritableSignal, ): ResourceRef { return rxResource({ diff --git a/src/app/services/graph/graph.service.spec.ts b/src/app/services/graph/graph.service.spec.ts new file mode 100644 index 00000000..62e4d71d --- /dev/null +++ b/src/app/services/graph/graph.service.spec.ts @@ -0,0 +1,141 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, beforeEach, expect } from 'vitest'; +import { GraphService } from './graph.service'; +import { QuestionService } from '@services/questions/questions.service'; +import { SchedaOpis } from '@interfaces/opis-record.interface'; +import { CHART_BTNS } from '@values/selection-graph'; +import { exampleSchedaOpis } from '@mocks/scheda-mock'; +import { AcademicYear } from '@values/years'; + +// ─── Mock SchedaOpis factory ────────────────────────────────────────────────── +const mockScheda = (overrides: Partial = {}): SchedaOpis => ({ ...exampleSchedaOpis, ...overrides}); + +// ─── Mock QuestionService ───────────────────────────────────────────────────── +const buildMockQuestionService = () => ({ + questionWeights: [ + { id: 1, gruppo: 'V1', peso_standard: 1 }, + { id: 2, gruppo: 'V2', peso_standard: 1 }, + { id: 3, gruppo: 'V3', peso_standard: 1 }, + ], +}); + +// ─── Suite ──────────────────────────────────────────────────────────────────── +describe('GraphService', () => { + let service: GraphService; + let mockQuestionService: ReturnType; + + beforeEach(() => { + mockQuestionService = buildMockQuestionService(); + + TestBed.configureTestingModule({ + providers: [ + GraphService, + { provide: QuestionService, useValue: mockQuestionService }, + ], + }); + + service = TestBed.inject(GraphService); + }); + + // ── graphKeySelected ────────────────────────────────────────────────────── + it('[GRAPH-SERVICE]: should initialize graphKeySelected as cds_general', () => { + expect(service.graphKeySelected()).toBe('cds_general'); + }); + + it('[GRAPH-SERVICE]: should update graphKeySelected', () => { + service.graphKeySelected.set('teaching_cds'); + expect(service.graphKeySelected()).toBe('teaching_cds'); + }); + + // ── graphBtns ───────────────────────────────────────────────────────────── + it('[GRAPH-SERVICE]: should initialize graphBtns with CHART_BTNS', () => { + expect(service.graphBtns()).toEqual(CHART_BTNS); + }); + + // ── manageGraphSelection ────────────────────────────────────────────────── + it('[GRAPH-SERVICE]: should set cds_general as active on init', async () => { + await new Promise(r => setTimeout(r, 0)); + const activeBtns = service.graphBtns().filter(b => b.active); + expect(activeBtns.length).toBe(1); + expect(activeBtns[0].value).toBe('cds_general'); + }); + + it('[GRAPH-SERVICE]: should update active button when graphKeySelected changes', async () => { + service.graphKeySelected.set('teaching_cds'); + await new Promise(r => setTimeout(r, 0)); + const activeBtns = service.graphBtns().filter(b => b.active); + expect(activeBtns.length).toBe(1); + expect(activeBtns[0].value).toBe('teaching_cds'); + }); + + // ── applyWeights ────────────────────────────────────────────────────────── + it('[GRAPH-SERVICE]: should return [0,0,0] when totale_schede < 5', () => { + const scheda = mockScheda({ totale_schede: 4 }); + expect(service['applyWeights'](scheda)).toEqual([0, 0, 0]); + }); + + it('[GRAPH-SERVICE]: should compute V1/V2/V3 correctly', () => { + const scheda = mockScheda(); + expect(service['applyWeights'](scheda)).toEqual([1, 4, 7]); + }); + + it('[GRAPH-SERVICE]: should skip questions not found in questionWeights', () => { + const scheda = mockScheda({ + domande: [ + [10, 0, 0, 0], // Q1 → V1 + [0, 10, 0, 0], // Q2 → V2 + [0, 0, 10, 0], // Q3 → V3 + [0, 0, 0, 10], // Q4 → not in weights, skipped + ], + }); + expect(service['applyWeights'](scheda)).toEqual([1, 4, 7]); + }); + + // ── elaborateFormulaFor ─────────────────────────────────────────────────── + it('[GRAPH-SERVICE]: should return means and per-schedule values', () => { + const scheda = mockScheda(); + const [means, perSchedule] = service['elaborateFormulaFor']([scheda]); + + expect(means).toEqual([1, 4, 7]); + expect(perSchedule).toEqual([[1], [4], [7]]); + }); + + it('[GRAPH-SERVICE]: should compute means across multiple schedules', () => { + const scheda1 = mockScheda(); + const scheda2 = mockScheda({ + domande: [ + [0, 0, 0, 10], // Q1 → d=100, V1 += (100/10)*1 = 10 + [0, 0, 0, 10], // Q2 → d=100, V2 += (100/10)*1 = 10 + [0, 0, 0, 10], // Q3 → d=100, V3 += (100/10)*1 = 10 + ], + }); + + const [means] = service['elaborateFormulaFor']([scheda1, scheda2]); + expect(means).toEqual([5.5, 7, 8.5]); + }); + + it('[GRAPH-SERVICE]: should return empty means for empty array', () => { + const [means, perSchedule] = service['elaborateFormulaFor']([]); + expect(means).toEqual([0, 0, 0]); + expect(perSchedule).toEqual([[], [], []]); + }); + + // ── computeMeansPerYear ─────────────────────────────────────────────────── + it('[GRAPH-SERVICE]: should compute means for each academic year', () => { + const schedeByYear = { + '2020/2021': [mockScheda({ anno_accademico: '2020/2021' })], + '2019/2020': [mockScheda({ anno_accademico: '2019/2020' })], + } as Record; + + const result = service.computeMeansPerYear(schedeByYear); + + expect(result['2020/2021']).toBeDefined(); + expect(result['2019/2020']).toBeDefined(); + expect(result['2020/2021'][0]).toEqual([1, 4, 7]); + expect(result['2019/2020'][0]).toEqual([1, 4, 7]); + }); + + it('[GRAPH-SERVICE]: should return empty object for empty input', () => { + expect(service.computeMeansPerYear({} as any)).toEqual({}); + }); +}); \ No newline at end of file diff --git a/src/app/services/graph/graph.service.ts b/src/app/services/graph/graph.service.ts index 0b3e2e6d..0cb2694e 100644 --- a/src/app/services/graph/graph.service.ts +++ b/src/app/services/graph/graph.service.ts @@ -1,83 +1,63 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable, signal } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; import { Means, MeansPerYear } from '@c_types/means-graph.type'; +import { GraphSelectionType } from '@enums/chart-typology.enum'; import { OpisGroup, OpisGroupType } from '@enums/opis-group.enum'; import { AnswerWeights } from '@enums/weights.enum'; -import { GraphView } from '@interfaces/graph-config.interface'; +import { GraphSelectionBtn } from '@interfaces/graph-config.interface'; import { SchedaOpis } from '@interfaces/opis-record.interface'; import { QuestionService } from '@services/questions/questions.service'; import { typedKeys } from '@utils/object-helpers.utils'; -import { mean, round } from '@utils/statistics.utils'; +import { mean, round } from '@utils/statistics.utils/statistics.utils'; +import { CHART_BTNS } from '@values/selection-graph'; import { AcademicYear } from '@values/years'; +import { of } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class GraphService { private readonly _questionService = inject(QuestionService); - public formatCDSGraph(dataFromResp: MeansPerYear): GraphView { - const labels: AcademicYear[] = []; - const v1: number[] = []; - const v2: number[] = []; - const v3: number[] = []; - - for (const year of typedKeys(dataFromResp)) { - const [means] = dataFromResp[year]; - - const isYearAlredyIn = labels.some((yearLabel) => yearLabel === year); - if (!isYearAlredyIn) labels.push(year); - - v1.push(means[0]); - v2.push(means[1]); - v3.push(means[2]); - } - - return { - type: 'line', - data: { - labels, - datasets: [ - { label: 'V1', data: [...v1] }, - { label: 'V2', data: [...v2] }, - { label: 'V3', data: [...v3] }, - ], - }, - }; - } + readonly graphKeySelected = signal('cds_general'); + readonly graphBtns = signal(CHART_BTNS); - public applyWeights(scheda: SchedaOpis): number[] { + private applyWeights(scheda: SchedaOpis): number[] { const questionsWeights = this._questionService.questionWeights; const { totale_schede, domande: questions } = scheda; - let d = 0; + if (totale_schede < 5) return [0, 0, 0]; + const V: Record = { [OpisGroup.Group1]: 0, [OpisGroup.Group2]: 0, [OpisGroup.Group3]: 0, }; - if (totale_schede >= 5) { - for (let j = 0; j < questions.length; j++) { - const singleQuestion = questions[j]; + for (let j = 0; j < questions.length; j++) { + const singleQuestion = questions[j]; - d = 0.0; - d += singleQuestion[0] * AnswerWeights.DefinitelyNo; - d += singleQuestion[1] * AnswerWeights.MoreNoThanYes; - d += singleQuestion[2] * AnswerWeights.MoreYesThanNo; - d += singleQuestion[3] * AnswerWeights.DefinitelyYes; + let d = 0; + d += singleQuestion[0] * AnswerWeights.DefinitelyNo; + d += singleQuestion[1] * AnswerWeights.MoreNoThanYes; + d += singleQuestion[2] * AnswerWeights.MoreYesThanNo; + d += singleQuestion[3] * AnswerWeights.DefinitelyYes; - const domanda = questionsWeights.find((question) => question.id === j + 1); - if (!domanda) continue; + const domanda = questionsWeights.find((question) => question.id === j + 1); + if (!domanda) continue; - const { gruppo, peso_standard } = domanda; - if (Object.prototype.hasOwnProperty.call(V, gruppo)) { - V[gruppo] += (d / totale_schede) * peso_standard; - } + const { gruppo, peso_standard } = domanda; + if (Object.prototype.hasOwnProperty.call(V, gruppo)) { + V[gruppo] += (d / totale_schede) * peso_standard; } } return [round(V[OpisGroup.Group1]), round(V[OpisGroup.Group2]), round(V[OpisGroup.Group3])]; } - public elaborateFormulaFor(opisSchedules: SchedaOpis[]): Means { + /** + * Computes V1/V2/V3 scores for a set of OPIS schedules. + * Returns both the aggregate means and the per-schedule values. + */ + private elaborateFormulaFor(opisSchedules: SchedaOpis[]): Means { const v1: number[] = []; const v2: number[] = []; const v3: number[] = []; @@ -92,4 +72,39 @@ export class GraphService { const means = [round(mean(v1)), round(mean(v2)), round(mean(v3))]; return [means, [v1, v2, v3]]; } + + /** + * Returns the currently active graph button based on graphKeySelected. + * Updates the active flag across all buttons reactively. + */ + readonly manageGraphSelection = rxResource({ + params: this.graphKeySelected, + stream: ({ params: graphSelected }) => { + const currentBtns = structuredClone(this.graphBtns()); + const graph = currentBtns.find((btn) => btn.value === graphSelected) ?? currentBtns[0]; + + for (const graphStored of currentBtns) { + graphStored.active = graphStored.value === graph.value; + } + + this.graphBtns.set(currentBtns); + return of(graph); + }, + }); + + /** + * Computes V1/V2/V3 means for each academic year from a pre-grouped record of OPIS schedules. + * Delegates the formula elaboration to `elaborateFormulaFor` for each year's set of schedules. + */ + computeMeansPerYear(schedeByYear: Record): MeansPerYear { + const meansPerYear = {} as MeansPerYear; + + for (const year of typedKeys(schedeByYear)) { + const allSchedules = schedeByYear[year]; + + meansPerYear[year] = this.elaborateFormulaFor(allSchedules); + } + + return meansPerYear; + } } diff --git a/src/app/services/questions/questions.service.ts b/src/app/services/questions/questions.service.ts index a1a6eab3..3b8b34ef 100644 --- a/src/app/services/questions/questions.service.ts +++ b/src/app/services/questions/questions.service.ts @@ -21,9 +21,9 @@ export class QuestionService { catchError(() => throwError(() => new Error('Recupero dei pesi delle domande fallito'))), ); - public questionWeights: Question[]; + questionWeights: Question[]; - public loadQuestionsWeights(): Observable { + loadQuestionsWeights(): Observable { return this._questionWeights$; } diff --git a/src/app/services/teachings/teachings.service.ts b/src/app/services/teachings/teachings.service.ts new file mode 100644 index 00000000..4422ba9a --- /dev/null +++ b/src/app/services/teachings/teachings.service.ts @@ -0,0 +1,46 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { inject, Injectable, ResourceRef, signal } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { MeansPerYear } from '@c_types/means-graph.type'; +import { env } from '@env'; +import { Teaching } from '@interfaces/teaching.interface'; +import { GraphMapper } from '@mappers/graph.mapper'; +import { GraphService } from '@services/graph/graph.service'; +import { map, Observable, of } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class TeachingService { + private readonly BASE_URL = env.api_url + '/insegnamento'; + + private readonly _http = inject(HttpClient); + private readonly _graphService = inject(GraphService); + + readonly selectedTeaching = signal(null); + + private teachingCoarseApi(teaching: Teaching): Observable { + const url = `${this.BASE_URL}/coarse/${teaching.codice_gomp}/schedeopis`; + const params = new HttpParams() + .set('canale', teaching.canale ?? 'no') + .set('id_modulo', teaching.id_modulo ?? '0'); + + return this._http + .get(url, { params }) + .pipe(map((rows) => rows.filter((t) => t.schedeopis?.domande != null))); + } + + private computeTeachingMeans(rows: Teaching[]) { + const teachingScheduleByYear = GraphMapper.groupByYear(rows, (teaching) => teaching.schedeopis); + return this._graphService.computeMeansPerYear(teachingScheduleByYear); + } + + getTeachingGraph(): ResourceRef { + return rxResource({ + params: this.selectedTeaching, + stream: ({ params }) => { + if (!params) return of(null); + + return this.teachingCoarseApi(params).pipe(map((rows) => this.computeTeachingMeans(rows))); + }, + }); + } +} diff --git a/src/app/ui/cards/disclaimer/disclaimers.html b/src/app/ui/cards/disclaimer/disclaimers.html new file mode 100644 index 00000000..08388504 --- /dev/null +++ b/src/app/ui/cards/disclaimer/disclaimers.html @@ -0,0 +1,35 @@ +
+ @for (disclaimer of disclaimers(); track $index) { +
+ + +
+
+
+
+ } +
diff --git a/src/app/ui/cards/disclaimer/disclaimers.scss b/src/app/ui/cards/disclaimer/disclaimers.scss new file mode 100644 index 00000000..42afbacf --- /dev/null +++ b/src/app/ui/cards/disclaimer/disclaimers.scss @@ -0,0 +1,170 @@ +// ─── Tokens ───────────────────────────────────────────────────────────────── +$radius: 1rem; +$transition: 0.25s ease; + +#disclaimers { + display: flex; + flex-direction: column; + gap: 1.2rem; + margin: 1rem 0 2rem; +} + +// ─── Host ──────────────────────────────────────────────────────────────────── +.disclaimer { + border-radius: $radius; + border: 1px solid transparent; + overflow: hidden; + transition: box-shadow $transition; + box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.08); + background-color: white; + + &.is-open { + .disclaimer { + &__description { + padding: 0.9rem; + opacity: 1; + transition: 0.3s; + + @media (min-width: 800px) { + padding: 10px 25px 25px 40px; + } + } + } + } + + // ── warning → Sand ───────────────────────────────────────────────────────── + &--warning { + &.is-open { + border-color: var(--sand-600); + + .disclaimer { + &__header { + background-color: var(--sand-100); + } + } + } + + .disclaimer { + &__icon { + color: var(--sand-500); + } + + &__title { + color: var(--sand-700); + } + + &__description { + color: var(--sand-600); + } + } + } + + // ── info → Sea Blue ──────────────────────────────────────────────────────── + &--info { + &.is-open { + border-color: var(--blue-300); + + .disclaimer { + &__header { + background-color: rgba(0, 105, 148, 0.06); + } + } + } + + .disclaimer { + &__icon { + color: var(--blue-700); + } + + &__title { + color: var(--blue-900); + } + + &__description { + color: var(--blue-700); + } + } + } +} + +// ─── Header ────────────────────────────────────────────────────────────────── +.disclaimer__header { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.5rem; + background: none; + border: none; + cursor: pointer; + text-align: left; + transition: background-color $transition; + + &:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: -2px; + border-radius: $radius; + } + + &:hover { + background-color: rgba(0, 105, 148, 0.06); + } + + &-left { + display: flex; + align-items: center; + gap: 1rem; + } + + @media (min-width: 800px) { + padding: 12px 14px; + } +} + +// ─── Title ──────────────────────────────────────────────────────────────────── +.disclaimer__title { + font-weight: 600; + line-height: 1.3; + letter-spacing: 0.01em; + font-size: 0.95rem; + + @media (min-width: 800px) { + font-size: 1.1rem; + } +} + +// ─── Chevron (ultimo opis-icon nell'header) ─────────────────────────────────── +.disclaimer__header > opis-icon:last-of-type { + flex-shrink: 0; + color: var(--gray-400); + transition: transform $transition; + + .is-open & { + transform: rotate(180deg); + } +} + +// ─── Body (accordion) ───────────────────────────────────────────────────────── +.disclaimer__body { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows $transition; + + > * { + overflow: hidden; + } + + .is-open & { + grid-template-rows: 1fr; + } +} + +// ─── Description ────────────────────────────────────────────────────────────── +.disclaimer__description { + margin: 0; + padding: 0; + opacity: 0; + font-size: 14px; + line-height: 1.6; + transition: 0.3s; +} diff --git a/src/app/ui/cards/disclaimer/disclaimers.ts b/src/app/ui/cards/disclaimer/disclaimers.ts new file mode 100644 index 00000000..7f9f112e --- /dev/null +++ b/src/app/ui/cards/disclaimer/disclaimers.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, input, linkedSignal } from '@angular/core'; +import { DisclaimerInfo } from '@interfaces/graph-config.interface'; +import { IconComponent } from '@shared-ui/icon/icon'; +import { slug } from '@utils/strings.utils'; + +@Component({ + selector: 'opis-disclaimers', + imports: [IconComponent], + templateUrl: './disclaimers.html', + styleUrl: './disclaimers.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Disclaimers { + readonly disclaimers = input.required(); + + private readonly _openId = linkedSignal(() => { + const defaultOpen = this.disclaimers().find((d) => d.isOpen); + return this.getIdentityKey(defaultOpen); + }); + + private getIdentityKey(disclaimer?: DisclaimerInfo): string | null { + if (!disclaimer) return null; + + const slugTitle = slug(disclaimer.title); + return `${disclaimer.type}-${slugTitle}`; + } + + protected isOpen(disclaimer: DisclaimerInfo): boolean { + if (!disclaimer.isAccordion) return false; + + return this._openId() === this.getIdentityKey(disclaimer); + } + + protected manageOpening(disclaimer: DisclaimerInfo): void { + if (!disclaimer.isAccordion) return; + + const identityKey = this.getIdentityKey(disclaimer); + this._openId.set(this._openId() === identityKey ? null : identityKey); + } +} diff --git a/src/app/ui/components/graph/grap.html b/src/app/ui/components/graph/grap.html deleted file mode 100644 index c82ff63b..00000000 --- a/src/app/ui/components/graph/grap.html +++ /dev/null @@ -1,10 +0,0 @@ -@if (dataChart()) { -
- -
-} diff --git a/src/app/ui/components/graph/graph.scss b/src/app/ui/components/graph/graph.scss deleted file mode 100644 index 3651c161..00000000 --- a/src/app/ui/components/graph/graph.scss +++ /dev/null @@ -1,11 +0,0 @@ -#graph { - width: 93%; - margin: 1rem auto; - display: flex; - justify-content: center; - - @media (min-width: 1300px) { - width: 60%; - margin: 1.5rem auto; - } -} diff --git a/src/app/ui/components/graph/graph.ts b/src/app/ui/components/graph/graph.ts deleted file mode 100644 index d2fb2c38..00000000 --- a/src/app/ui/components/graph/graph.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ChangeDetectionStrategy, Component, input, OnInit } from '@angular/core'; -import { OpisGroup, OpisGroupType } from '@enums/opis-group.enum'; -import { GraphView } from '@interfaces/graph-config.interface'; -import { ChartConfiguration, ChartData } from 'chart.js'; -import { BaseChartDirective } from 'ng2-charts'; - -@Component({ - selector: 'opis-graph', - imports: [BaseChartDirective], - templateUrl: `./grap.html`, - styleUrl: './graph.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class Graph implements OnInit { - protected readonly chartOptions = this.setInitOptions(); - - readonly dataChart = input.required(); - protected coloredDataChart: ChartData; - - ngOnInit(): void { - this.addBrandColorToDataset(); - } - - private isOpisGroup(value: unknown): value is OpisGroupType { - return Object.values(OpisGroup).includes(value as OpisGroupType); - } - - private cssVar(name: string): string { - return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - } - - private hexToRgba(hex: string, alpha: number): string { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; - } - - private getColor(labelGroup: OpisGroupType): string { - const blue = this.cssVar('--blue-900'); - const red = this.cssVar('--red-900'); - const sand = this.cssVar('--sand-500'); - const defaultGrey = this.cssVar('--gray-700'); - - const colors = new Map([ - [OpisGroup.Group1, blue], - [OpisGroup.Group2, red], - [OpisGroup.Group3, sand], - ]); - - return colors.get(labelGroup) ?? defaultGrey; - } - - private addBrandColorToDataset(): void { - const datasets = this.dataChart().data.datasets; - - this.dataChart().data.datasets = datasets.map((set) => { - const label = set.label; - - if (!this.isOpisGroup(label)) { - return set; - } - - const color = this.getColor(label); - - return { - ...set, - borderColor: color, - backgroundColor: this.hexToRgba(color, 0.6), - }; - }); - } - - private setInitOptions(): ChartConfiguration['options'] { - return { - // We use these empty structures as placeholders for dynamic theming. - scales: { - x: {}, - y: {}, - }, - plugins: { - legend: { - display: true, - }, - }, - }; - } -} diff --git a/src/app/ui/sections/cds-selected-section/cds-selected-section.html b/src/app/ui/sections/cds-selected-section/cds-selected-section.html index 14284ce4..4ffeca59 100644 --- a/src/app/ui/sections/cds-selected-section/cds-selected-section.html +++ b/src/app/ui/sections/cds-selected-section/cds-selected-section.html @@ -1,12 +1,45 @@ -
- @if (infoCds.isLoading()) { +
+ @if (isAllInfoLoading()) { } @else { - @if (infoCds.hasValue() && infoCds.value().graphs) { - - } +
+ @if (infoCds.hasValue() && graphSelected.hasValue()) { + @let graph = graphSelected.value(); + +
+

{{ graph.label }}

+ +

+ {{ graph.description }} +

+ + @if (selectorOptions()) { + + } +
+ + @if (activeGraph()) { + + } @else { +
+ @if (infoTeaching.isLoading()) { + + } @else { +
+ +

{{ msgError() }}

+
+ } +
+ } + } +
- @if (infoCds.status() === ERR_STATUS) { + @if (infoCds.status() === ERR_STATUS || graphSelected.status() === ERR_STATUS) {

Qualcosa è andato storto :/

diff --git a/src/app/ui/sections/cds-selected-section/cds-selected-section.scss b/src/app/ui/sections/cds-selected-section/cds-selected-section.scss index 66ac0c70..e7ee5c2d 100644 --- a/src/app/ui/sections/cds-selected-section/cds-selected-section.scss +++ b/src/app/ui/sections/cds-selected-section/cds-selected-section.scss @@ -9,13 +9,50 @@ } .error { - margin: 2rem auto; - width: 80%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; + width: 90%; color: var(--red-500); h2 { margin-top: 1rem; + + @media (min-width: 1000px) { + width: 70%; + margin-left: auto; + margin-right: auto; + } + } + } + + .graph-description { + &_left { + p { + margin: 0.8rem 0 1.8rem; + } + } + + &_right { + display: flex; + align-items: center; + justify-content: center; + } + + @media (min-width: 1000px) { + display: flex; + justify-content: space-between; + + &_left { + flex: 0 0 35%; + } + + &_right, + opis-graph { + flex: 0 0 65%; + } } } } diff --git a/src/app/ui/sections/cds-selected-section/cds-selected-section.spec.ts b/src/app/ui/sections/cds-selected-section/cds-selected-section.spec.ts index f308fa20..692036fa 100644 --- a/src/app/ui/sections/cds-selected-section/cds-selected-section.spec.ts +++ b/src/app/ui/sections/cds-selected-section/cds-selected-section.spec.ts @@ -1,45 +1,212 @@ +import { ChangeDetectionStrategy, Component, signal, ResourceStatus } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { describe, it, beforeEach, expect } from 'vitest'; +import { describe, it, beforeEach, expect, vi } from 'vitest'; import { CdsSelectedSection } from './cds-selected-section'; import { CdsService } from '@services/cds/cds.service'; +import { GraphService } from '@services/graph/graph.service'; +import { TeachingService } from '@services/teachings/teachings.service'; import { exampleCDS } from '@mocks/cds-mock'; -import { signal } from '@angular/core'; - -class MockCdsService { - readonly cdsSelected = signal(exampleCDS); - getInfoCds() { - return { - status: () => 'success', - isLoading: () => false, - hasValue: () => true, - value: () => exampleCDS, - error: null, - refresh: () => {}, - }; - } -} +import { Graph } from '@shared-ui/graph/graph'; +import { SelectComponent } from '@shared-ui/select/select'; +import { IconComponent } from '@shared-ui/icon/icon'; +import { Loader } from '@shared-ui/loader/loader'; +// ─── Mock componenti ────────────────────────────────────────────────────────── +@Component({ + selector: 'opis-graph', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockGraph {} + +@Component({ + selector: 'opis-select', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockSelect {} + +@Component({ + selector: 'opis-icon', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockIcon {} + +@Component({ + selector: 'opis-loader', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockLoader {} + +// ─── Mock resource factory ──────────────────────────────────────────────────── +const mockResource = (overrides = {}) => ({ + status: signal('idle'), + isLoading: signal(false), + hasValue: signal(false), + value: signal(null), + error: signal(null), + refresh: vi.fn(), + ...overrides, +}); + +// ─── Mock services ──────────────────────────────────────────────────────────── +const buildMockCdsService = () => ({ + cdsSelected: signal(exampleCDS), + getInfoCds: mockResource(), + isLoading: signal(false), +}); + +const buildMockGraphService = () => ({ + graphKeySelected: signal('cds_general'), + graphBtns: signal([]), + manageGraphSelection: mockResource({ + hasValue: signal(true), + value: signal({ value: 'cds_general', label: 'Generale', description: 'Desc', active: true }), + }), +}); + +const buildMockTeachingService = () => ({ + selectedTeaching: signal(null), + getTeachingGraph: vi.fn(() => mockResource()), +}); + +// ─── Suite ──────────────────────────────────────────────────────────────────── describe('CdsSelectedSection', () => { let component: CdsSelectedSection; let fixture: ComponentFixture; + let mockCdsService: ReturnType; + let mockGraphService: ReturnType; + let mockTeachingService: ReturnType; beforeEach(async () => { + window.ResizeObserver = vi.fn().mockImplementation(function () { + return { observe: vi.fn(), disconnect: vi.fn() }; + }); + + mockCdsService = buildMockCdsService(); + mockGraphService = buildMockGraphService(); + mockTeachingService = buildMockTeachingService(); + await TestBed.configureTestingModule({ imports: [CdsSelectedSection], - providers: [{ provide: CdsService, useClass: MockCdsService }], - }).compileComponents(); + providers: [ + { provide: CdsService, useValue: mockCdsService }, + { provide: GraphService, useValue: mockGraphService }, + { provide: TeachingService, useValue: mockTeachingService }, + ], + }) + .overrideComponent(CdsSelectedSection, { + remove: { imports: [Graph, SelectComponent, IconComponent, Loader] }, + add: { imports: [MockGraph, MockSelect, MockIcon, MockLoader] }, + }) + .compileComponents(); fixture = TestBed.createComponent(CdsSelectedSection); component = fixture.componentInstance; + fixture.autoDetectChanges(); + await fixture.whenStable(); + }); + + // ── Init ────────────────────────────────────────────────────────────────── + it('[CDS-SECTION]: should create', () => expect(component).toBeTruthy()); - fixture.detectChanges(); + it('[CDS-SECTION]: should read cds without throwing', () => { + expect(component['cds']()).toEqual(exampleCDS); }); - it('should create', () => { - expect(component).toBeTruthy(); + // ── Loading ─────────────────────────────────────────────────────────────── + it('[CDS-SECTION]: should show loader when isAllInfoLoading is true', async () => { + mockCdsService.isLoading.set(true); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('opis-loader')).toBeTruthy(); }); - it('should read cds without throwing', () => { - expect(component['cds']()).toEqual(exampleCDS); + it('[CDS-SECTION]: should hide content when loading', async () => { + mockCdsService.isLoading.set(true); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.graph-description')).toBeNull(); + }); + + it('[CDS-SECTION]: should show content when not loading', async () => { + mockCdsService.getInfoCds.hasValue.set(true); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.graph-description')).toBeTruthy(); + }); + + it('[CDS-SECTION]: should add "loading" class to section when loading', async () => { + mockCdsService.isLoading.set(true); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.cds-selection_wrap').classList).toContain( + 'loading', + ); + }); + + // ── Error state ─────────────────────────────────────────────────────────── + it('[CDS-SECTION]: should show error block when infoCds has error status', async () => { + mockCdsService.getInfoCds.status.set('error' as ResourceStatus); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.error')).toBeTruthy(); + }); + + it('[CDS-SECTION]: should show error block when graphSelected has error status', async () => { + mockGraphService.manageGraphSelection.status.set('error' as ResourceStatus); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.error')).toBeTruthy(); + }); + + // ── Graph description ───────────────────────────────────────────────────── + it('[CDS-SECTION]: should render graph label and description when data is available', async () => { + mockCdsService.getInfoCds.hasValue.set(true); + await fixture.whenStable(); + expect( + fixture.nativeElement.querySelector('.graph-description_left h2')?.textContent?.trim(), + ).toBe('Generale'); + }); + + it('[CDS-SECTION]: should not render opis-select when selectorOptions is null', async () => { + mockCdsService.getInfoCds.hasValue.set(true); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('opis-select')).toBeNull(); + }); + + // ── Teaching reset ──────────────────────────────────────────────────────── + it('[CDS-SECTION]: should reset selectedTeaching when graphKey changes to non teaching_cds', async () => { + mockGraphService.graphKeySelected.set('teaching_cds'); + await fixture.whenStable(); + + mockTeachingService.selectedTeaching.set({ id: 1 } as any); + mockGraphService.graphKeySelected.set('cds_general'); + await fixture.whenStable(); + + expect(mockTeachingService.selectedTeaching()).toBeNull(); + }); + + it('[CDS-SECTION]: should not reset selectedTeaching when graphKey is teaching_cds', () => { + const teaching = { id: 1 } as any; + mockTeachingService.selectedTeaching.set(teaching); + mockGraphService.graphKeySelected.set('teaching_cds'); + expect(mockTeachingService.selectedTeaching()).toEqual(teaching); + }); + + // ── onSelectorChange ────────────────────────────────────────────────────── + it('[CDS-SECTION]: should set selectedTeaching on selector change when graphKey is teaching_cds', () => { + const teaching = { id: 42, nome: 'Matematica', canale: 'A' }; + mockGraphService.graphKeySelected.set('teaching_cds'); + mockCdsService.getInfoCds.value.set({ teachings: [teaching], courses: {} } as any); + + component['onSelectorChange']({ value: 42, label: 'Matematica (Canale A)' }); + + expect(mockTeachingService.selectedTeaching()).toEqual(teaching); + }); + + it('[CDS-SECTION]: should set selectedTeaching to null if teaching not found', () => { + mockGraphService.graphKeySelected.set('teaching_cds'); + mockCdsService.getInfoCds.value.set({ teachings: [], courses: {} } as any); + + component['onSelectorChange']({ value: 99, label: 'Non esiste' }); + + expect(mockTeachingService.selectedTeaching()).toBeNull(); }); }); diff --git a/src/app/ui/sections/cds-selected-section/cds-selected-section.ts b/src/app/ui/sections/cds-selected-section/cds-selected-section.ts index dc59354e..6fd1043e 100644 --- a/src/app/ui/sections/cds-selected-section/cds-selected-section.ts +++ b/src/app/ui/sections/cds-selected-section/cds-selected-section.ts @@ -2,25 +2,123 @@ import { ChangeDetectionStrategy, Component, computed, + effect, + EffectRef, + ElementRef, inject, ResourceStatus, + signal, + viewChild, } from '@angular/core'; +import { GraphSelection } from '@enums/chart-typology.enum'; +import { GraphView, SelectOption } from '@interfaces/graph-config.interface'; import { CdsService } from '@services/cds/cds.service'; +import { GraphService } from '@services/graph/graph.service'; +import { TeachingService } from '@services/teachings/teachings.service'; import { Graph } from '@shared-ui/graph/graph'; import { IconComponent } from '@shared-ui/icon/icon'; import { Loader } from '@shared-ui/loader/loader'; +import { SelectComponent } from '@shared-ui/select/select'; +import { typedKeys } from '@utils/object-helpers.utils'; +import { GraphResolvers, SelectorResolvers } from '@values/graph-resolvers/graph-resolvers.value'; +import { GRAPH_DATA } from '@values/messages.value'; +import { AcademicYear } from '@values/years'; @Component({ selector: 'opis-cds-selected-section', - imports: [IconComponent, Loader, Graph], + imports: [IconComponent, Loader, Graph, SelectComponent], templateUrl: './cds-selected-section.html', styleUrl: './cds-selected-section.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class CdsSelectedSection { private readonly _cdsService = inject(CdsService); + private readonly _graphService = inject(GraphService); + private readonly _teachingService = inject(TeachingService); + + private readonly _graphDescrRef = viewChild('graphDesc'); + + protected readonly minHeight = signal(0); + protected readonly ERR_STATUS: ResourceStatus = 'error'; + protected readonly BASE_ERROR_MSG = 'Dati non disponibili :/'; + + protected readonly cds = computed(this._cdsService.cdsSelected); + + protected readonly infoCds = this._cdsService.getInfoCds; + protected readonly graphSelected = this._graphService.manageGraphSelection; + protected readonly infoTeaching = this._teachingService.getTeachingGraph(); + + protected readonly isAllInfoLoading = this._cdsService.isLoading; + + constructor() { + this.resetTeachingGraph(); + this.trackMinHeight(); + } + + protected readonly msgError = computed(() => { + const graphKey = this._graphService.graphKeySelected(); + const msg = GRAPH_DATA[graphKey]; + + if (!this.activeGraph() && msg) { + return msg; + } + + return this.BASE_ERROR_MSG; + }); + + protected readonly availableYears = computed(() => { + const courses = this.infoCds.value()?.courses; + return courses ? (typedKeys(courses) as AcademicYear[]) : []; + }); + + private readonly _graphResolvers = GraphResolvers(this.infoCds, this.infoTeaching); + protected readonly activeGraph = computed(() => { + const graphKey = this._graphService.graphKeySelected(); + return this._graphResolvers[graphKey]?.() || null; + }); + + private readonly _selectorResolvers = SelectorResolvers(this.infoCds, this.availableYears); + protected readonly selectorOptions = computed(() => { + const graph = this.graphSelected.value(); + if (!graph?.value || graph.value === GraphSelection.CDS_GENERAL) return null; + + return this._selectorResolvers[graph.value]?.() ?? null; + }); + + // TODO: enhancement + protected onSelectorChange(option: SelectOption): void { + const graphKey = this._graphService.graphKeySelected(); + + if (graphKey === 'teaching_cds') { + const teaching = this.infoCds.value()?.teachings.find((t) => t.id === option.value) ?? null; + this._teachingService.selectedTeaching.set(teaching); + } + // if (graphKey === 'cds_year') { + // this._graphService.selectedYear.set(option.value as AcademicYear); + // } + } + + private resetTeachingGraph(): EffectRef { + return effect(() => { + if (this._graphService.graphKeySelected() !== 'teaching_cds') { + this._teachingService.selectedTeaching.set(null); + } + }); + } + + private trackMinHeight(): EffectRef { + return effect(() => { + const el = this._graphDescrRef()?.nativeElement; + if (!el) return; + + const observer = new ResizeObserver(([entry]) => { + const h = entry.contentRect.height; + if (h > this.minHeight()) this.minHeight.set(h); + }); - protected readonly cds = computed(() => this._cdsService.cdsSelected()); - protected readonly infoCds = this._cdsService.getInfoCds(); + observer.observe(el); + return () => observer.disconnect(); + }); + } } diff --git a/src/app/ui/shared/graph/graph.scss b/src/app/ui/shared/graph/graph.scss index 3651c161..2e8a54f7 100644 --- a/src/app/ui/shared/graph/graph.scss +++ b/src/app/ui/shared/graph/graph.scss @@ -3,9 +3,4 @@ margin: 1rem auto; display: flex; justify-content: center; - - @media (min-width: 1300px) { - width: 60%; - margin: 1.5rem auto; - } } diff --git a/src/app/ui/shared/graph/graph.ts b/src/app/ui/shared/graph/graph.ts index d2fb2c38..b0c03544 100644 --- a/src/app/ui/shared/graph/graph.ts +++ b/src/app/ui/shared/graph/graph.ts @@ -76,7 +76,9 @@ export class Graph implements OnInit { // We use these empty structures as placeholders for dynamic theming. scales: { x: {}, - y: {}, + y: { + beginAtZero: true, + }, }, plugins: { legend: { diff --git a/src/app/ui/shared/icon/icon.component.html b/src/app/ui/shared/icon/icon.component.html index c5422a35..9ff12f6a 100644 --- a/src/app/ui/shared/icon/icon.component.html +++ b/src/app/ui/shared/icon/icon.component.html @@ -1,7 +1,10 @@ @if (safeSvgIcon()) { } @else { - + {{ iconName() }} } diff --git a/src/app/ui/shared/icon/icon.component.scss b/src/app/ui/shared/icon/icon.component.scss index ef374b7c..6a6547f4 100644 --- a/src/app/ui/shared/icon/icon.component.scss +++ b/src/app/ui/shared/icon/icon.component.scss @@ -1,5 +1,7 @@ .opis-icon { - display: inline-flex; + display: flex; + align-items: center; + justify-content: center; line-height: 1; &.size-1-5rem { @@ -33,9 +35,16 @@ &.size-5rem { font-size: 5rem; } + + &.size-responsive { + @media (max-width: 700px) { + &.size-2rem { + font-size: 1.7rem; + } + } + } } -/* QUESTO È IL FIX */ :host ::ng-deep svg { width: 1em; height: 1em; diff --git a/src/app/ui/shared/icon/icon.ts b/src/app/ui/shared/icon/icon.ts index 2ba90289..1ac0be32 100644 --- a/src/app/ui/shared/icon/icon.ts +++ b/src/app/ui/shared/icon/icon.ts @@ -13,6 +13,7 @@ export class IconComponent { readonly iconName = input(); readonly dimension = input('1-5rem'); + readonly isResponsive = input(false); readonly svgIcon = input(); protected readonly safeSvgIcon = computed(() => { diff --git a/src/app/ui/shared/select/select.html b/src/app/ui/shared/select/select.html new file mode 100644 index 00000000..3109b1b7 --- /dev/null +++ b/src/app/ui/shared/select/select.html @@ -0,0 +1,41 @@ +
+ + + @if (isOpen()) { +
+ + +
    + @for (option of filteredOptions(); track option.value) { +
  • + {{ option.label }} +
  • + } @empty { +
  • Nessun risultato
  • + } +
+
+ } +
diff --git a/src/app/ui/shared/select/select.scss b/src/app/ui/shared/select/select.scss new file mode 100644 index 00000000..452c03b8 --- /dev/null +++ b/src/app/ui/shared/select/select.scss @@ -0,0 +1,143 @@ +.opis-select { + position: relative; + width: 100%; + + // ─── Trigger ─────────────────────────────────────────────────────────────── + &__trigger { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.6rem 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text); + font-family: var(--font-sans); + font-size: 0.9rem; + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s; + + &:hover { + border-color: var(--color-border-hover); + } + + &:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + .placeholder { + color: var(--color-text-muted); + font-style: italic; + } + } + + &.open &__trigger { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(0, 105, 148, 0.12); + } + + // ─── Arrow ───────────────────────────────────────────────────────────────── + &__arrow { + font-size: 1.2rem; + color: var(--color-text-muted); + transition: transform 0.2s; + flex-shrink: 0; + line-height: 1; + } + + &.open &__arrow { + transform: rotate(180deg); + } + + // ─── Dropdown ────────────────────────────────────────────────────────────── + &__dropdown { + position: absolute; + left: 0; + right: 0; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + z-index: 100; + overflow: hidden; + + top: calc(100% + 4px); + + .opis-select.open-up & { + top: auto; + bottom: calc(100% + 4px); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12); + } + } + + // ─── Search ──────────────────────────────────────────────────────────────── + &__search { + padding: 0.5rem; + border-bottom: 1px solid var(--color-border); + } + + &__search-input { + width: 100%; + padding: 0.4rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 4px; + font-family: var(--font-sans); + font-size: 0.85rem; + color: var(--color-text); + background: var(--gray-100); + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; + + &:focus { + border-color: var(--color-primary); + } + + &::placeholder { + color: var(--color-text-muted); + } + } + + // ─── Options list ────────────────────────────────────────────────────────── + &__options { + list-style: none; + margin: 0; + padding: 0.3rem 0; + max-height: 200px; + overflow-y: auto; + } + + // ─── Option ──────────────────────────────────────────────────────────────── + &__option { + padding: 0.55rem 1rem; + font-size: 0.9rem; + color: var(--color-text); + cursor: pointer; + transition: background 0.15s; + + &:hover { + background: var(--blue-100); + color: var(--color-primary); + } + + &.selected { + background: var(--blue-100); + color: var(--color-primary); + font-weight: 600; + } + } + + // ─── Empty state ─────────────────────────────────────────────────────────── + &__empty { + padding: 0.75rem 1rem; + font-size: 0.875rem; + color: var(--color-text-muted); + font-style: italic; + text-align: center; + } +} diff --git a/src/app/ui/shared/select/select.spec.ts b/src/app/ui/shared/select/select.spec.ts new file mode 100644 index 00000000..507e160e --- /dev/null +++ b/src/app/ui/shared/select/select.spec.ts @@ -0,0 +1,268 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SelectOption } from '@interfaces/graph-config.interface'; +import { IconComponent } from '@shared-ui/icon/icon'; +import { describe, expect, it, vi } from 'vitest'; +import { SelectComponent } from './select'; + +// ─── Mock ───────────────────────────────────────────────────────────────────── +@Component({ + selector: 'opis-icon', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class MockIconComponent {} + +@Component({ + selector: 'opis-host', + imports: [SelectComponent], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class HostComponent { + readonly options = signal([ + { value: 'v1', label: 'Option One' }, + { value: 'v2', label: 'Option Two' }, + { value: 'v3', label: 'Option Three' }, + ]); + readonly placeholder = signal('Select...'); + value: SelectOption | null = null; + changedSpy = vi.fn(); + onChanged(opt: SelectOption) { + this.changedSpy(opt); + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── +const getTrigger = (f: ComponentFixture): HTMLButtonElement => + f.nativeElement.querySelector('.opis-select__trigger'); + +const getDropdown = (f: ComponentFixture): HTMLElement | null => + f.nativeElement.querySelector('.opis-select__dropdown'); + +const getOptions = (f: ComponentFixture): HTMLElement[] => + Array.from(f.nativeElement.querySelectorAll('.opis-select__option')); + +const getSearchInput = (f: ComponentFixture): HTMLInputElement | null => + f.nativeElement.querySelector('.opis-select__search-input'); + +const openDropdown = (f: ComponentFixture): void => { + getTrigger(f).click(); + f.detectChanges(); +}; + +// ─── Suite ──────────────────────────────────────────────────────────────────── +describe('SelectComponent', () => { + let fixture: ComponentFixture; + let host: HostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }) + .overrideComponent(SelectComponent, { + remove: { imports: [IconComponent] }, + add: { imports: [MockIconComponent] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(HostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + }); + + // ── Initial rendering ─────────────────────────────────────────────────────── + it('[SELECT]: Created', () => expect(fixture.componentInstance).toBeTruthy()); + + it('[SELECT]: should render the trigger button', () => expect(getTrigger(fixture)).toBeTruthy()); + + it('[SELECT]: should show placeholder when no value is selected', () => { + const span = getTrigger(fixture).querySelector('span'); + expect(span?.textContent?.trim()).toBe('Select...'); + expect(span?.classList).toContain('placeholder'); + }); + + it('[SELECT]: should reflect a custom placeholder', () => { + host.placeholder.set('Choose a course...'); + fixture.detectChanges(); + expect(getTrigger(fixture).querySelector('span')?.textContent?.trim()).toBe( + 'Choose a course...', + ); + }); + + it('[SELECT]: should not render the dropdown initially', () => + expect(getDropdown(fixture)).toBeNull()); + + // ── Toggle ────────────────────────────────────────────────────────────────── + it('[SELECT]: should open the dropdown on trigger click', () => { + openDropdown(fixture); + expect(getDropdown(fixture)).toBeTruthy(); + }); + + it('[SELECT]: should add "open" class to the wrapper when open', () => { + openDropdown(fixture); + expect(fixture.nativeElement.querySelector('.opis-select').classList).toContain('open'); + }); + + it('[SELECT]: should close the dropdown on second click', () => { + openDropdown(fixture); + getTrigger(fixture).click(); + fixture.detectChanges(); + expect(getDropdown(fixture)).toBeNull(); + }); + + it('[SELECT]: should reset search query on reopen', () => { + openDropdown(fixture); + const input = getSearchInput(fixture)!; + input.value = 'test'; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + + getTrigger(fixture).click(); + fixture.detectChanges(); + getTrigger(fixture).click(); + fixture.detectChanges(); + + expect(getSearchInput(fixture)?.value).toBe(''); + }); + + // ── Options ───────────────────────────────────────────────────────────────── + it('[SELECT]: should render all options', () => { + openDropdown(fixture); + expect(getOptions(fixture).length).toBe(3); + }); + + it('[SELECT]: should display correct option labels', () => { + openDropdown(fixture); + const labels = getOptions(fixture).map((o) => o.textContent?.trim()); + expect(labels).toEqual(['Option One', 'Option Two', 'Option Three']); + }); + + it('[SELECT]: should select an option on click and show its label', () => { + openDropdown(fixture); + getOptions(fixture)[1].click(); + fixture.detectChanges(); + const span = getTrigger(fixture).querySelector('span'); + expect(span?.textContent?.trim()).toBe('Option Two'); + expect(span?.classList).not.toContain('placeholder'); + }); + + it('[SELECT]: should close the dropdown after selection', () => { + openDropdown(fixture); + getOptions(fixture)[0].click(); + fixture.detectChanges(); + expect(getDropdown(fixture)).toBeNull(); + }); + + it('[SELECT]: should emit "changed" with the selected option', () => { + openDropdown(fixture); + getOptions(fixture)[2].click(); + fixture.detectChanges(); + expect(host.changedSpy).toHaveBeenCalledWith({ value: 'v3', label: 'Option Three' }); + }); + + it('[SELECT]: should apply "selected" class to the active option', () => { + openDropdown(fixture); + getOptions(fixture)[0].click(); + fixture.detectChanges(); + openDropdown(fixture); + expect(getOptions(fixture)[0].classList).toContain('selected'); + expect(getOptions(fixture)[1].classList).not.toContain('selected'); + }); + + it('[SELECT]: should render the search input', () => { + openDropdown(fixture); + expect(getSearchInput(fixture)).toBeTruthy(); + }); + + it('[SELECT]: should filter options by search query', async () => { + openDropdown(fixture); + const input = getSearchInput(fixture)!; + input.value = 'two'; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.detectChanges(); + + const options = getOptions(fixture); + expect(options.length).toBe(1); + expect(options[0].textContent?.trim()).toBe('Option Two'); + }); + + it('[SELECT]: should filter options case-insensitively', async () => { + openDropdown(fixture); + const input = getSearchInput(fixture)!; + input.value = 'ONE'; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(getOptions(fixture).length).toBe(1); + }); + + it('[SELECT]: should show "Nessun risultato" when no options match', async () => { + openDropdown(fixture); + const input = getSearchInput(fixture)!; + input.value = 'zzz'; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.opis-select__empty')?.textContent?.trim()).toBe( + 'Nessun risultato', + ); + expect(getOptions(fixture).length).toBe(0); + }); + + it('[SELECT]: should restore all options when query is cleared', async () => { + openDropdown(fixture); + const input = getSearchInput(fixture)!; + input.value = 'one'; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.detectChanges(); + + input.value = ''; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(getOptions(fixture).length).toBe(3); + }); + + it('[SELECT]: should close the dropdown on outside click', () => { + openDropdown(fixture); + document.body.click(); + fixture.detectChanges(); + expect(getDropdown(fixture)).toBeNull(); + }); + + it('[SELECT]: should not close the dropdown on inside click', () => { + openDropdown(fixture); + fixture.nativeElement.querySelector('.opis-select').click(); + fixture.detectChanges(); + expect(getDropdown(fixture)).toBeTruthy(); + }); + + it('[SELECT]: should add "open-up" class when space below is insufficient', () => { + const hostEl = fixture.nativeElement.querySelector('opis-select') as HTMLElement; + vi.spyOn(hostEl, 'getBoundingClientRect').mockReturnValue({ + bottom: window.innerHeight - 50, + } as DOMRect); + openDropdown(fixture); + const wrapper = fixture.nativeElement.querySelector('.opis-select'); + expect(wrapper.classList).toContain('open-up'); + }); + + it('[SELECT]: should not add "open-up" class when space below is sufficient', () => { + const hostEl = fixture.nativeElement.querySelector('opis-select') as HTMLElement; + vi.spyOn(hostEl, 'getBoundingClientRect').mockReturnValue({ bottom: 100 } as DOMRect); + openDropdown(fixture); + const wrapper = fixture.nativeElement.querySelector('.opis-select'); + expect(wrapper.classList).not.toContain('open-up'); + }); +}); diff --git a/src/app/ui/shared/select/select.ts b/src/app/ui/shared/select/select.ts new file mode 100644 index 00000000..420d0cb7 --- /dev/null +++ b/src/app/ui/shared/select/select.ts @@ -0,0 +1,75 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + input, + model, + output, + signal, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SelectOption } from '@interfaces/graph-config.interface'; +import { IconComponent } from '@shared-ui/icon/icon'; + +const DROPDOWN_HEIGHT = 240; + +@Component({ + selector: 'opis-select', + imports: [FormsModule, IconComponent], + templateUrl: './select.html', + styleUrl: './select.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '(document:click)': 'onOutsideClick($event)', + }, +}) +export class SelectComponent { + private readonly _el = inject(ElementRef); + + readonly options = input.required(); + readonly placeholder = input('Seleziona...'); + readonly searchPlaceholder = input('Cerca...'); + readonly value = model(null); + readonly changed = output(); + + protected readonly isOpen = signal(false); + protected readonly openUp = signal(false); + protected readonly searchQuery = signal(''); + + protected readonly selectedLabel = computed(() => this.value()?.label ?? this.placeholder()); + + protected readonly filteredOptions = computed(() => { + const query = this.searchQuery().toLowerCase().trim(); + if (!query) return this.options(); + return this.options().filter((o) => o.label.toLowerCase().includes(query)); + }); + + protected toggle(): void { + if (!this.isOpen()) { + this._checkDirection(); + this.searchQuery.set(''); + } + this.isOpen.update((v) => !v); + } + + protected select(option: SelectOption): void { + this.value.set(option); + this.changed.emit(option); + this.isOpen.set(false); + this.searchQuery.set(''); + } + + protected onOutsideClick(event: MouseEvent): void { + if (!this._el.nativeElement.contains(event.target)) { + this.isOpen.set(false); + } + } + + private _checkDirection(): void { + const rect = (this._el.nativeElement as HTMLElement).getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + this.openUp.set(spaceBelow < DROPDOWN_HEIGHT); + } +} diff --git a/src/app/utils/statistics.utils/statistics.utils.spec.ts b/src/app/utils/statistics.utils/statistics.utils.spec.ts new file mode 100644 index 00000000..516553de --- /dev/null +++ b/src/app/utils/statistics.utils/statistics.utils.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { mean, round, std, variance } from './statistics.utils'; + +describe('mean', () => { + it('should return 0 for empty array', () => expect(mean([])).toBe(0)); + it('should return the value itself for single element', () => expect(mean([5])).toBe(5)); + it('should compute the mean correctly', () => expect(mean([1, 2, 3, 4, 5])).toBe(3)); + it('should handle negative values', () => expect(mean([-1, -2, -3])).toBe(-2)); +}); + +describe('variance', () => { + it('should return 0 for empty array', () => expect(variance([])).toBe(0)); + it('should return 0 for single element', () => expect(variance([5])).toBe(0)); + it('should compute variance correctly', () => expect(variance([2, 4, 4, 4, 5, 5, 7, 9])).toBe(4)); + it('should return 0 when all values are equal', () => expect(variance([3, 3, 3])).toBe(0)); +}); + +describe('std', () => { + it('should return 0 for empty array', () => expect(std([])).toBe(0)); + it('should return 0 when all values are equal', () => expect(std([3, 3, 3])).toBe(0)); + it('should compute std correctly', () => expect(std([2, 4, 4, 4, 5, 5, 7, 9])).toBe(2)); +}); + +describe('round', () => { + it('should round to 2 decimal places by default', () => expect(round(1.2345)).toBe(1.23)); + it('should round to specified decimal places', () => expect(round(1.2345, 3)).toBe(1.235)); + it('should round to 0 decimal places', () => expect(round(1.6, 0)).toBe(2)); + it('should handle negative values', () => expect(round(-1.2345)).toBe(-1.23)); + it('should handle already rounded values', () => expect(round(1.23)).toBe(1.23)); +}); diff --git a/src/app/utils/statistics.utils.ts b/src/app/utils/statistics.utils/statistics.utils.ts similarity index 100% rename from src/app/utils/statistics.utils.ts rename to src/app/utils/statistics.utils/statistics.utils.ts diff --git a/src/app/utils/strings.utils.ts b/src/app/utils/strings.utils.ts new file mode 100644 index 00000000..144f2a6c --- /dev/null +++ b/src/app/utils/strings.utils.ts @@ -0,0 +1,3 @@ +export function slug(value: string): string { + return value.toLowerCase().replace(/\s+/g, '_'); +} diff --git a/src/app/utils/utils.spec.ts b/src/app/utils/utils.spec.ts new file mode 100644 index 00000000..c5a7dc80 --- /dev/null +++ b/src/app/utils/utils.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { typedKeys } from './object-helpers.utils'; +import { slug } from './strings.utils'; + +describe('typedKeys', () => { + it('should return keys of an object', () => + expect(typedKeys({ a: 1, b: 2 })).toEqual(['a', 'b'])); + it('should return empty array for empty object', () => expect(typedKeys({})).toEqual([])); + it('should preserve key types', () => expect(typedKeys({ foo: 1, bar: 2 })).toContain('foo')); +}); + +describe('slug', () => { + it('should lowercase the string', () => expect(slug('Hello')).toBe('hello')); + it('should replace spaces with underscores', () => + expect(slug('hello world')).toBe('hello_world')); + it('should handle multiple spaces', () => expect(slug('hello world')).toBe('hello_world')); + it('should handle already slugified string', () => + expect(slug('hello_world')).toBe('hello_world')); +}); diff --git a/src/app/values/disclaimers.value.ts b/src/app/values/disclaimers.value.ts new file mode 100644 index 00000000..b592e6c3 --- /dev/null +++ b/src/app/values/disclaimers.value.ts @@ -0,0 +1,100 @@ +import { DisclaimerInfo } from '@interfaces/graph-config.interface'; + +export const OpisGroup_Disclaimers: DisclaimerInfo[] = [ + { + title: 'V1 – Come lo studente vede il corso', + description: ` +

Il punteggio è calcolato sulla base di 2 domande con i seguenti pesi:

+ + + + + + + + + + + + + + + + + +
DomandaPeso
[1] Le conoscenze preliminari possedute sono risultate sufficienti per la comprensione degli argomenti previsti nel programma d'esame?0.7
[2] Il carico di studio dell'insegnamento è proporzionato ai crediti assegnati?0.3
+ `, + type: 'info', + icon: 'info', + isAccordion: true, + isOpen: false, + }, + { + title: 'V2 – Come lo studente vede il docente', + description: ` +

Il punteggio è calcolato sulla base di 4 domande con i seguenti pesi:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
DomandaPeso
[4] Le modalità di esame sono state definite in modo chiaro?0.1
[5] Gli orari di svolgimento di lezioni, esercitazioni e altre eventuali attività didattiche sono rispettati?0.3
[9] L'insegnamento è stato svolto in maniera coerente con quanto dichiarato sul sito web del corso di studio?0.3
[10] Il docente è reperibile per chiarimenti e spiegazioni?0.3
+ `, + type: 'info', + icon: 'info', + isAccordion: true, + isOpen: false, + }, + { + title: 'V3 – Come il docente interagisce con lo studente', + description: ` +

Il punteggio è calcolato sulla base di 3 domande con i seguenti pesi:

+ + + + + + + + + + + + + + + + + + + + + +
DomandaPeso
[3] Il materiale didattico (indicato e disponibile) è adeguato per lo studio della materia?0.1
[6] Il docente stimola/motiva l'interesse verso la disciplina?0.5
[7] Il docente espone gli argomenti in modo chiaro?0.4
+ `, + type: 'info', + icon: 'info', + isAccordion: true, + isOpen: false, + }, +]; diff --git a/src/app/values/graph-resolvers/graph-resolvers.value.spec.ts b/src/app/values/graph-resolvers/graph-resolvers.value.spec.ts new file mode 100644 index 00000000..2ec4bbfb --- /dev/null +++ b/src/app/values/graph-resolvers/graph-resolvers.value.spec.ts @@ -0,0 +1,87 @@ +import { signal } from '@angular/core'; +import { describe, it, expect, vi } from 'vitest'; +import { GraphResolvers, SelectorResolvers } from './graph-resolvers.value'; +import { GraphMapper } from '@mappers/graph.mapper'; + +// ─── Mock factory ───────────────────────────────────────────────────────────── +const mockResource = (value: unknown = null) => ({ + value: signal(value), +}); + +// ─── GraphResolvers ─────────────────────────────────────────────────────────── +describe('GraphResolvers', () => { + it('cds_general: should return null when infoCds has no value', () => { + const resolvers = GraphResolvers(mockResource() as any, mockResource() as any); + expect(resolvers.cds_general()).toBeNull(); + }); + + it('cds_general: should call GraphMapper.toCdsGeneralGraph when data is available', () => { + vi.spyOn(GraphMapper, 'toCdsGeneralGraph').mockReturnValue({} as any); + + const courses = { '2023/2024': [] }; + const infoCds = mockResource({ teachings: [], courses }); + const resolvers = GraphResolvers(infoCds as any, mockResource() as any); + + resolvers.cds_general(); + + expect(GraphMapper.toCdsGeneralGraph).toHaveBeenCalledWith(courses); + }); + + it('teaching_cds: should return null when infoTeaching has no value', () => { + const resolvers = GraphResolvers(mockResource() as any, mockResource() as any); + expect(resolvers.teaching_cds()).toBeNull(); + }); + + it('teaching_cds: should call GraphMapper.toTeachingGraph when data is available', () => { + vi.spyOn(GraphMapper, 'toTeachingGraph').mockReturnValue({} as any); + + const teachingData = { id: 1 }; + const resolvers = GraphResolvers(mockResource() as any, mockResource(teachingData) as any); + + resolvers.teaching_cds(); + + expect(GraphMapper.toTeachingGraph).toHaveBeenCalledWith(teachingData); + }); + + it('cds_year: should throw not implemented error', () => { + const resolvers = GraphResolvers(mockResource() as any, mockResource() as any); + expect(resolvers.cds_year()).toBe(null); + }); +}); + +// ─── SelectorResolvers ──────────────────────────────────────────────────────── +describe('SelectorResolvers', () => { + it('teaching_cds: should return empty array when infoCds has no value', () => { + const resolvers = SelectorResolvers(mockResource() as any, signal([])); + expect(resolvers.teaching_cds()).toEqual([]); + }); + + it('teaching_cds: should map teachings to SelectOption[]', () => { + const teachings = [ + { id: 1, nome: 'Matematica', canale: 'A - L' }, + { id: 2, nome: 'Fisica', canale: 'no' }, + ]; + const infoCds = mockResource({ teachings, courses: {} }); + const resolvers = SelectorResolvers(infoCds as any, signal([])); + + expect(resolvers.teaching_cds()).toEqual([ + { value: 1, label: 'Matematica (Canale A - L)' }, + { value: 2, label: 'Fisica (Canale no)' }, + ]); + }); + + it('cds_year: should return empty array when no years available', () => { + const resolvers = SelectorResolvers(mockResource() as any, signal([])); + expect(resolvers.cds_year()).toEqual([]); + }); + + it('cds_year: should map years to SelectOption[]', () => { + const years = ['2022/2023', '2023/2024'] as any; + const resolvers = SelectorResolvers(mockResource() as any, signal(years)); + + expect(resolvers.cds_year()).toEqual([ + { value: '2022/2023', label: '2022/2023' }, + { value: '2023/2024', label: '2023/2024' }, + ]); + }); +}); diff --git a/src/app/values/graph-resolvers/graph-resolvers.value.ts b/src/app/values/graph-resolvers/graph-resolvers.value.ts new file mode 100644 index 00000000..a2aed31c --- /dev/null +++ b/src/app/values/graph-resolvers/graph-resolvers.value.ts @@ -0,0 +1,41 @@ +import { Signal } from '@angular/core'; +import { GraphSelectionType } from '@enums/chart-typology.enum'; +import { GraphView, SelectOption } from '@interfaces/graph-config.interface'; +import { GraphMapper } from '@mappers/graph.mapper'; +import { CdsService } from '@services/cds/cds.service'; +import { TeachingService } from '@services/teachings/teachings.service'; +import { AcademicYear } from '../years'; + +export function GraphResolvers( + infoCds: CdsService['getInfoCds'], + infoTeaching: ReturnType, +): Record GraphView | null> { + return { + cds_general: () => { + const data = infoCds.value(); + return data ? GraphMapper.toCdsGeneralGraph(data.courses) : null; + }, + teaching_cds: () => { + const data = infoTeaching.value(); + return data ? GraphMapper.toTeachingGraph(data) : null; + }, + cds_year: () => { + console.error('Function not implemented.'); + return null; + }, + }; +} + +export function SelectorResolvers( + infoCds: CdsService['getInfoCds'], + availableYears: Signal, +): Record, () => SelectOption[]> { + return { + teaching_cds: () => + infoCds.value()?.teachings.map((t) => ({ + value: t.id, + label: t.nome + ` (Canale ${t.canale})`, + })) ?? [], + cds_year: () => availableYears().map((y) => ({ value: y, label: y })), + }; +} diff --git a/src/app/values/messages.value.ts b/src/app/values/messages.value.ts new file mode 100644 index 00000000..c3bf4224 --- /dev/null +++ b/src/app/values/messages.value.ts @@ -0,0 +1,7 @@ +import { GraphSelectionType } from '@enums/chart-typology.enum'; + +export const GRAPH_DATA: Record = { + cds_general: null, + teaching_cds: 'Seleziona un insegnamento per visualizzare i dati relativi.', + cds_year: '', +}; diff --git a/src/app/values/no-choice-cds.ts b/src/app/values/no-choice-cds.ts index ffaaf0fa..9433be8d 100644 --- a/src/app/values/no-choice-cds.ts +++ b/src/app/values/no-choice-cds.ts @@ -1,8 +1,10 @@ import { CDS } from '@interfaces/cds.interface'; +export const NO_SELECTION_CDS_ID = -1; + export const NO_CHOICE_CDS: CDS = { - id: -1, - unict_id: -1, + id: NO_SELECTION_CDS_ID, + unict_id: NO_SELECTION_CDS_ID, nome: 'Scegli un corso di studi', classe: '**/**', anno_accademico: '', diff --git a/src/app/values/selection-graph.ts b/src/app/values/selection-graph.ts new file mode 100644 index 00000000..8c10a192 --- /dev/null +++ b/src/app/values/selection-graph.ts @@ -0,0 +1,28 @@ +import { GraphSelectionBtn } from '@interfaces/graph-config.interface'; + +export const CHART_BTNS: GraphSelectionBtn[] = [ + { + value: 'cds_general', + description: + 'Andamento storico delle medie V1, V2 e V3 del Corso di Studi, aggregate su tutti gli insegnamenti per anno accademico.', + label: 'Generale', + active: true, + icon: 'show_chart', + }, + { + value: 'teaching_cds', + description: + "Punteggi medi per singolo insegnamento: confronta l'evoluzione di V1, V2 e V3 corso per corso nel tempo.", + label: 'Corsi', + active: false, + icon: 'menu_book', + }, + { + value: 'cds_year', + description: + 'Fotografia di un anno accademico specifico: confronta i punteggi medi di tutti gli insegnamenti nello stesso periodo.', + label: 'Per anno', + active: false, + icon: 'calendar_month', + }, +]; diff --git a/src/styles.scss b/src/styles.scss index e6e659d9..6aaff348 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -2,6 +2,7 @@ @use 'reset'; @use 'palette'; @use 'scrollbar'; +@use 'primitive'; html, body { @@ -25,3 +26,13 @@ body { transition: all 0.3s ease-in-out; } } + +.wrapper { + display: block; + width: 90%; + margin: 0 auto; + + @media (min-width: 1300px) { + width: 70% !important; + } +} diff --git a/src/styles/_palette.scss b/src/styles/_palette.scss index fd3fc5d8..15f1ee93 100644 --- a/src/styles/_palette.scss +++ b/src/styles/_palette.scss @@ -27,6 +27,8 @@ --red-100: #fdebec; /* SAND – warm surfaces */ + --sand-700: #724501; + --sand-600: #be9659; --sand-500: #d9ba8c; --sand-300: #ead6b3; --sand-100: #f5efe6; diff --git a/src/styles/_primitive.scss b/src/styles/_primitive.scss new file mode 100644 index 00000000..97d8b70f --- /dev/null +++ b/src/styles/_primitive.scss @@ -0,0 +1,64 @@ +.btn { + &-nav { + border-bottom: 1px var(--color-primary) solid; + + &-item { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.7rem 1.8rem; + + &.active, + &:hover { + background-color: var(--color-primary); + color: var(--color-surface); + } + + &:disabled { + opacity: 0.5; + pointer-events: none; + } + } + } +} + +.opis-table { + &--groups { + margin-top: 0.8rem; + width: 100%; + border: 1px var(--gray-200) solid; + + thead { + th { + padding: 0.7rem; + border: 1px var(--gray-200) solid; + + &:first-child { + text-align: start; + } + + &:last-child { + text-align: center; + } + } + } + + tbody { + tr { + td { + padding: 0.7rem; + border: 1px var(--gray-200) solid; + + &:first-child { + width: 80%; + } + + &:last-child { + width: 10%; + text-align: center; + } + } + } + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 00ad673a..85f83664 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "@mocks/*": ["src/app/mocks/*"], "@utils/*": ["src/app/utils/*"], "@enums/*": ["src/app/enums/*"], + "@mappers/*": ["src/app/mappers/*"], "@env": ["src/environment"] }, "strict": true,