feat(reputation): add the reusable Regional Reputation display component
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
<div class="reputation-display" [attr.aria-label]="entry().factionName + ' Ruf'">
|
||||||
|
<div class="reputation-display__header">
|
||||||
|
<span class="reputation-display__faction">{{ entry().factionName }}</span>
|
||||||
|
<span class="reputation-display__rank">{{ entry().rankLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (entry().nextThreshold !== null) {
|
||||||
|
<div class="reputation-display__progress" role="progressbar"
|
||||||
|
[attr.aria-valuenow]="entry().reputation"
|
||||||
|
[attr.aria-valuemin]="0"
|
||||||
|
[attr.aria-valuemax]="entry().nextThreshold"
|
||||||
|
>
|
||||||
|
<span class="reputation-display__track" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
class="reputation-display__fill"
|
||||||
|
data-reputation-progress-fill
|
||||||
|
[style.inline-size.%]="progressPercent()"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
<span class="reputation-display__value">{{ entry().reputation }} / {{ entry().nextThreshold }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="reputation-display__max" data-reputation-max>{{ entry().reputation }} — Höchster Rang erreicht</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.reputation-display {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--ar-space-2);
|
||||||
|
padding: var(--ar-space-3);
|
||||||
|
border: 1px solid var(--ar-border);
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
background: var(--ar-panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reputation-display__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reputation-display__rank {
|
||||||
|
color: var(--ar-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reputation-display__progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--ar-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reputation-display__track {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
block-size: 0.5rem;
|
||||||
|
border-radius: var(--ar-radius-sm);
|
||||||
|
background: var(--ar-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reputation-display__fill {
|
||||||
|
position: absolute;
|
||||||
|
inset-block: 0;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
background: var(--ar-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reputation-display__value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--ar-text-muted);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reputation-display__max {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ar-gold);
|
||||||
|
font-size: var(--ar-font-sm);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import type { ReputationEntry } from '../../core/api/game-api.models';
|
||||||
|
import { ReputationDisplayComponent } from './reputation-display.component';
|
||||||
|
|
||||||
|
async function setup(entry: ReputationEntry) {
|
||||||
|
await TestBed.configureTestingModule({ imports: [ReputationDisplayComponent] }).compileComponents();
|
||||||
|
const fixture = TestBed.createComponent(ReputationDisplayComponent);
|
||||||
|
fixture.componentRef.setInput('entry', entry);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ReputationDisplayComponent', () => {
|
||||||
|
it('renders the faction name and rank', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
factionKey: 'border-guard',
|
||||||
|
factionName: 'Grenzwacht',
|
||||||
|
reputation: 320,
|
||||||
|
rank: 'KNOWN',
|
||||||
|
rankLabel: 'Bekannt',
|
||||||
|
nextThreshold: 500,
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.textContent).toContain('Grenzwacht');
|
||||||
|
expect(element.textContent).toContain('Bekannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows current reputation against the next threshold', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
factionKey: 'border-guard',
|
||||||
|
factionName: 'Grenzwacht',
|
||||||
|
reputation: 320,
|
||||||
|
rank: 'KNOWN',
|
||||||
|
rankLabel: 'Bekannt',
|
||||||
|
nextThreshold: 500,
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.textContent).toContain('320');
|
||||||
|
expect(element.textContent).toContain('500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a progress fraction between 0 and 100', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
factionKey: 'border-guard',
|
||||||
|
factionName: 'Grenzwacht',
|
||||||
|
reputation: 320,
|
||||||
|
rank: 'KNOWN',
|
||||||
|
rankLabel: 'Bekannt',
|
||||||
|
nextThreshold: 500,
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
const fill = element.querySelector<HTMLElement>('[data-reputation-progress-fill]');
|
||||||
|
const width = parseFloat(fill?.style.inlineSize ?? '0');
|
||||||
|
expect(width).toBeGreaterThan(0);
|
||||||
|
expect(width).toBeLessThanOrEqual(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the top rank with no next threshold without crashing or showing a bogus progress bar', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
factionKey: 'border-guard',
|
||||||
|
factionName: 'Grenzwacht',
|
||||||
|
reputation: 1500,
|
||||||
|
rank: 'ESTEEMED',
|
||||||
|
rankLabel: 'Geachtet',
|
||||||
|
nextThreshold: null,
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.textContent).toContain('Geachtet');
|
||||||
|
expect(element.querySelector('[data-reputation-max]')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a sensible zero-reputation row for a faction never interacted with', async () => {
|
||||||
|
const fixture = await setup({
|
||||||
|
factionKey: 'silent-order',
|
||||||
|
factionName: 'Der Stille Orden',
|
||||||
|
reputation: 0,
|
||||||
|
rank: 'STRANGER',
|
||||||
|
rankLabel: 'Fremder',
|
||||||
|
nextThreshold: 100,
|
||||||
|
});
|
||||||
|
const element = fixture.nativeElement as HTMLElement;
|
||||||
|
|
||||||
|
expect(element.textContent).toContain('Der Stille Orden');
|
||||||
|
expect(element.textContent).toContain('Fremder');
|
||||||
|
expect(element.textContent).toContain('0');
|
||||||
|
|
||||||
|
const fill = element.querySelector<HTMLElement>('[data-reputation-progress-fill]');
|
||||||
|
const width = parseFloat(fill?.style.inlineSize ?? '-1');
|
||||||
|
expect(width).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
import type { ReputationEntry } from '../../core/api/game-api.models';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable Regional Reputation presentation (spec §27). No dedicated screen
|
||||||
|
* hosts this yet -- it is built and tested standalone so a Character
|
||||||
|
* screen, merchant, or NPC dialog can embed it later.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-reputation-display',
|
||||||
|
templateUrl: './reputation-display.component.html',
|
||||||
|
styleUrl: './reputation-display.component.scss',
|
||||||
|
})
|
||||||
|
export class ReputationDisplayComponent {
|
||||||
|
readonly entry = input.required<ReputationEntry>();
|
||||||
|
|
||||||
|
// Progress toward the next threshold, measured from 0 rather than from the
|
||||||
|
// current rank's own floor: the API DTO exposes `reputation` and
|
||||||
|
// `nextThreshold` only. A more precise bar would need a `currentThreshold`
|
||||||
|
// field that nothing else needs yet, so this stays coarse on purpose.
|
||||||
|
protected readonly progressPercent = computed(() => {
|
||||||
|
const entry = this.entry();
|
||||||
|
if (entry.nextThreshold === null) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
return Math.min(100, Math.max(0, (entry.reputation / entry.nextThreshold) * 100));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user