Compare commits
1 Commits
agentd/tas
...
agentd/tas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c1147f2a0 |
@@ -16,8 +16,13 @@ public static class PriceCalculator
|
|||||||
throw new ArgumentOutOfRangeException(nameof(discountPercent), "A discount is between 0 and 100 percent.");
|
throw new ArgumentOutOfRangeException(nameof(discountPercent), "A discount is between 0 and 100 percent.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var discounted = price * (1 - discountPercent / 100m);
|
var raw = price * (100 - discountPercent) / 100m;
|
||||||
// Swiss rounding (Rappenrundung): round to the nearest 0.05
|
return RoundToFiveRappen(raw);
|
||||||
return Math.Round(discounted / 0.05m, MidpointRounding.ToEven) * 0.05m;
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,23 +35,23 @@ public sealed class PriceCalculatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ApplyDiscount_99_90_with_10_percent_rounds_to_89_90()
|
public void Swiss_rappernrundung_rounds_to_nearest_0_05()
|
||||||
{
|
{
|
||||||
// 99.90 * (1 - 10/100) = 89.91 → rounded to nearest 0.05 = 89.90
|
// 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));
|
Assert.Equal(89.90m, PriceCalculator.ApplyDiscount(99.90m, 10m));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ApplyDiscount_10_with_33_percent_rounds_correctly()
|
public void Rounding_rounds_down_when_cent_remainder_is_below_0_05()
|
||||||
{
|
{
|
||||||
// 10 * (1 - 33/100) = 6.70 → 6.70 is already a multiple of 0.05
|
// 1.23 * 0.5 = 0.615 → 0.615 * 20 = 12.3 → round to 12 → 12 / 20 = 0.60
|
||||||
Assert.Equal(6.70m, PriceCalculator.ApplyDiscount(10m, 33m));
|
Assert.Equal(0.60m, PriceCalculator.ApplyDiscount(1.23m, 50m));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ApplyDiscount_5_55_with_10_percent_rounds_to_5_00()
|
public void Rounding_rounds_up_when_cent_remainder_is_at_0_05()
|
||||||
{
|
{
|
||||||
// 5.55 * 0.9 = 4.995 → rounded to nearest 0.05 = 5.00
|
// 3.37 * 0.5 = 1.685 → 1.685 * 20 = 33.7 → round to 34 → 34 / 20 = 1.70
|
||||||
Assert.Equal(5.00m, PriceCalculator.ApplyDiscount(5.55m, 10m));
|
Assert.Equal(1.70m, PriceCalculator.ApplyDiscount(3.37m, 50m));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,6 @@
|
|||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>Hello, {{ title() }}</h1>
|
<h1>Hello, {{ title() }}</h1>
|
||||||
<app-discount-calculator />
|
|
||||||
<p>Congratulations! Your app is running. 🎉</p>
|
<p>Congratulations! Your app is running. 🎉</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||||
@@ -342,4 +341,6 @@
|
|||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||||
|
|
||||||
|
|
||||||
|
<app-discount-calculator></app-discount-calculator>
|
||||||
|
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +1,67 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { DiscountCalculator } from './discount-calculator';
|
import { DiscountCalculator } from './discount-calculator';
|
||||||
|
|
||||||
describe('DiscountCalculator', () => {
|
describe('DiscountCalculator', () => {
|
||||||
beforeEach(async () => {
|
async function createFixture(): Promise<ComponentFixture<DiscountCalculator>> {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [DiscountCalculator, FormsModule],
|
imports: [DiscountCalculator],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
});
|
return TestBed.createComponent(DiscountCalculator);
|
||||||
|
}
|
||||||
|
|
||||||
it('should create the component', () => {
|
it('should create the component', async () => {
|
||||||
const fixture = TestBed.createComponent(DiscountCalculator);
|
const fixture = await createFixture();
|
||||||
const component = fixture.componentInstance;
|
const component = fixture.componentInstance;
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display "CHF 89.90" for price=99.90 and discount=10', async () => {
|
it('should calculate 100 CHF with 10% discount to 90.00', async () => {
|
||||||
const fixture = TestBed.createComponent(DiscountCalculator);
|
const fixture = await createFixture();
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component['price'].set(100);
|
||||||
|
component['discountPercent'].set(10);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const priceInput = fixture.nativeElement.querySelector('#price') as HTMLInputElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
const discountInput = fixture.nativeElement.querySelector('#discount') as HTMLInputElement;
|
expect(compiled.querySelector('.result')?.textContent).toContain('90.00 CHF');
|
||||||
|
|
||||||
priceInput.value = '99.90';
|
|
||||||
priceInput.dispatchEvent(new Event('input'));
|
|
||||||
discountInput.value = '10';
|
|
||||||
discountInput.dispatchEvent(new Event('input'));
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
|
|
||||||
const resultEl = fixture.nativeElement.querySelector('.result-value') as HTMLElement;
|
|
||||||
expect(resultEl.textContent?.trim()).toBe('CHF 89.90');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display "CHF 100.00" for price=100 and discount=0', async () => {
|
it('should round 99.90 CHF with 10% discount to 89.90 (Swiss 0.05 rounding)', async () => {
|
||||||
const fixture = TestBed.createComponent(DiscountCalculator);
|
const fixture = await createFixture();
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component['price'].set(99.90);
|
||||||
|
component['discountPercent'].set(10);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const priceInput = fixture.nativeElement.querySelector('#price') as HTMLInputElement;
|
// raw = 99.90 * 90 / 100 = 89.91
|
||||||
const discountInput = fixture.nativeElement.querySelector('#discount') as HTMLInputElement;
|
// Math.round(89.91 * 20) / 20 = Math.round(1798.2) / 20 = 1798 / 20 = 89.90
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
priceInput.value = '100';
|
expect(compiled.querySelector('.result')?.textContent).toContain('89.90 CHF');
|
||||||
priceInput.dispatchEvent(new Event('input'));
|
|
||||||
discountInput.value = '0';
|
|
||||||
discountInput.dispatchEvent(new Event('input'));
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
await fixture.whenStable();
|
|
||||||
|
|
||||||
const resultEl = fixture.nativeElement.querySelector('.result-value') as HTMLElement;
|
|
||||||
expect(resultEl.textContent?.trim()).toBe('CHF 100.00');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display "CHF 150.00" for price=200 and discount=25', async () => {
|
it('should not show result when price is negative', async () => {
|
||||||
const fixture = TestBed.createComponent(DiscountCalculator);
|
const fixture = await createFixture();
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component['price'].set(-5);
|
||||||
|
component['discountPercent'].set(10);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const priceInput = fixture.nativeElement.querySelector('#price') as HTMLInputElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
const discountInput = fixture.nativeElement.querySelector('#discount') as HTMLInputElement;
|
expect(compiled.querySelector('.result')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
priceInput.value = '200';
|
it('should not show result when discount is negative', async () => {
|
||||||
priceInput.dispatchEvent(new Event('input'));
|
const fixture = await createFixture();
|
||||||
discountInput.value = '25';
|
const component = fixture.componentInstance;
|
||||||
discountInput.dispatchEvent(new Event('input'));
|
|
||||||
|
|
||||||
|
component['price'].set(100);
|
||||||
|
component['discountPercent'].set(-1);
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
|
||||||
|
|
||||||
const resultEl = fixture.nativeElement.querySelector('.result-value') as HTMLElement;
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
expect(resultEl.textContent?.trim()).toBe('CHF 150.00');
|
expect(compiled.querySelector('.result')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,135 +1,36 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-discount-calculator',
|
selector: 'app-discount-calculator',
|
||||||
standalone: true,
|
imports: [],
|
||||||
imports: [FormsModule],
|
templateUrl: './discount-calculator.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
styleUrl: './discount-calculator.scss'
|
||||||
template: `
|
|
||||||
<div class="card">
|
|
||||||
<h2>Rabatt-Rechner</h2>
|
|
||||||
<div class="field">
|
|
||||||
<label for="price">Preis (CHF)</label>
|
|
||||||
<input
|
|
||||||
id="price"
|
|
||||||
type="number"
|
|
||||||
step="0.05"
|
|
||||||
[ngModel]="priceValue()"
|
|
||||||
(ngModelChange)="priceValue.set($event)"
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="discount">Rabatt (%)</label>
|
|
||||||
<input
|
|
||||||
id="discount"
|
|
||||||
type="number"
|
|
||||||
step="1"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
[ngModel]="discountValue()"
|
|
||||||
(ngModelChange)="discountValue.set($event)"
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="result">
|
|
||||||
<span class="result-label">Endpreis</span>
|
|
||||||
<span class="result-value">{{ displayValue() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
.card {
|
|
||||||
max-width: 320px;
|
|
||||||
margin: 2rem auto;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px solid #d0d0d0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fafafa;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #555;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #e9f5e9;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #2a6b2a;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-value {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a4a1a;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
export class DiscountCalculator {
|
export class DiscountCalculator {
|
||||||
protected readonly priceValue = signal<number | null>(null);
|
protected readonly price = signal<number | null>(null);
|
||||||
protected readonly discountValue = signal<number | null>(null);
|
protected readonly discountPercent = signal<number | null>(null);
|
||||||
|
|
||||||
protected readonly displayValue = computed(() => {
|
protected onPriceInput(event: Event): void {
|
||||||
const price = this.priceValue();
|
const value = (event.target as HTMLInputElement).value;
|
||||||
const discount = this.discountValue();
|
this.price.set(value === '' ? null : Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
if (price === null || discount === null) {
|
protected onDiscountInput(event: Event): void {
|
||||||
return '—';
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
if (isNaN(price) || isNaN(discount)) {
|
protected formatPrice(value: number): string {
|
||||||
return '—';
|
return value.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (price <= 0) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discount < 0 || discount > 100) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
const discounted = price * (1 - discount / 100);
|
|
||||||
const rounded = Math.round(discounted / 0.05) * 0.05;
|
|
||||||
return `CHF ${rounded.toFixed(2)}`;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user