Skip to content
Merged

Next #307

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ <h3>
[options]="ammoDropdownOptions(row)"
[value]="ammo.selectedOptionId"
[disabled]="readOnly()"
[expandPanelToContent]="true"
(valueChange)="selectAmmoOption(row, $event)"
/>
} @else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3267,15 +3267,24 @@ describe('WeaponsEquipmentPanelComponent', () => {
const leftBin = entry({ id: 'left-ammo', equipment: standardAmmo, totalAmmo: 10, consumed: 1, locations: new Set(['LT']) });
const rightBin = entry({ id: 'right-ammo', equipment: standardAmmo, totalAmmo: 10, consumed: 5, locations: new Set(['RT']) });
const equipmentMap: EquipmentMap = { [standardAmmo.internalName]: standardAmmo };
const { component } = createComponent([atm, leftBin, rightBin], equipmentMap);
const { component, fixture } = createComponent([atm, leftBin, rightBin], equipmentMap);
const row = component.groups().find(group => group.id === 'ranged')!.rows[0];

expect(row.ammo.options.map(option => option.label)).toEqual([
'[LT] ATM 6 Standard (9/10)',
'[RT] ATM 6 Standard (5/10)'
]);
expect(component.ammoDropdownOptions(row).map(option => option.trailingLabel)).toEqual([
'(9/10)',
'(5/10)'
]);
expect(component.ammoState(row).selectedOptionId).toBe(row.ammo.options[0].id);
expect(component.ammoState(row).text).toBe('[LT] ATM 6 Standard (9/10)');

fixture.detectChanges();
const ammoChoice = fixture.nativeElement.querySelector('.ammo-choice') as HTMLElement;
expect(ammoChoice.querySelector('.multiline-dropdown-label-text')?.textContent?.trim()).toBe('[LT] ATM 6 Standard');
expect(ammoChoice.querySelector('.multiline-dropdown-trailing-label')?.textContent?.trim()).toBe('(9/10)');
});

