dev #13

Merged
JoeKung merged 23 commits from dev into main 2026-03-03 18:28:30 +01:00
7 changed files with 407 additions and 1 deletions
Showing only changes of commit 9d40e74baf - Show all commits

View File

@@ -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

View File

@@ -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

View File

@@ -107,6 +107,7 @@
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"karmaConfig": "karma.conf.js",
"polyfills": [
"zone.js",
"zone.js/testing"

40
frontend/karma.conf.js Normal file
View File

@@ -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,
});
};

View File

@@ -0,0 +1,106 @@
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<unknown>();
const createUrlTree = (
commands: unknown[],
extras?: { queryParams?: Record<string, string>; 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',
);
});
});

View File

@@ -0,0 +1,112 @@
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>(
'QuoteEstimatorService',
['updateLineItem', 'getQuoteSession', 'mapSessionToQuoteResult'],
);
const router = jasmine.createSpyObj<Router>('Router', ['navigate']);
const route = {
data: of({}),
queryParams: of({}),
} as unknown as ActivatedRoute;
const languageService = jasmine.createSpyObj<LanguageService>(
'LanguageService',
['selectedLang'],
);
const component = new CalculatorPageComponent(
estimator,
router,
route,
languageService,
);
const uploadForm = jasmine.createSpyObj<UploadFormComponent>(
'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);
});
});

View File

@@ -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<QuoteResultComponent>;
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();
});
});