Preisrechner: Rappenrundung im Backend und Rabatt-Rechner im Web #4
@@ -16,6 +16,13 @@ public static class PriceCalculator
|
||||
throw new ArgumentOutOfRangeException(nameof(discountPercent), "A discount is between 0 and 100 percent.");
|
||||
}
|
||||
|
||||
return price - discountPercent;
|
||||
var raw = price * (100 - discountPercent) / 100m;
|
||||
return RoundToFiveRappen(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rounds a decimal to the nearest 0.05 (Swiss Rappenrundung).</summary>
|
||||
private static decimal RoundToFiveRappen(decimal value)
|
||||
{
|
||||
return Math.Round(value * 20m, MidpointRounding.AwayFromZero) / 20m;
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,25 @@ public sealed class PriceCalculatorTests
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => PriceCalculator.ApplyDiscount(100m, 101m));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Swiss_rappernrundung_rounds_to_nearest_0_05()
|
||||
{
|
||||
// 99.90 * 0.9 = 89.91 → 89.91 * 20 = 1798.2 → round to 1798 → 1798 / 20 = 89.90
|
||||
Assert.Equal(89.90m, PriceCalculator.ApplyDiscount(99.90m, 10m));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rounding_rounds_down_when_cent_remainder_is_below_0_05()
|
||||
{
|
||||
// 1.23 * 0.5 = 0.615 → 0.615 * 20 = 12.3 → round to 12 → 12 / 20 = 0.60
|
||||
Assert.Equal(0.60m, PriceCalculator.ApplyDiscount(1.23m, 50m));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rounding_rounds_up_when_cent_remainder_is_at_0_05()
|
||||
{
|
||||
// 3.37 * 0.5 = 1.685 → 1.685 * 20 = 33.7 → round to 34 → 34 / 20 = 1.70
|
||||
Assert.Equal(1.70m, PriceCalculator.ApplyDiscount(3.37m, 50m));
|
||||
}
|
||||
}
|
||||
@@ -341,4 +341,6 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
<app-discount-calculator></app-discount-calculator>
|
||||
|
||||
<router-outlet />
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { DiscountCalculator } from './discount-calculator/discount-calculator';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterOutlet, DiscountCalculator],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal('web');
|
||||
}
|
||||
}
|
||||
34
web/src/app/discount-calculator/discount-calculator.html
Normal file
34
web/src/app/discount-calculator/discount-calculator.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<div class="calculator">
|
||||
<h2>Rabatt-Rechner</h2>
|
||||
|
||||
<div class="field">
|
||||
<label for="price">Preis (CHF)</label>
|
||||
<input
|
||||
id="price"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.05"
|
||||
placeholder="z.B. 100"
|
||||
(input)="onPriceInput($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="discount">Rabatt (%)</label>
|
||||
<input
|
||||
id="discount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.5"
|
||||
placeholder="z.B. 10"
|
||||
(input)="onDiscountInput($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (discountedPrice !== null) {
|
||||
<div class="result">
|
||||
Endpreis: <strong>{{ formatPrice(discountedPrice) }} CHF</strong>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
47
web/src/app/discount-calculator/discount-calculator.scss
Normal file
47
web/src/app/discount-calculator/discount-calculator.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
:host {
|
||||
display: block;
|
||||
max-width: 400px;
|
||||
margin: 2rem auto;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.calculator {
|
||||
h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #e8f5e9;
|
||||
border-radius: 4px;
|
||||
font-size: 1.1rem;
|
||||
|
||||
strong {
|
||||
color: #2e7d32;
|
||||
}
|
||||
}
|
||||
67
web/src/app/discount-calculator/discount-calculator.spec.ts
Normal file
67
web/src/app/discount-calculator/discount-calculator.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||
import { DiscountCalculator } from './discount-calculator';
|
||||
|
||||
describe('DiscountCalculator', () => {
|
||||
async function createFixture(): Promise<ComponentFixture<DiscountCalculator>> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DiscountCalculator],
|
||||
}).compileComponents();
|
||||
return TestBed.createComponent(DiscountCalculator);
|
||||
}
|
||||
|
||||
it('should create the component', async () => {
|
||||
const fixture = await createFixture();
|
||||
const component = fixture.componentInstance;
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should calculate 100 CHF with 10% discount to 90.00', async () => {
|
||||
const fixture = await createFixture();
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component['price'].set(100);
|
||||
component['discountPercent'].set(10);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.result')?.textContent).toContain('90.00 CHF');
|
||||
});
|
||||
|
||||
it('should round 99.90 CHF with 10% discount to 89.90 (Swiss 0.05 rounding)', async () => {
|
||||
const fixture = await createFixture();
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component['price'].set(99.90);
|
||||
component['discountPercent'].set(10);
|
||||
fixture.detectChanges();
|
||||
|
||||
// raw = 99.90 * 90 / 100 = 89.91
|
||||
// Math.round(89.91 * 20) / 20 = Math.round(1798.2) / 20 = 1798 / 20 = 89.90
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.result')?.textContent).toContain('89.90 CHF');
|
||||
});
|
||||
|
||||
it('should not show result when price is negative', async () => {
|
||||
const fixture = await createFixture();
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component['price'].set(-5);
|
||||
component['discountPercent'].set(10);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.result')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not show result when discount is negative', async () => {
|
||||
const fixture = await createFixture();
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component['price'].set(100);
|
||||
component['discountPercent'].set(-1);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.result')).toBeNull();
|
||||
});
|
||||
});
|
||||
36
web/src/app/discount-calculator/discount-calculator.ts
Normal file
36
web/src/app/discount-calculator/discount-calculator.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-discount-calculator',
|
||||
imports: [],
|
||||
templateUrl: './discount-calculator.html',
|
||||
styleUrl: './discount-calculator.scss'
|
||||
})
|
||||
export class DiscountCalculator {
|
||||
protected readonly price = signal<number | null>(null);
|
||||
protected readonly discountPercent = signal<number | null>(null);
|
||||
|
||||
protected onPriceInput(event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
this.price.set(value === '' ? null : Number(value));
|
||||
}
|
||||
|
||||
protected onDiscountInput(event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
this.discountPercent.set(value === '' ? null : Number(value));
|
||||
}
|
||||
|
||||
protected get discountedPrice(): number | null {
|
||||
const p = this.price();
|
||||
const d = this.discountPercent();
|
||||
if (p === null || d === null || p < 0 || d < 0) {
|
||||
return null;
|
||||
}
|
||||
const raw = p * (100 - d) / 100;
|
||||
return Math.round(raw * 20) / 20;
|
||||
}
|
||||
|
||||
protected formatPrice(value: number): string {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user