it('shows No ammo only when a weapon has no ammo choices', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,7 @@ export class WeaponsEquipmentPanelComponent {
return row.ammo.options.map(option => ({
value: option.id,
label: option.label,
trailingLabel: `(${option.remaining}/${option.total})`,
disabled: option.disabled,
destroyed: option.destroyed,
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,61 @@ describe('MultilineDropdownComponent', () => {
expect(measureOptions.map(option => option.textContent?.trim())).toEqual(options.map(option => option.label));
});

it('ellipsizes the leading label while keeping a trailing label separate', () => {
const fixture = TestBed.createComponent(MultilineDropdownComponent);
fixture.componentRef.setInput('options', [{
value: 'heavy-gauss',
label: '[HD] iHeavy Gauss Ammo (4/4)',
trailingLabel: '(4/4)',
}]);
fixture.componentRef.setInput('value', 'heavy-gauss');
(fixture.nativeElement as HTMLElement).style.width = '150px';
fixture.detectChanges();

const trigger = fixture.nativeElement.querySelector('.multiline-dropdown-trigger') as HTMLButtonElement;
const label = trigger.querySelector('.multiline-dropdown-label') as HTMLElement;
const leadingLabel = trigger.querySelector('.multiline-dropdown-label-text') as HTMLElement;
const trailingLabel = trigger.querySelector('.multiline-dropdown-trailing-label') as HTMLElement;

expect(leadingLabel.textContent?.trim()).toBe('[HD] iHeavy Gauss Ammo');
expect(trailingLabel.textContent?.trim()).toBe('(4/4)');
expect(getComputedStyle(trigger).whiteSpace).toBe('nowrap');
expect(getComputedStyle(leadingLabel).textOverflow).toBe('ellipsis');
expect(getComputedStyle(trailingLabel).whiteSpace).toBe('nowrap');
expect(leadingLabel.scrollWidth).toBeGreaterThan(leadingLabel.clientWidth);
expect(trailingLabel.scrollWidth).toBe(trailingLabel.clientWidth);
expect(trailingLabel.getBoundingClientRect().right).toBeLessThanOrEqual(label.getBoundingClientRect().right);
expect(trigger.scrollHeight).toBe(trigger.clientHeight);
});

it('allows an opted-in overlay panel to expand to its content width', async () => {
const fixture = TestBed.createComponent(MultilineDropdownComponent);
fixture.componentRef.setInput('options', [{
value: 'heavy-gauss',
label: '[HD] iHeavy Gauss Ammo (4/4)',
trailingLabel: '(4/4)',
}]);
fixture.componentRef.setInput('value', 'heavy-gauss');
fixture.componentRef.setInput('expandPanelToContent', true);
(fixture.nativeElement as HTMLElement).style.width = '150px';
fixture.detectChanges();

const trigger = fixture.nativeElement.querySelector('.multiline-dropdown-trigger') as HTMLButtonElement;
trigger.click();
fixture.detectChanges();
await nextAnimationFrame();
await nextAnimationFrame();

const panelHost = overlayContainerElement.querySelector('multiline-dropdown-panel') as HTMLElement;
const overlayPane = panelHost.closest('.cdk-overlay-pane') as HTMLElement;
const optionLabel = panelHost.querySelector('.multiline-dropdown-option-label') as HTMLElement;
expect(panelHost.classList.contains('expand-to-content')).toBeTrue();
expect(optionLabel.textContent?.trim()).toBe('[HD] iHeavy Gauss Ammo (4/4)');
expect(overlayPane.getBoundingClientRect().width).toBeGreaterThan(trigger.getBoundingClientRect().width);
});

});

function nextAnimationFrame(): Promise<void> {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { DropdownPointerActivationGuard, scrollActiveOptionIntoView } from '../.
export interface MultilineDropdownOption {
value: string;
label: string;
/** A trailing part of label that must remain visible when the closed trigger truncates. */
trailingLabel?: string | null;
modifierLabel?: string | null;
disabled?: boolean;
destroyed?: boolean;
Expand All @@ -30,6 +32,7 @@ interface MultilineDropdownPointerHoverEvent {
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[style.font-size]': 'fontSize() || null',
'[class.expand-to-content]': 'expandToContent()',
},
template: `
<div
Expand Down Expand Up @@ -70,6 +73,19 @@ interface MultilineDropdownPointerHoverEvent {
min-height: 0;
}

:host(.expand-to-content) {
box-sizing: border-box;
width: max-content;
min-width: 100%;
max-width: calc(100dvw - 8px);
}

:host(.expand-to-content) .multiline-dropdown-options {
width: max-content;
min-width: 100%;
max-width: calc(100dvw - 8px);
}

.multiline-dropdown-options {
box-sizing: border-box;
width: 100%;
Expand Down Expand Up @@ -158,6 +174,7 @@ class MultilineDropdownPanelComponent {
readonly activeOptionId = input('');
readonly activeIndex = input(0);
readonly fontSize = input('');
readonly expandToContent = input(false);

readonly selected = output<MultilineDropdownOption>();
readonly pointerHovered = output<MultilineDropdownPointerHoverEvent>();
Expand Down Expand Up @@ -195,12 +212,18 @@ class MultilineDropdownPanelComponent {
[attr.aria-controls]="optionsId()"
[attr.aria-expanded]="open()"
[attr.aria-label]="label()"
[attr.title]="selectedLabel()"
[disabled]="disabled() || options().length === 0"
[class.destroyed]="selectedOption()?.destroyed"
(click)="toggle()"
(keydown)="onTriggerKeydown($event)"
>
<span class="multiline-dropdown-label">{{ selectedLabel() }}</span>
<span class="multiline-dropdown-label">
<span class="multiline-dropdown-label-text">{{ selectedLeadingLabel() }}</span>
@if (selectedTrailingLabel(); as trailingLabel) {
<span class="multiline-dropdown-trailing-label">{{ trailingLabel }}</span>
}
</span>
@if (selectedOption()?.modifierLabel; as modifierLabel) {
<span class="modifier-badge">{{ modifierLabel }}</span>
}
Expand Down Expand Up @@ -234,6 +257,10 @@ class MultilineDropdownPanelComponent {
width: 100%;
height: 100%;
gap: 4px;
overflow: hidden;
overflow-wrap: normal;
word-break: normal;
white-space: nowrap;
text-align: left;
cursor: pointer;
}
Expand All @@ -252,11 +279,27 @@ class MultilineDropdownPanelComponent {
.multiline-dropdown-label {
grid-column: 1;
grid-row: 1;
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 0.3em;
min-width: 0;
white-space: normal;
overflow: hidden;
overflow-wrap: normal;
word-break: normal;
white-space: nowrap;
}

.multiline-dropdown-label-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.multiline-dropdown-trailing-label {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}

.multiline-dropdown-measure {
Expand Down Expand Up @@ -323,6 +366,7 @@ export class MultilineDropdownComponent implements OnDestroy {
readonly placeholder = input('Select');
readonly controlId = input(this.instanceId);
readonly disabled = input(false);
readonly expandPanelToContent = input(false);

readonly valueChange = output<string>();
readonly optionSelected = output<MultilineDropdownOption>();
Expand All @@ -333,6 +377,21 @@ export class MultilineDropdownComponent implements OnDestroy {
readonly activeOptionId = computed(() => this.optionId(this.activeIndex()));
readonly selectedOption = computed(() => this.options().find(option => option.value === this.value()) ?? null);
readonly selectedLabel = computed(() => this.selectedOption()?.label ?? this.placeholder());
readonly selectedTrailingLabel = computed(() => {
const option = this.selectedOption();
const trailingLabel = option?.trailingLabel?.trim();
return option && trailingLabel && option.label.trimEnd().endsWith(trailingLabel)
? trailingLabel
: null;
});
readonly selectedLeadingLabel = computed(() => {
const option = this.selectedOption();
if (!option) return this.placeholder();
const trailingLabel = this.selectedTrailingLabel();
return trailingLabel
? option.label.trimEnd().slice(0, -trailingLabel.length).trimEnd()
: option.label;
});

optionId(index: number): string {
return `${this.optionsId()}-${index}`;
Expand Down Expand Up @@ -465,6 +524,7 @@ export class MultilineDropdownComponent implements OnDestroy {
panelRef.setInput('activeOptionId', this.activeOptionId());
panelRef.setInput('activeIndex', this.activeIndex());
panelRef.setInput('fontSize', this.triggerFontSize());
panelRef.setInput('expandToContent', this.expandPanelToContent());
panelRef.changeDetectorRef.detectChanges();
if (scrollActiveIntoView) {
this.scrollActiveOptionIntoView(panelRef.location.nativeElement as HTMLElement);
Expand Down
42 changes: 41 additions & 1 deletion src/app/utils/ammo-interaction.util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type HandlerCommandContext,
type HandlerDialogsService,
} from '../services/equipment-interaction-registry.service';
import { changeAmmoEntryRemaining, changeAmmoGroupRemaining, getAmmoControlEntriesForUnitWeapons, getAmmoControlGroups, getAmmoEntryRemaining, getAmmoGroupRemaining, isIntrinsicOneShotAmmoMount, materializeIntrinsicOneShotAmmoForInventory, setAmmoEntryValue, type AmmoControlEntry } from './ammo-interaction.util';
import { changeAmmoEntryRemaining, changeAmmoGroupRemaining, getAmmoControlEntriesForUnitWeapons, getAmmoControlEntryForCriticalSlot, getAmmoControlGroups, getAmmoEntryRemaining, getAmmoGroupRemaining, isIntrinsicOneShotAmmoMount, materializeIntrinsicOneShotAmmoForInventory, setAmmoEntryValue, type AmmoControlEntry } from './ammo-interaction.util';

function createAmmo(id: string, shortName: string): AmmoEquipment {
return new AmmoEquipment({
Expand Down Expand Up @@ -136,6 +136,46 @@ function testEquipmentOperational(source: MountedEquipment | CriticalSlot): bool
return testEquipmentStatus(source) === 'available';
}

describe('ammo interaction critical damage', () => {
it('keeps a damaged bin usable until destruction commits without erasing its rounds', () => {
const ammo = createAmmo('Clan Ultra AC/20 Ammo', 'Ultra AC/20 Ammo');
const slot: CriticalSlot = {
id: 'ammo@LT',
name: ammo.internalName,
loc: 'LT',
slot: 0,
eq: ammo,
totalAmmo: 5,
consumed: 1,
destroying: Date.now(),
};
const unit = {
svg: () => null,
getEquipmentStatus: (source: CriticalSlot) => source.destroyed ? 'destroyed' : 'available',
} as unknown as CBTForceUnit;
const catalog = createEquipmentCatalog({ [ammo.internalName]: ammo });

const pendingEntry = getAmmoControlEntryForCriticalSlot(unit, slot, catalog)!;
expect(pendingEntry.status).toBe('available');
expect(pendingEntry.totalAmmo).toBe(5);
expect(pendingEntry.consumed).toBe(1);
expect(getAmmoEntryRemaining(pendingEntry)).toBe(4);

slot.destroying = undefined;
slot.destroyed = Date.now();
const destroyedEntry = getAmmoControlEntryForCriticalSlot(unit, slot, catalog)!;
expect(destroyedEntry.status).toBe('destroyed');
expect(destroyedEntry.consumed).toBe(1);
expect(getAmmoEntryRemaining(destroyedEntry)).toBe(0);

slot.destroyed = undefined;
const repairedEntry = getAmmoControlEntryForCriticalSlot(unit, slot, catalog)!;
expect(repairedEntry.status).toBe('available');
expect(repairedEntry.consumed).toBe(1);
expect(getAmmoEntryRemaining(repairedEntry)).toBe(4);
});
});

describe('ammo interaction direct inventory groups', () => {
const standardAmmo = createAmmo('Clan Ultra AC/20 Ammo', 'Ultra AC/20 Ammo');
const precisionAmmo = createAmmo('Clan Ultra AC/20 Precision Ammo', 'Ultra AC/20 Precision Ammo');
Expand Down
51 changes: 51 additions & 0 deletions src/app/utils/inventory-control-ammo.util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AmmoEquipment, WeaponEquipment } from '../models/equipment.model';
import { EquipmentRegistry } from '../models/equipment-lookup';
import { MountedAmmo, MountedWeapon } from '../models/mounted-equipment.model';
import type { CBTForceUnit } from '../models/cbt-force-unit.model';
import type { CriticalSlot } from '../models/force-serialization';
import { createEmptyUnit } from '../testing/unit-test-helpers';
import { getInventoryControlAmmoProfileId, getInventoryControlModeAmmoSummary, resolveInventoryControlSelectedAmmoOption, type InventoryControlAmmoOption } from './inventory-control.util';

Expand Down Expand Up @@ -135,6 +136,56 @@ describe('inventory-control ammo selection', () => {
expect(summary.options[0].profileId).toBe('AC5 Ammo||M_STANDARD');
});

it('keeps a damaged bin usable until the critical destruction commits', () => {
const weapon = new WeaponEquipment({
id: 'AC5', name: 'AC/5', type: 'weapon',
weapon: { ammoType: 'AC', rackSize: 5, damage: 5 },
});
const ammo = new AmmoEquipment({
id: 'AC5 Ammo', name: 'AC/5 Ammo', type: 'ammo',
ammo: { type: 'AC', rackSize: 5, shots: 20, munitionType: ['M_STANDARD'] },
});
const ammoSlot: CriticalSlot = {
id: 'ammo@LT',
name: ammo.internalName,
loc: 'LT',
slot: 0,
eq: ammo,
totalAmmo: 20,
consumed: 3,
destroying: Date.now(),
};
const inventory: MountedWeapon[] = [];
const owner = {
getInventory: () => inventory,
getCritSlots: () => [ammoSlot],
getCritSlot: () => ammoSlot,
getEquipmentStatus: (source: CriticalSlot) => source.destroyed ? 'destroyed' as const : 'available' as const,
isEquipmentOperational: (source: CriticalSlot) => !source.destroyed,
} as unknown as CBTForceUnit;
const mountedWeapon = new MountedWeapon({ owner, id: 'ac5', name: weapon.name, equipment: weapon });
inventory.push(mountedWeapon);
const catalog = new EquipmentRegistry({ [ammo.internalName]: ammo });

const pendingSummary = getInventoryControlModeAmmoSummary(mountedWeapon, catalog, {}, null);
expect(pendingSummary.remaining).toBe(17);
expect(pendingSummary.options[0]).toEqual(jasmine.objectContaining({
remaining: 17,
total: 20,
destroyed: false,
}));

ammoSlot.destroying = undefined;
ammoSlot.destroyed = Date.now();
const committedSummary = getInventoryControlModeAmmoSummary(mountedWeapon, catalog, {}, null);
expect(committedSummary.remaining).toBe(0);
expect(committedSummary.options[0]).toEqual(jasmine.objectContaining({
remaining: 0,
total: 20,
destroyed: true,
}));
});

it('sorts and normalizes fields when creating an ammo profile ID', () => {
const ammo = new AmmoEquipment({
id: 'Standard',
Expand Down
Loading