diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts index e716224c..12d6a074 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts @@ -168,7 +168,7 @@ describe('QuestEditDialogComponent', () => { it('offers the stardust floor, which PoracleNG keeps in reward', () => { setup({ ...baseQuest, pokemonId: 0, reward: 1000, rewardType: 3 }); - expect(component.isStardust).toBe(true); + expect(component.usesRewardSlot).toBe(true); expect(component.form.controls.stardust.value).toBe(1000); component.form.controls.stardust.setValue(1500); @@ -183,7 +183,7 @@ describe('QuestEditDialogComponent', () => { setup({ ...baseQuest, reward: 25, rewardType: 7 }); expect(component.hasAmount).toBe(false); - expect(component.isStardust).toBe(false); + expect(component.usesRewardSlot).toBe(false); component.save(); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts index 8b2ca893..ab37b21f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts @@ -94,8 +94,6 @@ export class QuestEditDialogComponent { readonly isPokecoins = this.data.rewardType === POKECOINS; - readonly isStardust = this.data.rewardType === STARDUST; - readonly isWebhook = inject(AuthService).isImpersonating(); saving = signal(false); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.spec.ts index f861feaa..fa479f56 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.spec.ts @@ -3,6 +3,7 @@ import { provideTranslateService, TranslateService } from '@ngx-translate/core'; import { ActiveHoursChipComponent } from './active-hours-chip.component'; import { ActiveHourEntry } from '../../../core/models/active-hours.models'; +import { I18nService } from '../../../core/services/i18n.service'; describe('ActiveHoursChipComponent', () => { let component: ActiveHoursChipComponent; @@ -106,6 +107,27 @@ describe('ActiveHoursChipComponent', () => { expect(component.pills()[0].label).toBe('Weekends 9:00 AM–5:30 PM, every 2h'); }); + it('follows a live display-language switch', () => { + // The labels come from translate.instant inside a computed, which only stays current because + // instant reads the translation store's signals -- an implementation detail of @ngx-translate + // v18 that older versions did not have. Switching through I18nService is the path the language + // menu takes, so this fails if either half stops holding. + const i18n = TestBed.inject(I18nService); + const translate = TestBed.inject(TranslateService); + withEnglishRangeStrings(); + translate.setTranslation('it', { PROFILES: { ACTIVE_HOURS_RANGE_HOURLY: '{{days}} {{start}}–{{end}}, ogni ora' } }, true); + + fixture.componentRef.setInput('activeHours', [{ day: 1, endHours: 17, endMins: 0, hours: 9, mins: 0, step: 1 }] as ActiveHourEntry[]); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.chip-active').textContent).toContain('hourly'); + + i18n.use('it'); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.chip-active').textContent).toContain('ogni ora'); + expect(component.pills()[0].label).toBe('Mon 9:00 AM–5:00 PM, ogni ora'); + }); + it('should leave a single fire label untouched', () => { withEnglishRangeStrings(); fixture.componentRef.setInput('activeHours', [{ day: 1, hours: 9, mins: 0 }] as ActiveHourEntry[]); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts index b3170167..dde59c02 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts @@ -21,6 +21,15 @@ export class ActiveHoursChipComponent { readonly isEmpty = computed(() => this.activeHours().length === 0); + /** + * Reactive to a display-language switch, and by a route worth stating out loud: `translate.instant` + * reads the store's `_currentLang` and `_translations` signals, so calling it inside a `computed` + * registers them as dependencies and switching language invalidates these labels the same way a new + * schedule does. That is true of @ngx-translate v18 and was not true of the versions before it, where + * this shape went stale until something else knocked `activeHours`. The spec switches the language and + * asserts the rendered pill follows; if a future version stops reading those signals it goes red here + * rather than in front of a user. + */ readonly pills = computed(() => this.groups().map(g => ({ label: formatRuleLabel(g, (key, params) => this.translate.instant(key, params)), diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts index 3a182389..6e969527 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts @@ -33,6 +33,22 @@ describe('cleanRuleSummary', () => { ); }); + it('leaves an underscore that is part of a name alone', () => { + // Poracle interpolates user-chosen strings -- template names, and the areas and saved-place labels + // a scope override renders -- straight into this sentence without escaping them. A place called + // work_gym is not italics, and losing the underscore renames it on the card. + expect(cleanRuleSummary('**Bulbasaur** | distance: 5000m | template: my_template ')).toBe( + 'Bulbasaur | distance: 5000m | template: my_template', + ); + expect(cleanRuleSummary('**Pikachu** | areas: north_side, east_side ')).toBe('Pikachu | areas: north_side, east_side'); + }); + + it('strips the other Discord emphasis, but only where it is paired', () => { + expect(cleanRuleSummary('_Bulbasaur_ | distance: 5000m')).toBe('Bulbasaur | distance: 5000m'); + expect(cleanRuleSummary('__Bulbasaur__ | distance: 5000m')).toBe('Bulbasaur | distance: 5000m'); + expect(cleanRuleSummary('*Bulbasaur* | `iv: 90%-100%`')).toBe('Bulbasaur | iv: 90%-100%'); + }); + it('is empty for the absent, null and blank cases, so the card renders nothing', () => { expect(cleanRuleSummary(undefined)).toBe(''); expect(cleanRuleSummary(null)).toBe(''); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts index 65c1b21d..9692e27f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts @@ -4,21 +4,34 @@ * The upstream string is written for Discord, so it carries markdown emphasis and the loose spacing * that survives a chat client: `**Bulbasaur** | distance: 5000m | iv: 90%-100% `. The bold always * wraps the species or the level, which is already the card's heading, so keeping it would double the - * emphasis and fight the `

` rather than help it. Everything here is plain-text transformation — + * emphasis and fight the `

` rather than help it. Everything here is plain-text transformation -- * the result is interpolated, never handed to innerHTML. + * + * The stripping is deliberately pair-aware rather than a sweep of `[*_`]`. PoracleNG interpolates + * user-chosen strings into this sentence without escaping them -- `rowtext` builds `**%s**` around a + * template name, an area or a saved-place label -- and `work_gym` is a name, not italics. Discord + * agrees: an underscore flanked by word characters emphasises nothing. So a delimiter is only removed + * where it has a partner, and the underscore forms additionally need a non-word character on the + * outside, which is exactly the boundary Discord applies. */ export function cleanRuleSummary(raw: null | string | undefined): string { if (!raw) return ''; - return raw - .replace(/\*\*/g, '') - .replace(/__/g, '') - .replace(/[*_`]/g, '') - .replace(/\s+/g, ' ') - .replace(/\s*\|\s*/g, ' | ') - .replace(/^[\s|]+/, '') - .replace(/[\s|]+$/, '') - .trim(); + return ( + raw + // Code spans first: inside a backtick pair Discord renders the rest literally, so unwrapping the + // span before looking for emphasis stops a `*` in code being read as a delimiter. + .replace(/`([^`\n]+)`/g, '$1') + .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '$1') + .replace(/\*(?=\S)([^*\n]*?\S)\*/g, '$1') + .replace(/(?