diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts index 91419457..972c3e46 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts @@ -10,6 +10,8 @@ export interface Monster { /** Costume filter: 9000 any, 0 none, N that costume. See shared/utils/costumes.ts. */ costume: number; def: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; form: number; gender: number; @@ -45,7 +47,7 @@ export interface Monster { uid: number; } -export type MonsterCreate = Omit; +export type MonsterCreate = Omit; export type MonsterUpdate = Partial; @@ -53,6 +55,8 @@ export type MonsterUpdate = Partial; export interface Raid { clean: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; /** Costume filter on the boss: 9000 any, 0 none, N that costume. See shared/utils/costumes.ts. */ costume: number; distance: number; @@ -74,7 +78,7 @@ export interface Raid { uid: number; } -export type RaidCreate = Omit; +export type RaidCreate = Omit; export type RaidUpdate = Partial; @@ -82,6 +86,8 @@ export type RaidUpdate = Partial; export interface MaxBattle { clean: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; evolution: number; form: number; @@ -99,7 +105,7 @@ export interface MaxBattle { uid: number; } -export type MaxBattleCreate = Omit; +export type MaxBattleCreate = Omit; export type MaxBattleUpdate = Partial; @@ -107,6 +113,8 @@ export type MaxBattleUpdate = Partial; export interface Egg { clean: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; exclusive: number; gymId: string | null; @@ -122,7 +130,7 @@ export interface Egg { uid: number; } -export type EggCreate = Omit; +export type EggCreate = Omit; export type EggUpdate = Partial; @@ -132,6 +140,8 @@ export interface Quest { /** Fewest of the reward the quest must give. Items, candy and mega energy only; 0 means any. */ amount: number; clean: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; id: string; overrideAreas?: null | string[]; @@ -146,7 +156,7 @@ export interface Quest { uid: number; } -export type QuestCreate = Omit; +export type QuestCreate = Omit; export type QuestUpdate = Partial; @@ -154,6 +164,8 @@ export type QuestUpdate = Partial; export interface Invasion { clean: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; gender: number; gruntType: string | null; @@ -166,7 +178,7 @@ export interface Invasion { uid: number; } -export type InvasionCreate = Omit; +export type InvasionCreate = Omit; export type InvasionUpdate = Partial; @@ -174,6 +186,8 @@ export type InvasionUpdate = Partial; export interface Lure { clean: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; id: string; lureId: number; @@ -185,7 +199,7 @@ export interface Lure { uid: number; } -export type LureCreate = Omit; +export type LureCreate = Omit; export type LureUpdate = Partial; @@ -193,6 +207,8 @@ export type LureUpdate = Partial; export interface Nest { clean: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; id: string; minSpawnAvg: number; @@ -205,7 +221,7 @@ export interface Nest { uid: number; } -export type NestCreate = Omit; +export type NestCreate = Omit; export type NestUpdate = Partial; @@ -213,6 +229,8 @@ export type NestUpdate = Partial; export interface FortChange { changeTypes: string[]; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; fortType: string | null; id: string; @@ -225,7 +243,7 @@ export interface FortChange { uid: number; } -export type FortChangeCreate = Omit; +export type FortChangeCreate = Omit; export type FortChangeUpdate = Partial; @@ -234,6 +252,8 @@ export type FortChangeUpdate = Partial; export interface Gym { battleChanges: number; clean: number; + /** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */ + description?: null | string; distance: number; gymId: string | null; id: string; @@ -247,7 +267,7 @@ export interface Gym { uid: number; } -export type GymCreate = Omit; +export type GymCreate = Omit; export type GymUpdate = Partial; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts index 3a2f73d7..4c3a7dc9 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts @@ -26,6 +26,16 @@ export class AlertLanguageService { /** Every language Poracle can write alerts in. */ readonly languages = this.i18n.allLanguages; + /** + * The language Poracle will actually write in, or null when we cannot tell. + * + * Deliberately not `selected()`. That one coerces to 'en' so the picker always has a row highlighted, + * which is right for a menu and wrong for anything that acts on the answer: when Poracle's own locale + * maps onto no UI language (ja, ru, zh-cn), coercing would claim English and put Japanese prose on an + * English card. Null says "unknown", and callers are expected to do nothing rather than guess. + */ + readonly resolved = computed(() => this.chosen() ?? this.i18n.serverDefaultLanguage()); + readonly selected = computed(() => this.chosen() ?? this.i18n.serverDefaultLanguage() ?? 'en'); /** Sets the alert language, rolling back if the write fails. Returns whether it stuck. */ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.html index 8eb3a908..55faf0e1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.html @@ -87,6 +87,7 @@

{{ getTeamName(gym.team) }}

[profileAreas]="profileAreas()" (click)="editScope(gym); $event.stopPropagation()" /> + @if (gym.gymId; as gymId) { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.ts index 8fc02e5f..78197195 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.ts @@ -22,6 +22,7 @@ import { ScannerService } from '../../core/services/scanner.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { RuleSummaryComponent } from '../../shared/components/rule-summary/rule-summary.component'; import { QuietChipComponent } from '../../shared/components/quiet-chip/quiet-chip.component'; import { WhereChipComponent } from '../../shared/components/where-chip/where-chip.component'; import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; @@ -40,6 +41,7 @@ import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-sco MatTooltipModule, MatSnackBarModule, MatProgressSpinnerModule, + RuleSummaryComponent, TranslatePipe, WhereChipComponent, ], diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.html index 2537d867..f0ab8fec 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.html @@ -104,6 +104,7 @@

{{ getDisplayName(invasion.gruntType, invasion.gender) }}

[profileAreas]="profileAreas()" (click)="editScope(invasion); $event.stopPropagation()" /> + + } @else { + + {{ cleaned() }} + + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.scss new file mode 100644 index 00000000..d32e6441 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.scss @@ -0,0 +1,47 @@ +// Last thing on the card, below a hairline: the pills own the top of the content block and the scan +// rhythm that goes with it, so this sits under a rule rather than competing for the same space. +.rule-summary { + align-items: flex-start; + border-top: 1px solid var(--card-border, rgb(0 0 0 / 12%)); + color: var(--text-muted, rgb(0 0 0 / 64%)); + display: flex; + font-size: 0.75rem; + gap: 0.25rem; + line-height: 1.35; + margin-top: 0.625rem; + padding-top: 0.5rem; + text-align: left; + width: 100%; +} + +.rule-summary-toggle { + background: none; + border: none; + border-top: 1px solid var(--card-border, rgb(0 0 0 / 12%)); + cursor: pointer; + font: inherit; + // Vertical padding rather than a min-height, so the collapsed line stays visually two lines while the + // touch target reaches 40px. + padding: 0.5rem 0 0.5rem; +} + +.rule-summary-text { + flex: 1 1 auto; + min-width: 0; +} + +// Visual only: a screen reader still reads the whole sentence. +.rule-summary-clamped { + -webkit-box-orient: vertical; + display: -webkit-box; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.rule-summary-icon { + flex: 0 0 auto; + font-size: 1rem; + height: 1rem; + opacity: 0.7; + width: 1rem; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.spec.ts new file mode 100644 index 00000000..377aac02 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.spec.ts @@ -0,0 +1,103 @@ +import { ComponentRef, WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { RuleSummaryComponent } from './rule-summary.component'; +import { AlertLanguageService } from '../../../core/services/alert-language.service'; +import { I18nService } from '../../../core/services/i18n.service'; + +const LIVE_POKEMON = + '**Bulbasaur** | distance: 5000m | iv: 90%-100% | cp: 1200-4000 | level: 20-35 | stats: 0/0/0 - 15/15/15 | pvp ranking: greatpvp top100 (@0+) | size: XXS-XXL '; + +describe('RuleSummaryComponent', () => { + let fixture: ComponentFixture; + let ref: ComponentRef; + let displayLang: WritableSignal; + let alertLang: WritableSignal; + + function create(text: null | string | undefined, display = 'en', alert: null | string = 'en'): RuleSummaryComponent { + displayLang = signal(display); + alertLang = signal(alert); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + { provide: I18nService, useValue: { currentLang: displayLang } }, + { provide: AlertLanguageService, useValue: { resolved: alertLang } }, + ], + imports: [RuleSummaryComponent], + }); + fixture = TestBed.createComponent(RuleSummaryComponent); + ref = fixture.componentRef; + ref.setInput('text', text); + fixture.detectChanges(); + return fixture.componentInstance; + } + + function rendered(): string { + return (fixture.nativeElement as HTMLElement).textContent?.trim() ?? ''; + } + + it('prints the rule as a sentence, with the Discord markdown taken out', () => { + const summary = create('Reward: **Pikachu** | distance: 500m '); + + expect(summary.visible()).toBe(true); + expect(rendered()).toContain('Reward: Pikachu | distance: 500m'); + expect(rendered()).not.toContain('**'); + }); + + it('renders nothing when Poracle sent no description, so the pills carry on alone', () => { + const summary = create(null); + + expect(summary.visible()).toBe(false); + expect((fixture.nativeElement as HTMLElement).querySelector('.rule-summary')).toBeNull(); + }); + + it('renders nothing for a blank description rather than an empty hairline', () => { + expect(create(' ').visible()).toBe(false); + }); + + it('stays out of the way when the alert language is not the display language', () => { + // Poracle localises this sentence with the alert language; the pills above it follow the display + // language. A card must not carry both. + const summary = create('Reward: **Pikachu** | distance: 500m ', 'de', 'en'); + + expect(summary.visible()).toBe(false); + }); + + it('shows it when the two languages agree, whatever they are', () => { + // The legitimate case: a German user whose alerts are German still gets the line. + expect(create('Belohnung: **Pikachu** | distance: 500m ', 'de', 'de').visible()).toBe(true); + }); + + it('treats an unresolvable alert language as a mismatch instead of guessing English', () => { + // resolved() is null when Poracle's own locale maps onto no UI language. Guessing would put ja + // prose under en chips. + expect(create('Reward: **Pikachu** | distance: 500m ', 'en', null).visible()).toBe(false); + }); + + it('offers an expand control on a long rule and collapses again', () => { + const summary = create(LIVE_POKEMON); + const host = fixture.nativeElement as HTMLElement; + + expect(summary.expandable()).toBe(true); + const button = host.querySelector('button'); + expect(button).not.toBeNull(); + expect(button?.getAttribute('aria-expanded')).toBe('false'); + expect(host.querySelector('.rule-summary-clamped')).not.toBeNull(); + + button?.click(); + fixture.detectChanges(); + + expect(summary.expanded()).toBe(true); + expect(host.querySelector('.rule-summary-clamped')).toBeNull(); + }); + + it('offers no control on a short rule, because there is nothing to expand', () => { + const summary = create('**Level 5 raids** without rsvp updates'); + + expect(summary.expandable()).toBe(false); + expect((fixture.nativeElement as HTMLElement).querySelector('button')).toBeNull(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.ts new file mode 100644 index 00000000..229cbe3b --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rule-summary/rule-summary.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { AlertLanguageService } from '../../../core/services/alert-language.service'; +import { I18nService } from '../../../core/services/i18n.service'; +import { cleanRuleSummary, ruleSummaryNeedsExpanding } from '../../utils/rule-summary'; + +/** + * What this rule does, in Poracle's own words. + * + * The filter pills above it answer "which of these forty is the one I want" — same chips, same place on + * every card, so they read first and this reads second. This line is what you read once you have stopped + * on a card: a whole sentence, stating things the pills leave out (attack/defence floors, weight, the + * PVP CP cap) at the cost of restating things they already show. + * + * It shows nothing at all when there is nothing worth showing: no description from Poracle, or a + * Poracle too old to render one, and the card is exactly what it was before. The same nothing when the + * alert language and the display language disagree — Poracle localises this sentence with the alert + * language while the pills follow the display language, and a card carrying both is worse than a card + * carrying one. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIconModule, MatTooltipModule, TranslatePipe], + selector: 'app-rule-summary', + standalone: true, + styleUrl: './rule-summary.component.scss', + templateUrl: './rule-summary.component.html', +}) +export class RuleSummaryComponent { + private readonly alertLanguage = inject(AlertLanguageService); + private readonly i18n = inject(I18nService); + + /** The raw `description` PoracleNG returned on the alarm. */ + readonly text = input(null); + + readonly cleaned = computed(() => cleanRuleSummary(this.text())); + + /** Long enough that the two-line clamp will bite, so the line is worth a control. */ + readonly expandable = computed(() => ruleSummaryNeedsExpanding(this.cleaned())); + + readonly expanded = signal(false); + + /** + * Whether the sentence and the interface are in the same language. Null from `resolved()` means we + * cannot tell what Poracle will write in, which counts as a mismatch: guessing is how you end up + * putting one language's prose under another language's chips. + */ + readonly languagesAgree = computed(() => { + const alert = this.alertLanguage.resolved(); + return alert !== null && alert.toLowerCase() === this.i18n.currentLang().toLowerCase(); + }); + + readonly visible = computed(() => this.cleaned().length > 0 && this.languagesAgree()); + + toggle(): void { + this.expanded.update(open => !open); + } +} 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 new file mode 100644 index 00000000..3a182389 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts @@ -0,0 +1,57 @@ +import { cleanRuleSummary, ruleSummaryNeedsExpanding } from './rule-summary'; + +describe('cleanRuleSummary', () => { + // Every input below is a string a live PoracleNG returned, not one invented to suit the function. + it('strips the bold around the species, which the card heading already says', () => { + expect(cleanRuleSummary('**Bulbasaur** | distance: 5000m | iv: 90%-100% | cp: 1200-4000 ')).toBe( + 'Bulbasaur | distance: 5000m | iv: 90%-100% | cp: 1200-4000', + ); + }); + + it('handles the quest shape, where the bold is mid-sentence', () => { + expect(cleanRuleSummary('Reward: **Pikachu** | distance: 500m ')).toBe('Reward: Pikachu | distance: 500m'); + }); + + it('keeps the trailing clean flag, which is part of what the rule does', () => { + expect(cleanRuleSummary('**Mystic gyms** | distance: 150m clean')).toBe('Mystic gyms | distance: 150m clean'); + }); + + it('collapses the doubled space a missing segment leaves behind', () => { + expect(cleanRuleSummary('**Level 5 raids** without rsvp updates')).toBe('Level 5 raids without rsvp updates'); + }); + + it('leaves the fort description intact rather than trying to prettify its JSON array', () => { + // Not rendered on a card today, but the utility must not mangle it if it ever is. + expect(cleanRuleSummary('Fort updates: **pokestop** | distance: 5000m ["name"] ')).toBe( + 'Fort updates: pokestop | distance: 5000m ["name"]', + ); + }); + + it('normalises the spacing around pipes so the separators line up', () => { + expect(cleanRuleSummary('Grunt type: **Gold-stop**|distance: 1500m |gender: any clean')).toBe( + 'Grunt type: Gold-stop | distance: 1500m | gender: any clean', + ); + }); + + it('is empty for the absent, null and blank cases, so the card renders nothing', () => { + expect(cleanRuleSummary(undefined)).toBe(''); + expect(cleanRuleSummary(null)).toBe(''); + expect(cleanRuleSummary(' ')).toBe(''); + expect(cleanRuleSummary(' | ')).toBe(''); + }); +}); + +describe('ruleSummaryNeedsExpanding', () => { + it('offers the control on a real Pokemon description, which overflows two lines', () => { + const live = cleanRuleSummary( + '**Bulbasaur** | distance: 5000m | iv: 90%-100% | cp: 1200-4000 | level: 20-35 | stats: 0/0/0 - 15/15/15 | pvp ranking: greatpvp top100 (@0+) | size: XXS-XXL ', + ); + + expect(ruleSummaryNeedsExpanding(live)).toBe(true); + }); + + it('does not offer it on a short one, where there is nothing to expand', () => { + expect(ruleSummaryNeedsExpanding(cleanRuleSummary('Reward: **Pikachu** | distance: 500m '))).toBe(false); + expect(ruleSummaryNeedsExpanding(cleanRuleSummary('**Level 5 raids** without rsvp updates'))).toBe(false); + }); +}); 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 new file mode 100644 index 00000000..65c1b21d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts @@ -0,0 +1,37 @@ +/** + * Tidies the sentence PoracleNG renders for a tracking rule into something a card can print. + * + * 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 — + * the result is interpolated, never handed to innerHTML. + */ +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(); +} + +/** + * Above this many characters the two-line clamp will engage at the grid's minimum card width, so the + * line earns an expand control; below it, an affordance would point at nothing. + * + * A string length rather than a DOM measurement on purpose: the Pokemon page routinely renders several + * hundred cards, and measuring each one costs a layout pass per card to answer a question worth one + * comparison. Roughly 95 characters fit in two lines at the 300px minimum card width, so the threshold + * sits just under that and errs towards offering the control on a line that only just fits. + */ +export const RULE_SUMMARY_CLAMP_THRESHOLD = 90; + +export function ruleSummaryNeedsExpanding(cleaned: string): boolean { + return cleaned.length > RULE_SUMMARY_CLAMP_THRESHOLD; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index 2e283c9b..6170865c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -375,7 +375,9 @@ "TEST_SEND": "Send testnotifikation", "TAB_DELIVERY": "Levering", "COMMON_SETTINGS": "Fælles indstillinger", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} oprettet, {{duplicates}} spores allerede" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} oprettet, {{duplicates}} spores allerede", + "RULE_SUMMARY_MORE": "Vis hele reglen", + "RULE_SUMMARY_LESS": "Vis mindre" }, "RAIDS": { "RSVP_LABEL": "RSVP-notifikationer", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json index 3842b5ff..3a15c869 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -375,7 +375,9 @@ "TEST_SEND": "Testbenachrichtigung senden", "TAB_DELIVERY": "Zustellung", "COMMON_SETTINGS": "Allgemeine Einstellungen", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} erstellt, {{duplicates}} bereits verfolgt" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} erstellt, {{duplicates}} bereits verfolgt", + "RULE_SUMMARY_MORE": "Ganze Regel anzeigen", + "RULE_SUMMARY_LESS": "Weniger anzeigen" }, "RAIDS": { "RSVP_LABEL": "RSVP-Benachrichtigungen", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json index cd14f811..1b876b72 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -375,7 +375,9 @@ "TEST_SEND": "Send test notification", "TAB_DELIVERY": "Delivery", "COMMON_SETTINGS": "Common Settings", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} created, {{duplicates}} already tracked" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} created, {{duplicates}} already tracked", + "RULE_SUMMARY_MORE": "Show the whole rule", + "RULE_SUMMARY_LESS": "Show less" }, "RAIDS": { "RSVP_LABEL": "RSVP notifications", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json index c99d19cd..1cb5cdb8 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -375,7 +375,9 @@ "TEST_SEND": "Enviar notificación de prueba", "TAB_DELIVERY": "Entrega", "COMMON_SETTINGS": "Ajustes comunes", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} creadas, {{duplicates}} ya rastreadas" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} creadas, {{duplicates}} ya rastreadas", + "RULE_SUMMARY_MORE": "Mostrar la regla completa", + "RULE_SUMMARY_LESS": "Mostrar menos" }, "RAIDS": { "RSVP_LABEL": "Notificaciones RSVP", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json index 1aa389c8..f6e2e0e6 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -375,7 +375,9 @@ "TEST_SEND": "Envoyer une notification test", "TAB_DELIVERY": "Livraison", "COMMON_SETTINGS": "Paramètres communs", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} creees, {{duplicates}} deja suivies" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} creees, {{duplicates}} deja suivies", + "RULE_SUMMARY_MORE": "Afficher toute la règle", + "RULE_SUMMARY_LESS": "Afficher moins" }, "RAIDS": { "RSVP_LABEL": "Notifications RSVP", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json index ba01c567..67235be7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -375,7 +375,9 @@ "TEST_SEND": "Invia notifica di prova", "TAB_DELIVERY": "Consegna", "COMMON_SETTINGS": "Impostazioni comuni", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} create, {{duplicates}} gia tracciate" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} create, {{duplicates}} gia tracciate", + "RULE_SUMMARY_MORE": "Mostra tutta la regola", + "RULE_SUMMARY_LESS": "Mostra meno" }, "RAIDS": { "RSVP_LABEL": "Notifiche RSVP", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json index 63c35602..e447e869 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -375,7 +375,9 @@ "TEST_SEND": "Testmelding versturen", "TAB_DELIVERY": "Bezorging", "COMMON_SETTINGS": "Gemeenschappelijke Instellingen", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} aangemaakt, {{duplicates}} al gevolgd" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} aangemaakt, {{duplicates}} al gevolgd", + "RULE_SUMMARY_MORE": "Toon de hele regel", + "RULE_SUMMARY_LESS": "Toon minder" }, "RAIDS": { "RSVP_LABEL": "RSVP-meldingen", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json index 1a00f917..3ebb8037 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -375,7 +375,9 @@ "TEST_SEND": "Wyślij testowe powiadomienie", "TAB_DELIVERY": "Dostarczanie", "COMMON_SETTINGS": "Wspólne ustawienia", - "SNACK_CREATED_WITH_DUPLICATES": "Utworzono: {{count}}, juz sledzone: {{duplicates}}" + "SNACK_CREATED_WITH_DUPLICATES": "Utworzono: {{count}}, juz sledzone: {{duplicates}}", + "RULE_SUMMARY_MORE": "Pokaż całą regułę", + "RULE_SUMMARY_LESS": "Pokaż mniej" }, "RAIDS": { "RSVP_LABEL": "Powiadomienia RSVP", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json index 9d0220df..d4e5e274 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json @@ -375,7 +375,9 @@ "TEST_SEND": "Enviar notificação de teste", "TAB_DELIVERY": "Entrega", "COMMON_SETTINGS": "Configurações comuns", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} criados, {{duplicates}} ja monitorados" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} criados, {{duplicates}} ja monitorados", + "RULE_SUMMARY_MORE": "Mostrar a regra completa", + "RULE_SUMMARY_LESS": "Mostrar menos" }, "RAIDS": { "RSVP_LABEL": "Notificações RSVP", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json index 35673eba..ac7cae11 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -375,7 +375,9 @@ "TEST_SEND": "Enviar notificação de teste", "TAB_DELIVERY": "Entrega", "COMMON_SETTINGS": "Definições Comuns", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} criados, {{duplicates}} ja monitorizados" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} criados, {{duplicates}} ja monitorizados", + "RULE_SUMMARY_MORE": "Mostrar a regra completa", + "RULE_SUMMARY_LESS": "Mostrar menos" }, "RAIDS": { "RSVP_LABEL": "Notificações RSVP", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json index 1c53696a..d2de11ca 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -375,7 +375,9 @@ "TEST_SEND": "Skicka testnotis", "TAB_DELIVERY": "Leverans", "COMMON_SETTINGS": "Gemensamma inställningar", - "SNACK_CREATED_WITH_DUPLICATES": "{{count}} skapade, {{duplicates}} bevakas redan" + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} skapade, {{duplicates}} bevakas redan", + "RULE_SUMMARY_MORE": "Visa hela regeln", + "RULE_SUMMARY_LESS": "Visa mindre" }, "RAIDS": { "RSVP_LABEL": "RSVP-aviseringar", diff --git a/CHANGELOG.md b/CHANGELOG.md index 325fb3d2..207a7333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Every alarm card says in a sentence what its rule actually does.** A card gave you the species and a row of filter chips, and working out that a rule meant "Bulbasaur within 5 km, 90% IV or better, level 20 to 35" was a matter of decoding the chips. Poracle already writes that sentence -- it is the same wording the bot answers a `!pokemon` command with -- and was returning it on every read of this page, where it was thrown away. It now sits at the foot of each card, under a hairline, below the chips that still read first when you are scanning forty rules. Long ones are clamped to two lines with a control to open them. On the nine card types Poracle words well; fort-change cards keep their chips, because the sentence Poracle renders for them still has a raw JSON array in the middle of it. The line appears only when the language Poracle writes your alerts in is the language you are reading the site in, so a card never carries two languages at once -- and only on a Poracle new enough to send it, which older instances are not; in both cases the card is exactly what it was before ([#810](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/810)). - **Quiet one gym, one area or one species for a while, instead of deleting the rule.** A notifications-paused button now sits in the actions row of every alarm card that names something specific -- Pokemon and nest cards name a species, gym, raid and egg cards name a gym, max battle cards name a station -- and on the Areas page, on both the checklist rows and the selected-area chips. Pick a duration from fifteen minutes to a day and that subject goes quiet; the button becomes a live countdown, and pressing it again extends or lifts it. The dashboard grows a Quiet card while anything is silenced, which lists everything including quiet periods set from the Discord bot and offers Resume on each. This is not *Pause Alerts*, which stays what it was: account-wide, indefinite, and saved. A quiet period is one subject for a while, and Poracle holds it in memory -- a restart of the processor clears every one, which the sheet says out loud rather than showing a deadline it cannot keep. Requires PoracleNG 5.2.0 or newer; on anything older the control does not appear at all ([#809](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/809)). - **Showcases, Kecleon and Gold Stops are trackable in their own right.** They were only ever reachable through the invasion add dialog, which files them as ordinary invasions, so the alert that arrived described a Team Rocket encounter that was not there. There is now a *Pokéstop Events* page: pick the events you want, set a radius or areas, and Poracle formats them with its showcase template. The rules live in the same table invasions do, so the invasion list stops listing them and the dashboard counts them separately — the total across all alarm types is unchanged, but the Invasions figure will drop by however many event alarms you had, and a *Pokéstop Events* figure appears beside it ([#806](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/806)). - **The new page appears only where Poracle can serve it.** It needs PoracleNG 5.2.0 or newer; on anything older the nav item, the route and the API are all absent, and the three events stay where they are today in the invasion dialog rather than disappearing from a site that has nowhere else to put them. Operators can switch it off with the new `disable_showcase` toggle in admin settings, which is the same option name PoracleNG uses in its own `[general]` config ([#806](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/806)). diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Egg.cs b/Core/Pgan.PoracleWebNet.Core.Models/Egg.cs index ae9c1a1a..deeb1254 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Egg.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Egg.cs @@ -69,4 +69,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs b/Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs index 64eaae21..310f9c51 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs @@ -70,6 +70,20 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } /// diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Gym.cs b/Core/Pgan.PoracleWebNet.Core.Models/Gym.cs index 22dbfca1..573e97cd 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Gym.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Gym.cs @@ -68,4 +68,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Invasion.cs b/Core/Pgan.PoracleWebNet.Core.Models/Invasion.cs index c9fec400..af85c9e7 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Invasion.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Invasion.cs @@ -60,4 +60,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Lure.cs b/Core/Pgan.PoracleWebNet.Core.Models/Lure.cs index 52b2cef9..f3498013 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Lure.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Lure.cs @@ -56,4 +56,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattle.cs b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattle.cs index 17358dd2..b2aedf90 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattle.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattle.cs @@ -68,4 +68,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs b/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs index c6c78e5f..6fab5913 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs @@ -154,4 +154,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Nest.cs b/Core/Pgan.PoracleWebNet.Core.Models/Nest.cs index 2c4757c0..e60ed397 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Nest.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Nest.cs @@ -64,4 +64,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Quest.cs b/Core/Pgan.PoracleWebNet.Core.Models/Quest.cs index ec3fc17f..81ccbf90 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Quest.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Quest.cs @@ -81,4 +81,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Raid.cs b/Core/Pgan.PoracleWebNet.Core.Models/Raid.cs index 0d8ecdae..7d119779 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Raid.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Raid.cs @@ -85,4 +85,18 @@ public List? OverrideAreas { get; set; } + /// + /// The sentence PoracleNG renders for this rule, in the user's alert language. + /// + /// + /// Read-only, and read-only in both directions. PoracleNG returns it on every v1 per-type tracking + /// read with no query parameter asked for -- verified live against 5.1.0 and 5.2.1 -- and there is no + /// description column on any of the ten tracking tables, so it is rendered from the other + /// fields on the way out and means nothing on the way in. PoracleJsonHelper.ShouldStrip + /// therefore removes it from every write body. A PoracleNG too old to send it leaves this null. + /// + public string? Description + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs index 7a6e1103..d19808a8 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs @@ -62,8 +62,16 @@ public static JsonElement SerializeToElement(T value) } /// Names that must never reach PoracleNG on an alarm write. See . + /// + /// description is PoracleNG's own rendering of the rule, handed back on every read. No + /// tracking table has a column for it, so sending it back is at best ignored -- and it travels on + /// three separate write paths, because and + /// both copy stored properties through verbatim. Stripping it here covers + /// all three at once. See #810. + /// private static bool ShouldStrip(JsonProperty prop) => prop.NameEquals("profile_no") || + prop.NameEquals("description") || (prop.NameEquals("uid") && prop.Value.ValueKind == JsonValueKind.Number && prop.Value.GetInt32() == 0); private static JsonElement StripAlarmMetadata(JsonElement obj) diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/AlarmDescriptionPassthroughTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/AlarmDescriptionPassthroughTests.cs new file mode 100644 index 00000000..8a5f6e4e --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/AlarmDescriptionPassthroughTests.cs @@ -0,0 +1,196 @@ +using System.Text.Json; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// PoracleNG renders a sentence for every tracking rule and hands it back on every v1 per-type read, +/// with no query parameter asked for -- verified live against 5.1.0 and 5.2.1. It travels one way only: +/// no tracking table has a description column, so the field must reach the models on the way in +/// and must never reach PoracleNG on the way out. See #810. +/// +public class AlarmDescriptionPassthroughTests +{ + private static readonly JsonSerializerOptions SnakeCase = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + private const string LiveDescription = + "**Bulbasaur** | distance: 5000m | iv: 90%-100% | cp: 1200-4000 | level: 20-35"; + + private readonly Mock _proxy = new(); + private readonly Mock _featureGate = new(); + private readonly Mock _remapper = new(); + private readonly MonsterService _monsters; + + public AlarmDescriptionPassthroughTests() + { + this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + this._monsters = new MonsterService(this._proxy.Object, this._featureGate.Object, this._remapper.Object, CostumeCapabilityDoubles.Supported()); + } + + private static JsonElement StoredRow(int uid, string? description) => JsonSerializer.SerializeToElement( + new[] + { + new + { + uid, + id = "user1", + pokemon_id = 25, + distance = 5000, + min_iv = 90, + max_iv = 100, + template = "1", + description, + }, + }, + SnakeCase); + + /// Captures the body the service posts, so the tests can read the wire shape. + private JsonElement[] CaptureWrites() + { + var slot = new JsonElement[1]; + this._proxy + .Setup(p => p.CreateAsync("pokemon", "user1", It.IsAny())) + .Callback((_, _, body) => slot[0] = body.Clone()) + .ReturnsAsync(new TrackingCreateResult([], 0, 0, 0)); + return slot; + } + + /// + /// Edits go through UpdateByUidAsync, not CreateAsync -- #805 moved the pokemon update path onto + /// PoracleNG's uid-addressed v2 PUT, and the proxy chooses v1 or v2 underneath. Capturing the create + /// call here would observe nothing and assert nothing. + /// + private JsonElement[] CaptureUpdates() + { + var slot = new JsonElement[1]; + this._proxy + .Setup(p => p.UpdateByUidAsync("pokemon", "user1", It.IsAny(), It.IsAny())) + .Callback((_, _, _, body) => slot[0] = body.Clone()) + .ReturnsAsync(new TrackingUpdateResult(7, true)); + return slot; + } + + [Fact] + public async Task ReadCarriesTheDescriptionOntoTheModel() + { + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "user1")).ReturnsAsync(StoredRow(1, LiveDescription)); + + var monster = Assert.Single(await this._monsters.GetByUserAsync("user1", 1)); + + Assert.Equal(LiveDescription, monster.Description); + } + + [Fact] + public async Task AnOlderPoracleThatSendsNoDescriptionLeavesItNull() + { + // The degradation path: no field, no exception, the rest of the row still deserializes. + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "user1")).ReturnsAsync( + JsonSerializer.SerializeToElement(new[] { new { uid = 1, id = "user1", pokemon_id = 25 } }, SnakeCase)); + + var monster = Assert.Single(await this._monsters.GetByUserAsync("user1", 1)); + + Assert.Null(monster.Description); + Assert.Equal(25, monster.PokemonId); + } + + [Fact] + public async Task CreateDoesNotSendTheDescription() + { + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "user1")).ReturnsAsync(JsonDocument.Parse("[]").RootElement); + var written = this.CaptureWrites(); + + await this._monsters.CreateAsync( + "user1", + new Monster { PokemonId = 25, Distance = 5000, Description = LiveDescription }); + + Assert.False(written[0].TryGetProperty("description", out _)); + } + + [Fact] + public async Task CreateStillSendsEveryOtherProperty() + { + // The legitimate-case-still-passes half. Stripping one name must not disturb the rest of the body: + // an assertion that only says "description is gone" would not notice a strip that takes too much. + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "user1")).ReturnsAsync(JsonDocument.Parse("[]").RootElement); + var written = this.CaptureWrites(); + + var model = new Monster + { + PokemonId = 25, + Distance = 5000, + MinIv = 90, + Clean = 1, + Template = "1", + Description = LiveDescription, + }; + + await this._monsters.CreateAsync("user1", model); + + var expected = JsonSerializer.SerializeToElement(model, SnakeCase) + .EnumerateObject() + .Select(p => p.Name) + .Where(n => n is not ("description" or "profile_no" or "uid")) + .ToList(); + + Assert.NotEmpty(expected); + foreach (var name in expected) + { + Assert.True(written[0].TryGetProperty(name, out _), $"body lost {name}"); + } + } + + [Fact] + public async Task AnEditDoesNotEchoTheStoredDescriptionBack() + { + // TrackingFieldPreserver copies every stored property the model does not state (#730), so the + // description was going back out on every edit even before the models carried it. + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "user1")).ReturnsAsync(StoredRow(7, LiveDescription)); + var written = this.CaptureUpdates(); + + await this._monsters.UpdateAsync( + "user1", + new Monster { Uid = 7, Id = "user1", PokemonId = 25, Distance = 6000 }); + + Assert.False(written[0].TryGetProperty("description", out _)); + } + + [Fact] + public async Task BulkDistanceRewriteDropsTheDescriptionAndKeepsTheRest() + { + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "user1")).ReturnsAsync(StoredRow(7, LiveDescription)); + var written = this.CaptureWrites(); + + await this._monsters.UpdateDistanceByUserAsync("user1", 1, 1234); + + var row = written[0].EnumerateArray().Single(); + Assert.False(row.TryGetProperty("description", out _)); + Assert.Equal(25, row.GetProperty("pokemon_id").GetInt32()); + Assert.Equal(90, row.GetProperty("min_iv").GetInt32()); + Assert.Equal(1234, row.GetProperty("distance").GetInt32()); + } + + [Fact] + public async Task TheCollisionGuardIsBlindToTheDescription() + { + // The reconciler compares submitted against stored field by field. A description present on one + // side only would read as a difference -- either refusing a legitimate edit (#553) or hiding a + // real collision (#561). It is listed in AssignedByPoracle/IgnoredForNoOp; this pins that. + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "user1")).ReturnsAsync(StoredRow(7, LiveDescription)); + this._proxy + .Setup(p => p.CreateAsync("pokemon", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 0, 0)); + + // Same row, same values: a no-op edit, which must be allowed rather than read as a collision. + var exception = await Record.ExceptionAsync(() => this._monsters.UpdateAsync( + "user1", + new Monster { Uid = 7, Id = "user1", PokemonId = 25, Distance = 5000, MinIv = 90, Template = "1" })); + + Assert.Null(exception); + } +}