From c66903d22e9d1af6be50ab13d6b2677fb31ee117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 3 Mar 2026 13:39:50 +0100 Subject: [PATCH 1/2] fix(deploy): new test --- .gitea/workflows/deploy.yaml | 36 +++++- .gitea/workflows/pr-checks.yaml | 34 ++++++ frontend/angular.json | 1 + frontend/karma.conf.js | 40 +++++++ .../core/services/language.service.spec.ts | 110 ++++++++++++++++++ .../calculator-page.component.spec.ts | 108 +++++++++++++++++ .../quote-result.component.spec.ts | 79 +++++++++++++ 7 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 frontend/karma.conf.js create mode 100644 frontend/src/app/core/services/language.service.spec.ts create mode 100644 frontend/src/app/features/calculator/calculator-page.component.spec.ts create mode 100644 frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index ebdb49a..b96224a 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -28,8 +28,42 @@ jobs: chmod +x gradlew ./gradlew test + test-frontend: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: "frontend/package-lock.json" + + - name: Install Chromium + shell: bash + run: | + apt-get update + apt-get install -y --no-install-recommends chromium + + - name: Install frontend dependencies + shell: bash + run: | + cd frontend + npm ci --no-audit --no-fund + + - name: Run frontend tests (headless) + shell: bash + env: + CHROME_BIN: /usr/bin/chromium + CI: "true" + run: | + cd frontend + npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox + build-and-push: - needs: test-backend + needs: [test-backend, test-frontend] runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.gitea/workflows/pr-checks.yaml b/.gitea/workflows/pr-checks.yaml index 46d4971..f964f6b 100644 --- a/.gitea/workflows/pr-checks.yaml +++ b/.gitea/workflows/pr-checks.yaml @@ -136,3 +136,37 @@ jobs: cd backend chmod +x gradlew ./gradlew test + + test-frontend: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: "frontend/package-lock.json" + + - name: Install Chromium + shell: bash + run: | + apt-get update + apt-get install -y --no-install-recommends chromium + + - name: Install frontend dependencies + shell: bash + run: | + cd frontend + npm ci --no-audit --no-fund + + - name: Run frontend tests (headless) + shell: bash + env: + CHROME_BIN: /usr/bin/chromium + CI: "true" + run: | + cd frontend + npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox diff --git a/frontend/angular.json b/frontend/angular.json index bc7951c..6107515 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -107,6 +107,7 @@ "test": { "builder": "@angular-devkit/build-angular:karma", "options": { + "karmaConfig": "karma.conf.js", "polyfills": [ "zone.js", "zone.js/testing" diff --git a/frontend/karma.conf.js b/frontend/karma.conf.js new file mode 100644 index 0000000..9c1b695 --- /dev/null +++ b/frontend/karma.conf.js @@ -0,0 +1,40 @@ +// Karma config dedicated to CI-safe Chrome execution. +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + client: { + jasmine: {}, + clearContext: false, + }, + jasmineHtmlReporter: { + suppressAll: true, + }, + coverageReporter: { + dir: require('path').join(__dirname, './coverage/frontend'), + subdir: '.', + reporters: [{ type: 'html' }, { type: 'text-summary' }], + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['ChromeHeadlessNoSandbox'], + customLaunchers: { + ChromeHeadlessNoSandbox: { + base: 'ChromeHeadless', + flags: ['--no-sandbox', '--disable-dev-shm-usage'], + }, + }, + singleRun: false, + restartOnFileChange: true, + }); +}; diff --git a/frontend/src/app/core/services/language.service.spec.ts b/frontend/src/app/core/services/language.service.spec.ts new file mode 100644 index 0000000..4c91f69 --- /dev/null +++ b/frontend/src/app/core/services/language.service.spec.ts @@ -0,0 +1,110 @@ +import { Subject } from 'rxjs'; +import { + DefaultUrlSerializer, + Router, + UrlTree, +} from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { LanguageService } from './language.service'; + +describe('LanguageService', () => { + function createTranslateMock() { + const onLangChange = new Subject<{ lang: string }>(); + const translate = { + currentLang: '', + addLangs: jasmine.createSpy('addLangs'), + setDefaultLang: jasmine.createSpy('setDefaultLang'), + use: jasmine.createSpy('use').and.callFake((lang: string) => { + translate.currentLang = lang; + onLangChange.next({ lang }); + }), + onLangChange, + }; + + return translate as unknown as TranslateService; + } + + function createRouterMock(initialUrl: string) { + const serializer = new DefaultUrlSerializer(); + const events$ = new Subject(); + + const createUrlTree = ( + commands: unknown[], + extras?: { queryParams?: Record; fragment?: string }, + ): UrlTree => { + const segments = commands + .filter((entry) => typeof entry === 'string' && entry !== '/') + .map((entry) => String(entry)); + + let url = `/${segments.join('/')}`; + if (url === '') { + url = '/'; + } + + const queryParams = extras?.queryParams ?? {}; + const query = new URLSearchParams(); + Object.entries(queryParams).forEach(([key, value]) => { + query.set(key, value); + }); + const queryString = query.toString(); + if (queryString) { + url += `?${queryString}`; + } + + if (extras?.fragment) { + url += `#${extras.fragment}`; + } + + return serializer.parse(url); + }; + + const router = { + url: initialUrl, + events: events$.asObservable(), + parseUrl: (url: string) => serializer.parse(url), + createUrlTree, + serializeUrl: (tree: UrlTree) => serializer.serialize(tree), + navigateByUrl: jasmine.createSpy('navigateByUrl'), + }; + + return router as unknown as Router; + } + + it('prefixes URL with default language when missing', () => { + const translate = createTranslateMock(); + const router = createRouterMock('/calculator?session=abc'); + const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const service = new LanguageService(translate, router); + + expect(translate.use).toHaveBeenCalledWith('it'); + expect(navigateSpy).toHaveBeenCalledTimes(1); + + const firstCall = navigateSpy.calls.mostRecent(); + const tree = firstCall.args[0] as UrlTree; + const navOptions = firstCall.args[1] as { replaceUrl: boolean }; + expect(router.serializeUrl(tree)).toBe('/it/calculator?session=abc'); + expect(navOptions.replaceUrl).toBeTrue(); + }); + + it('switches language while preserving path and query params', () => { + const translate = createTranslateMock(); + const router = createRouterMock('/it/calculator?session=abc&mode=advanced'); + const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + const service = new LanguageService(translate, router); + + expect(navigateSpy).not.toHaveBeenCalled(); + + service.switchLang('de'); + + expect(translate.use).toHaveBeenCalledWith('de'); + expect(navigateSpy).toHaveBeenCalledTimes(1); + + const call = navigateSpy.calls.mostRecent(); + const tree = call.args[0] as UrlTree; + expect(router.serializeUrl(tree)).toBe( + '/de/calculator?session=abc&mode=advanced', + ); + }); +}); diff --git a/frontend/src/app/features/calculator/calculator-page.component.spec.ts b/frontend/src/app/features/calculator/calculator-page.component.spec.ts new file mode 100644 index 0000000..a340618 --- /dev/null +++ b/frontend/src/app/features/calculator/calculator-page.component.spec.ts @@ -0,0 +1,108 @@ +import { of } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { CalculatorPageComponent } from './calculator-page.component'; +import { + QuoteEstimatorService, + QuoteResult, +} from './services/quote-estimator.service'; +import { LanguageService } from '../../core/services/language.service'; +import { UploadFormComponent } from './components/upload-form/upload-form.component'; + +describe('CalculatorPageComponent', () => { + const createResult = (sessionId: string, notes?: string): QuoteResult => ({ + sessionId, + items: [ + { + id: 'line-1', + fileName: 'part-a.stl', + unitPrice: 4, + unitTime: 120, + unitWeight: 2, + quantity: 1, + }, + ], + setupCost: 2, + globalMachineCost: 0, + currency: 'CHF', + totalPrice: 6, + totalTimeHours: 0, + totalTimeMinutes: 2, + totalWeight: 2, + notes, + }); + + function createComponent() { + const estimator = jasmine.createSpyObj( + 'QuoteEstimatorService', + ['updateLineItem', 'getQuoteSession', 'mapSessionToQuoteResult'], + ); + const router = jasmine.createSpyObj('Router', ['navigate']); + const route = { + data: of({}), + queryParams: of({}), + } as unknown as ActivatedRoute; + const languageService = jasmine.createSpyObj( + 'LanguageService', + ['selectedLang'], + ); + + const component = new CalculatorPageComponent( + estimator, + router, + route, + languageService, + ); + + const uploadForm = jasmine.createSpyObj( + 'UploadFormComponent', + ['updateItemQuantityByIndex', 'updateItemQuantityByName'], + ); + component.uploadForm = uploadForm; + + return { + component, + estimator, + uploadForm, + }; + } + + it('updates left panel quantities even when item id is missing', () => { + const { component, estimator, uploadForm } = createComponent(); + + component.onItemChange({ + index: 0, + fileName: 'part-a.stl', + quantity: 4, + }); + + expect(uploadForm.updateItemQuantityByIndex).toHaveBeenCalledWith(0, 4); + expect(uploadForm.updateItemQuantityByName).toHaveBeenCalledWith( + 'part-a.stl', + 4, + ); + expect(estimator.updateLineItem).not.toHaveBeenCalled(); + }); + + it('refreshes quote totals after successful line item update', () => { + const { component, estimator } = createComponent(); + component.result.set(createResult('session-1', 'persisted notes')); + + estimator.updateLineItem.and.returnValue(of({ ok: true })); + estimator.getQuoteSession.and.returnValue(of({ session: { id: 'session-1' } })); + estimator.mapSessionToQuoteResult.and.returnValue(createResult('session-1')); + + component.onItemChange({ + id: 'line-1', + index: 0, + fileName: 'part-a.stl', + quantity: 7, + }); + + expect(estimator.updateLineItem).toHaveBeenCalledWith('line-1', { + quantity: 7, + }); + expect(estimator.getQuoteSession).toHaveBeenCalledWith('session-1'); + expect(component.result()?.notes).toBe('persisted notes'); + expect(component.result()?.items[0].quantity).toBe(1); + }); +}); diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts new file mode 100644 index 0000000..5784b3a --- /dev/null +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts @@ -0,0 +1,79 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { QuoteResultComponent } from './quote-result.component'; +import { QuoteResult } from '../../services/quote-estimator.service'; + +describe('QuoteResultComponent', () => { + let fixture: ComponentFixture; + let component: QuoteResultComponent; + + const createResult = (): QuoteResult => ({ + sessionId: 'session-1', + items: [ + { + id: 'line-1', + fileName: 'part-a.stl', + unitPrice: 2, + unitTime: 120, + unitWeight: 1.2, + quantity: 2, + }, + { + id: 'line-2', + fileName: 'part-b.stl', + unitPrice: 1.5, + unitTime: 60, + unitWeight: 0.5, + quantity: 1, + }, + ], + setupCost: 5, + globalMachineCost: 0, + currency: 'CHF', + totalPrice: 0, + totalTimeHours: 0, + totalTimeMinutes: 0, + totalWeight: 0, + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [QuoteResultComponent, TranslateModule.forRoot()], + }).compileComponents(); + + fixture = TestBed.createComponent(QuoteResultComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('result', createResult()); + fixture.detectChanges(); + }); + + it('emits quantity changes with clamped max quantity', () => { + spyOn(component.itemChange, 'emit'); + + component.updateQuantity(0, 999); + component.flushQuantityUpdate(0); + + expect(component.items()[0].quantity).toBe(component.maxInputQuantity); + expect(component.itemChange.emit).toHaveBeenCalledWith({ + id: 'line-1', + index: 0, + fileName: 'part-a.stl', + quantity: component.maxInputQuantity, + }); + }); + + it('computes totals from local item quantities', () => { + component.updateQuantity(1, 3); + + const totals = component.totals(); + expect(totals.price).toBe(13.5); + expect(totals.hours).toBe(0); + expect(totals.minutes).toBe(7); + expect(totals.weight).toBe(4); + }); + + it('flags over-limit quantities for direct order', () => { + component.updateQuantity(0, 101); + expect(component.hasQuantityOverLimit()).toBeTrue(); + }); +}); -- 2.49.1 From f3ea2be8b0ac72f83fdcbd0274236c01943ab431 Mon Sep 17 00:00:00 2001 From: printcalc-ci Date: Tue, 3 Mar 2026 12:41:15 +0000 Subject: [PATCH 2/2] style: apply prettier formatting --- frontend/src/app/core/services/language.service.spec.ts | 6 +----- .../features/calculator/calculator-page.component.spec.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/core/services/language.service.spec.ts b/frontend/src/app/core/services/language.service.spec.ts index 4c91f69..1e45064 100644 --- a/frontend/src/app/core/services/language.service.spec.ts +++ b/frontend/src/app/core/services/language.service.spec.ts @@ -1,9 +1,5 @@ import { Subject } from 'rxjs'; -import { - DefaultUrlSerializer, - Router, - UrlTree, -} from '@angular/router'; +import { DefaultUrlSerializer, Router, UrlTree } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { LanguageService } from './language.service'; diff --git a/frontend/src/app/features/calculator/calculator-page.component.spec.ts b/frontend/src/app/features/calculator/calculator-page.component.spec.ts index a340618..92e9033 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.spec.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.spec.ts @@ -88,8 +88,12 @@ describe('CalculatorPageComponent', () => { component.result.set(createResult('session-1', 'persisted notes')); estimator.updateLineItem.and.returnValue(of({ ok: true })); - estimator.getQuoteSession.and.returnValue(of({ session: { id: 'session-1' } })); - estimator.mapSessionToQuoteResult.and.returnValue(createResult('session-1')); + estimator.getQuoteSession.and.returnValue( + of({ session: { id: 'session-1' } }), + ); + estimator.mapSessionToQuoteResult.and.returnValue( + createResult('session-1'), + ); component.onItemChange({ id: 'line-1', -- 2.49.1