Files
gogobee/cmd/open5e-import/desc_overrides_test.go
prosolis 1512f6cc50 UX S3: SRD copy-edit pass — sanitize jargon + curated overrides
Plumbed through the open5e importer so regen stays safe:

- New cmd/open5e-import/desc_overrides.go holds two per-ID override
  maps (spellDescOverride, magicItemDescOverride) and a regex-driven
  cleanDesc sanitizer. Override wins outright; otherwise the SRD
  first-sentence runs through cleanDesc, which strips the phrases
  the S3 acceptance criteria forbid (saving throw[s], spell slot,
  within range, 5-foot, DC <n>, "(save DC X)" parentheticals,
  "constitution score is N", "out to a range of N feet"). A small
  post-pass repairs the orphan stubs the strippers leave behind
  (" and." trailers, "must make." after the saving-throw object
  is gone).

- gen.go (spells) + magicitems.go now call spellDescription /
  magicItemDescription instead of raw firstSentence; same hand-
  authored override pattern, same cleanDesc fallthrough.

- Override coverage: the 19 SRD-only spells that show up in
  defaultKnownSpells (call_lightning, charm_person, vicious_mockery,
  …) plus ~35 high-visibility magic items (Amulet of Health, every
  Belt of Giant Strength variant, Cloak of Displacement, etc.).
  Tone is outcome-first second-person with bite — these surface
  in the spellbook and the curio shop, so they get to be funny.

- tuned.go (R21) + magicitems.go strip "(...)" from emitted Names
  via stripNameParenthetical. Slug keeps the variant; only the
  display text loses the qualifier. Two bestiary entries
  (giant_rat_diseased, deep_gnome_svirfneblin) and stone_of_good_luck
  affected.

- Regenerated all three data files. Acceptance grep is clean:
  zero hits for any banned phrase in Description/Desc fields.

- New cmd/open5e-import/desc_overrides_test.go covers the
  sanitizer regressions, orphan-repair, name-strip, and the
  override-wins-but-fallthrough-still-sanitizes path.

Conflicts: none. S4 (magic-item UX) wanted this in first so the
new curio renderer consumes clean text — ready for it now.
2026-05-14 21:53:06 -07:00

123 lines
3.8 KiB
Go

package main
import (
"strings"
"testing"
)
// TestCleanDescStripsJargon — the S3 acceptance criteria translated into
// regression cases. Any phrase listed here must not survive the sanitizer.
func TestCleanDescStripsJargon(t *testing.T) {
cases := []struct {
name string
in string
mustNot []string
}{
{
name: "saving throw + DC",
in: "Each creature in the area must make a Dexterity saving throw (DC 15) or take damage.",
mustNot: []string{
"saving throw", "DC 15", "DC15",
},
},
{
name: "saving throws plural",
in: "You have advantage on saving throws against spells.",
mustNot: []string{"saving throws"},
},
{
name: "within range",
in: "Hurl a bolt at a creature within range.",
mustNot: []string{"within range"},
},
{
name: "5-foot radius",
in: "Each creature in a 5-foot radius takes damage.",
mustNot: []string{"5-foot"},
},
{
name: "spell slot upcast clause",
in: "When you cast this spell using a spell slot of 2nd level or higher, the damage increases.",
mustNot: []string{"spell slot"},
},
{
name: "constitution score",
in: "Your Constitution score is 19 while you wear this amulet.",
mustNot: []string{"Constitution score"},
},
{
name: "(save DC X) parenthetical",
in: "You can use an action to cast the detect thoughts spell (save DC 13) from it.",
mustNot: []string{"(save", "DC 13"},
},
{
name: "wisdom save",
in: "Forces a wisdom save against the caster.",
mustNot: []string{"wisdom save"},
},
{
name: "out to a range of feet",
in: "You have darkvision out to a range of 60 feet.",
mustNot: []string{"feet", "out to a range"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cleanDesc(tc.in)
low := strings.ToLower(got)
for _, ban := range tc.mustNot {
if strings.Contains(low, strings.ToLower(ban)) {
t.Errorf("cleanDesc kept banned phrase %q\n in: %q\n out: %q", ban, tc.in, got)
}
}
})
}
}
// TestCleanDescRepairsOrphans — the strippers leave dangling stubs ("must
// make.", " and."); the post-pass is meant to tidy them so player-facing
// prose doesn't end mid-clause.
func TestCleanDescRepairsOrphans(t *testing.T) {
in := "You gain a +1 bonus to ability checks and saving throws."
got := cleanDesc(in)
if strings.HasSuffix(got, " and.") {
t.Errorf("orphan trailing 'and.' survived: %q", got)
}
in2 := "Up to three creatures of your choice that you can see must make charisma saving throws."
got2 := cleanDesc(in2)
if strings.Contains(strings.ToLower(got2), "must make.") {
t.Errorf("orphan 'must make.' survived: %q", got2)
}
}
// TestStripNameParenthetical — bestiary R21 / magic-item alias names.
func TestStripNameParenthetical(t *testing.T) {
cases := map[string]string{
"Giant Rat (Diseased)": "Giant Rat",
"Deep Gnome (Svirfneblin)": "Deep Gnome",
"Stone of Good Luck (Luckstone)": "Stone of Good Luck",
"Ordinary Name": "Ordinary Name",
"": "",
}
for in, want := range cases {
if got := stripNameParenthetical(in); got != want {
t.Errorf("stripNameParenthetical(%q) = %q, want %q", in, got, want)
}
}
}
// TestSpellOverrideWins — the curated descriptions for the SRD-only default
// spells must beat the auto first-sentence path.
func TestSpellOverrideWins(t *testing.T) {
got := spellDescription("vicious_mockery", "anything could go here")
if got != spellDescOverride["vicious_mockery"] {
t.Errorf("override didn't win: got %q", got)
}
// Unknown ID falls through to cleanDesc.
in := "Hurl a bolt at a creature within range."
got = spellDescription("unknown_spell_xyz", in)
if strings.Contains(got, "within range") {
t.Errorf("fallthrough didn't sanitize: %q", got)
}
}