fix(deploy): new test
This commit is contained in:
@@ -28,8 +28,42 @@ jobs:
|
|||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
./gradlew test
|
./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:
|
build-and-push:
|
||||||
needs: test-backend
|
needs: [test-backend, test-frontend]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -136,3 +136,37 @@ jobs:
|
|||||||
cd backend
|
cd backend
|
||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
./gradlew test
|
./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
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
"options": {
|
"options": {
|
||||||
|
"karmaConfig": "karma.conf.js",
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"zone.js",
|
"zone.js",
|
||||||
"zone.js/testing"
|
"zone.js/testing"
|
||||||
|
|||||||
40
frontend/karma.conf.js
Normal file
40
frontend/karma.conf.js
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
110
frontend/src/app/core/services/language.service.spec.ts
Normal file
110
frontend/src/app/core/services/language.service.spec.ts
Normal file
@@ -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<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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>(
|
||||||
|
'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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user