Five intertwined fixes m surfaced in the interactive session:
1. **Jurisdiction prefix on the picked proceeding** — the collapsed
summary chip and the result header now read "UPC Verletzungsverfahren"
/ "DE Verletzungsklage (LG)" instead of the bare proceeding name.
Disambiguates the 4 redundancies in the corpus once the picker
collapses. Driven by .proceeding-group[data-forum] which is already
on every group.
2. **Trigger Event label = root rule** — step 2's "Auslösendes Ereignis"
line now shows the first event in the proceeding (e.g. Klageerhebung,
Nichtigkeitsklage) instead of the proceeding name. Populated from
the calc response (isRootEvent=true) on every render; em-dash
placeholder while step 3 hasn't rendered yet. lang-change keeps it
coherent.
3. **Flag rows on /tools/verfahrensablauf** — Slice 1 of t-paliad-179
stripped the with_ccr / with_amend / with_cci toggles when it lifted
the shared renderer; they never came back. Lifted the 4 existing
rows from fristenrechner.tsx plus 2 new with_po rows (RoP 19.1
preliminary objection, mig 095) — same wiring + show/hide rules on
both surfaces. with_amend stays nested under with_ccr on upc.inf.cfi
(R.30 only with a CCR).
4. **Rule references → youpc.org/laws links** — new
BuildLegalSourceURL(src) maps the structured legal_source code to
the youpc permalink for the UPC corpus (UPCRoP / UPCA / UPCS today;
39 of 91 active rules carry UPC.RoP.* and now link). DE/EPA/EU
bodies have no youpc home yet and render as plain display text —
filed as m/paliad#39. Wired through UIDeadline.LegalSourceDisplay +
LegalSourceURL so deadlineCardHtml can render <a target="_blank"
rel="noopener"> when the URL is set.
5. **R.19 label: "Vorab-Einrede" → "Einspruch"** — m's correction. DE
only (EN canonical UPC RoP term stays "Preliminary objection").
Client-side change only — i18n + JSX fallbacks. The matching DB
rename on the two rule-name rows folds into joule's broader mig 097
(legal-citation backfill, t-paliad-208 follow-up). The live UPDATE
applied during the session is captured under that audit reason; the
no-op when joule's mig re-applies is harmless.
Build hygiene:
- go build ./... + go vet ./... clean
- new test TestBuildLegalSourceURL covers UPC corpus + DE/EPA/EU
fall-through + edge cases (empty input, malformed source)
- bun run build clean (2417 i18n keys total)
Rebased on origin/main @ d126913 (ohm's submission_code rename
workstream B) — no conflicts in this commit's surface area.
Branch: mai/fermi/interactive-session. NOT self-merged.
547 lines
17 KiB
Go
547 lines
17 KiB
Go
package services
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/jmoiron/sqlx"
|
||
_ "github.com/lib/pq"
|
||
|
||
"mgit.msbls.de/m/paliad/internal/db"
|
||
)
|
||
|
||
// TestFormatLegalSourceDisplay covers the structured-form → display-form
|
||
// conversion the search API exposes alongside legal_source.
|
||
func TestFormatLegalSourceDisplay(t *testing.T) {
|
||
cases := []struct {
|
||
in, want string
|
||
}{
|
||
{"UPC.RoP.23.1", "UPC RoP R.23(1)"},
|
||
{"UPC.RoP.139", "UPC RoP R.139"},
|
||
{"UPC.RoP.220.1", "UPC RoP R.220(1)"},
|
||
{"DE.PatG.82.1", "PatG §82(1)"},
|
||
{"DE.PatG.111.1", "PatG §111(1)"},
|
||
{"DE.PatG.59.3", "PatG §59(3)"},
|
||
{"DE.ZPO.276.1", "ZPO §276(1)"},
|
||
{"DE.ZPO.517", "ZPO §517"},
|
||
{"EU.EPÜ.108", "EPÜ Art.108"},
|
||
{"EU.EPÜ.112a", "EPÜ Art.112a"},
|
||
{"EU.EPC-R.79.1", "EPC R.79(1)"},
|
||
{"EU.RPBA.12.1.c", "RPBA Art.12(1)(c)"},
|
||
{"", ""},
|
||
}
|
||
for _, c := range cases {
|
||
got := FormatLegalSourceDisplay(c.in)
|
||
if got != c.want {
|
||
t.Errorf("FormatLegalSourceDisplay(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestBuildLegalSourceURL covers the structured-form → youpc.org/laws
|
||
// permalink mapping. Only the UPC corpus has a youpc home today;
|
||
// DE/EPA/EU bodies fall through to the empty string and the renderer
|
||
// shows display text without a link.
|
||
func TestBuildLegalSourceURL(t *testing.T) {
|
||
cases := []struct {
|
||
in, want string
|
||
}{
|
||
{"UPC.RoP.23.1", "https://youpc.org/laws#UPCRoP.023"},
|
||
{"UPC.RoP.139", "https://youpc.org/laws#UPCRoP.139"},
|
||
{"UPC.RoP.220.1", "https://youpc.org/laws#UPCRoP.220"},
|
||
{"UPC.RoP.29.a", "https://youpc.org/laws#UPCRoP.029"},
|
||
{"UPC.RoP.49.2.a", "https://youpc.org/laws#UPCRoP.049"},
|
||
{"UPC.RoP.19.1", "https://youpc.org/laws#UPCRoP.019"},
|
||
{"UPC.UPCA.83", "https://youpc.org/laws#UPCA.083"},
|
||
{"UPC.UPCS.40.1", "https://youpc.org/laws#UPCS.040"},
|
||
{"DE.PatG.82.1", ""},
|
||
{"DE.ZPO.276.1", ""},
|
||
{"EU.EPÜ.108", ""},
|
||
{"EU.EPC-R.79.1", ""},
|
||
{"EU.RPBA.12.1.c", ""},
|
||
{"UPC.RoP", ""},
|
||
{"", ""},
|
||
}
|
||
for _, c := range cases {
|
||
got := BuildLegalSourceURL(c.in)
|
||
if got != c.want {
|
||
t.Errorf("BuildLegalSourceURL(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNormalizeQuery covers the input-side legal-prefix stripping that
|
||
// keeps "§ 82" / "Art. 108" findable against structured legal_source
|
||
// values that don't carry the prefix.
|
||
func TestNormalizeQuery(t *testing.T) {
|
||
cases := []struct {
|
||
in, want string
|
||
}{
|
||
{"§ 82", "82"},
|
||
{"§82", "82"},
|
||
{"Art. 108", "108"},
|
||
{"art.108", "108"},
|
||
{"Section 276", "276"},
|
||
{" Wiedereinsetzung ", "Wiedereinsetzung"},
|
||
{"RoP 23", "RoP 23"}, // RoP is meaningful, kept verbatim
|
||
{"", ""},
|
||
}
|
||
for _, c := range cases {
|
||
got := normalizeQuery(c.in)
|
||
if got != c.want {
|
||
t.Errorf("normalizeQuery(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestDeadlineSearch is the Phase C golden table — the binding spec for
|
||
// what the search backend must answer for a fixed set of HLC vocabulary
|
||
// queries. Skipped when TEST_DATABASE_URL is unset, mirroring the other
|
||
// live-DB tests in this package.
|
||
func TestDeadlineSearch(t *testing.T) {
|
||
url := os.Getenv("TEST_DATABASE_URL")
|
||
if url == "" {
|
||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||
}
|
||
if err := db.ApplyMigrations(url); err != nil {
|
||
t.Fatalf("apply migrations: %v", err)
|
||
}
|
||
pool, err := sqlx.Connect("postgres", url)
|
||
if err != nil {
|
||
t.Fatalf("connect: %v", err)
|
||
}
|
||
defer pool.Close()
|
||
|
||
ctx := context.Background()
|
||
if err := RefreshSearchView(ctx, pool); err != nil {
|
||
t.Fatalf("refresh search view: %v", err)
|
||
}
|
||
|
||
svc := NewDeadlineSearchService(pool)
|
||
|
||
t.Run("Klageerwiderung returns the statement-of-defence concept with multi-jurisdiction pills", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "statement-of-defence")
|
||
// Expected at minimum: UPC R.23, ZPO §276, PatG §82, EPC R.79, PatG §59.
|
||
// The actual data has 9 rule rows (upc.inf.cfi, upc.rev.cfi,
|
||
// upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, de.inf.lg,
|
||
// de.null.bpatg, epa.opp.opd, dpma.opp.dpma).
|
||
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
|
||
mustHaveLegalSource(t, card, "DE.ZPO.276.1")
|
||
mustHaveLegalSource(t, card, "DE.PatG.82.1")
|
||
mustHaveLegalSource(t, card, "EU.EPC-R.79.1")
|
||
mustHaveLegalSource(t, card, "DE.PatG.59.3")
|
||
mustHaveProceedingCodes(t, card, CodeUPCInfringement, CodeDEInfringementLG, CodeDENullityBPatG, CodeEPAOpposition, CodeDPMAOpposition)
|
||
})
|
||
|
||
t.Run("RoP 23 returns the UPC R.23 hit", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "RoP 23", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if len(resp.Cards) == 0 {
|
||
t.Fatalf("expected at least one card for 'RoP 23', got 0")
|
||
}
|
||
// Statement-of-defence is the canonical R.23 concept; it must be
|
||
// among the cards (not necessarily rank 1 because rule_code-based
|
||
// trigram only weighs 0.9, and several rules with adjacent codes
|
||
// also hit).
|
||
card := findCardBySlug(t, resp, "statement-of-defence")
|
||
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
|
||
})
|
||
|
||
t.Run("§ 82 returns the BPatG nullity-defence card", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "§ 82", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// PatG §82(1) is the BPatG Klageerwiderung anchor — should fall
|
||
// under the statement-of-defence concept.
|
||
card := findCardBySlug(t, resp, "statement-of-defence")
|
||
mustHaveLegalSource(t, card, "DE.PatG.82.1")
|
||
})
|
||
|
||
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 trigger pills", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "wiedereinsetzung")
|
||
// Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
|
||
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
|
||
// 200..203 from migration 046.
|
||
triggerIDs := []int64{}
|
||
for _, p := range card.Pills {
|
||
if p.Kind != "trigger" {
|
||
t.Errorf("Wiedereinsetzung card has non-trigger pill: kind=%q proc=%v", p.Kind, p.Proceeding)
|
||
}
|
||
if p.TriggerEventID != nil {
|
||
triggerIDs = append(triggerIDs, *p.TriggerEventID)
|
||
}
|
||
}
|
||
want := map[int64]bool{200: true, 201: true, 202: true, 203: true}
|
||
if len(triggerIDs) != 4 {
|
||
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
|
||
}
|
||
for _, id := range triggerIDs {
|
||
if !want[id] {
|
||
t.Errorf("Wiedereinsetzung card has unexpected trigger id %d", id)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// Statement-of-defence is filed by the defendant. Filtering
|
||
// party=claimant should NOT drop the concept entirely — the
|
||
// effective_party can vary per pill (e.g. epa.opp.opd Erwiderung
|
||
// is owed by the patentee/claimant). At least it must not
|
||
// return any card with EVERY pill on defendant side.
|
||
for _, c := range resp.Cards {
|
||
allDefendant := true
|
||
for _, p := range c.Pills {
|
||
if p.Party != "defendant" {
|
||
allDefendant = false
|
||
break
|
||
}
|
||
}
|
||
if allDefendant {
|
||
t.Errorf("party=claimant filter returned a card whose every pill is defendant: %q", c.Concept.Slug)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("source filter narrows to UPC.RoP-prefixed pills only", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Source: "UPC.RoP", Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.LegalSource != nil && !startsWith(*p.LegalSource, "UPC.RoP") {
|
||
t.Errorf("source=UPC.RoP filter leaked a non-UPC.RoP pill: %q", *p.LegalSource)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("empty query returns empty cards (browse surface is Phase D)", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if len(resp.Cards) != 0 {
|
||
t.Errorf("empty q should return 0 cards, got %d", len(resp.Cards))
|
||
}
|
||
})
|
||
|
||
t.Run("limit is capped at MaxLimit", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 1000, MaxLimit: 5})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
if len(resp.Cards) > 5 {
|
||
t.Errorf("limit cap not enforced: got %d cards, want ≤ 5", len(resp.Cards))
|
||
}
|
||
})
|
||
|
||
t.Run("legal_source_display is rendered for rule pills with a legal_source", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Limit: 12})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
card := findCardBySlug(t, resp, "statement-of-defence")
|
||
for _, p := range card.Pills {
|
||
if p.LegalSource == nil {
|
||
continue
|
||
}
|
||
if p.LegalSourceDisplay == nil || *p.LegalSourceDisplay == "" {
|
||
t.Errorf("rule pill with legal_source=%q is missing legal_source_display", *p.LegalSource)
|
||
}
|
||
}
|
||
})
|
||
|
||
// v4 (t-paliad-136): event-category narrowing must apply per-leaf
|
||
// (concept_id, proceeding_type_code) tuples, not just concept_id. The
|
||
// v3 implementation collapsed tuples to flat concept_ids and surfaced
|
||
// pills for every proceeding the matview had a row for. m's repro:
|
||
// picking "CMS-Eingang → Gegenseite → UPC Verletzung" leaked DE/EPA/
|
||
// DPMA pills.
|
||
ec := NewEventCategoryService(pool)
|
||
svc.SetEventCategoryService(ec)
|
||
|
||
t.Run("v4 event_category_slug narrows pills to per-leaf proceeding (UPC infringement subtree)", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: "cms-eingang.gegenseite.upc-inf",
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// Every rule pill must be a UPC proceeding. The seed maps every
|
||
// concept under this subtree to upc.inf.cfi or upc.apl.merits — no
|
||
// DE/EPA/DPMA codes should leak.
|
||
allowedRulePrefix := []string{"upc."}
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.Kind != "rule" {
|
||
continue
|
||
}
|
||
if p.Proceeding == nil {
|
||
t.Errorf("rule pill on %q has no proceeding", c.Concept.Slug)
|
||
continue
|
||
}
|
||
ok := false
|
||
for _, prefix := range allowedRulePrefix {
|
||
if strings.HasPrefix(p.Proceeding.Code, prefix) {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
t.Errorf("subtree narrowing leaked non-UPC pill on %q: proc=%s rule=%s",
|
||
c.Concept.Slug, p.Proceeding.Code, p.RuleLocalCode)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("v4 event_category_slug honours per-tuple narrowing (klageerwiderung-mit-ccr leaf)", func(t *testing.T) {
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: "cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr",
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
// Junction maps three concepts × upc.inf.cfi for this leaf:
|
||
// defence-to-counterclaim-for-revocation, application-to-amend,
|
||
// reply-to-defence. Every pill must be upc.inf.cfi.
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.Kind != "rule" {
|
||
continue
|
||
}
|
||
if p.Proceeding == nil || p.Proceeding.Code != CodeUPCInfringement {
|
||
code := "(nil)"
|
||
if p.Proceeding != nil {
|
||
code = p.Proceeding.Code
|
||
}
|
||
t.Errorf("klageerwiderung-mit-ccr leaf leaked non-%s pill on %q: proc=%s",
|
||
CodeUPCInfringement, c.Concept.Slug, code)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("v4 trigger-only concept under leaf with NULL proc surfaces", func(t *testing.T) {
|
||
// frist-verpasst.epa maps wiedereinsetzung and weiterbehandlung
|
||
// with NULL proceeding_type_code (cross-cutting). Both must
|
||
// appear and both must surface their trigger pills.
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: "frist-verpasst.epa",
|
||
Limit: 50,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
mustHaveCard := func(slug string) ConceptCard {
|
||
for _, c := range resp.Cards {
|
||
if c.Concept.Slug == slug {
|
||
return c
|
||
}
|
||
}
|
||
t.Fatalf("missing card %q under frist-verpasst.epa; got %v", slug, conceptSlugs(resp.Cards))
|
||
return ConceptCard{}
|
||
}
|
||
wcard := mustHaveCard("wiedereinsetzung")
|
||
// Trigger pills (no proceeding) must be present.
|
||
hasTrigger := false
|
||
for _, p := range wcard.Pills {
|
||
if p.Kind == "trigger" {
|
||
hasTrigger = true
|
||
break
|
||
}
|
||
}
|
||
if !hasTrigger {
|
||
t.Errorf("wiedereinsetzung card under frist-verpasst.epa has no trigger pills")
|
||
}
|
||
})
|
||
|
||
t.Run("v4 forum filter ANDs against subtree narrowing", func(t *testing.T) {
|
||
// Pick the upc.inf.cfi subtree and add a forum chip that excludes
|
||
// upc.inf.cfi — the result must be empty (the user contradicted
|
||
// themselves; empty is the correct UX).
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: "cms-eingang.gegenseite.upc-inf",
|
||
Forums: []string{"epa_opp"},
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search: %v", err)
|
||
}
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.Kind == "rule" {
|
||
t.Errorf("AND-narrowing produced a rule pill where forum + subtree contradict: %q proc=%v",
|
||
c.Concept.Slug, p.Proceeding)
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
// Invariant test: walk every leaf with at least one non-NULL
|
||
// proceeding_type_code in the junction and assert that the search
|
||
// result for that leaf only surfaces pills whose (concept, proc)
|
||
// is authorised by an outcome row. This is the regression gate
|
||
// that would have caught the v3 bug at PR time.
|
||
t.Run("v4 invariant: per-leaf pills are authorised by junction tuples", func(t *testing.T) {
|
||
leafRows, err := pool.QueryxContext(ctx, `
|
||
SELECT DISTINCT ec.slug
|
||
FROM paliad.event_categories ec
|
||
JOIN paliad.event_category_concepts ecc ON ecc.event_category_id = ec.id
|
||
WHERE ec.is_active AND ec.is_leaf
|
||
AND ecc.proceeding_type_code IS NOT NULL
|
||
`)
|
||
if err != nil {
|
||
t.Fatalf("list leaves: %v", err)
|
||
}
|
||
var slugs []string
|
||
for leafRows.Next() {
|
||
var s string
|
||
if err := leafRows.Scan(&s); err != nil {
|
||
leafRows.Close()
|
||
t.Fatalf("scan leaf: %v", err)
|
||
}
|
||
slugs = append(slugs, s)
|
||
}
|
||
leafRows.Close()
|
||
if len(slugs) == 0 {
|
||
t.Fatalf("expected at least one leaf with non-NULL proc; got 0")
|
||
}
|
||
|
||
for _, slug := range slugs {
|
||
outcomes, err := ec.ConceptsForSlug(ctx, slug)
|
||
if err != nil {
|
||
t.Fatalf("ConceptsForSlug(%q): %v", slug, err)
|
||
}
|
||
// Build tuple validator.
|
||
isAllowed := func(conceptID, proc string) bool {
|
||
for _, o := range outcomes {
|
||
if o.ConceptID != conceptID {
|
||
continue
|
||
}
|
||
if o.ProceedingTypeCode == nil {
|
||
return true
|
||
}
|
||
if *o.ProceedingTypeCode == proc {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
resp, err := svc.Search(ctx, "", SearchOptions{
|
||
EventCategorySlug: slug,
|
||
Limit: 200,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("search %q: %v", slug, err)
|
||
}
|
||
for _, c := range resp.Cards {
|
||
for _, p := range c.Pills {
|
||
if p.Kind != "rule" {
|
||
continue
|
||
}
|
||
proc := ""
|
||
if p.Proceeding != nil {
|
||
proc = p.Proceeding.Code
|
||
}
|
||
if !isAllowed(c.Concept.ID, proc) {
|
||
t.Errorf("leaf %s leaked unauthorised pill: concept=%s proc=%s rule=%s",
|
||
slug, c.Concept.Slug, proc, p.RuleLocalCode)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
func findCardBySlug(t *testing.T, resp *SearchResponse, slug string) ConceptCard {
|
||
t.Helper()
|
||
for _, c := range resp.Cards {
|
||
if c.Concept.Slug == slug {
|
||
return c
|
||
}
|
||
}
|
||
t.Fatalf("expected concept slug %q in cards, got: %v", slug, conceptSlugs(resp.Cards))
|
||
return ConceptCard{}
|
||
}
|
||
|
||
func conceptSlugs(cards []ConceptCard) []string {
|
||
out := make([]string, len(cards))
|
||
for i, c := range cards {
|
||
out[i] = c.Concept.Slug
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mustHaveLegalSource(t *testing.T, card ConceptCard, want string) {
|
||
t.Helper()
|
||
for _, p := range card.Pills {
|
||
if p.LegalSource != nil && *p.LegalSource == want {
|
||
return
|
||
}
|
||
}
|
||
t.Errorf("concept %q card missing pill with legal_source=%q. Got: %v", card.Concept.Slug, want, pillSources(card.Pills))
|
||
}
|
||
|
||
func mustHaveProceedingCodes(t *testing.T, card ConceptCard, codes ...string) {
|
||
t.Helper()
|
||
have := map[string]bool{}
|
||
for _, p := range card.Pills {
|
||
if p.Proceeding != nil {
|
||
have[p.Proceeding.Code] = true
|
||
}
|
||
}
|
||
for _, c := range codes {
|
||
if !have[c] {
|
||
t.Errorf("concept %q card missing proceeding pill %q. Got: %v", card.Concept.Slug, c, mapKeys(have))
|
||
}
|
||
}
|
||
}
|
||
|
||
func pillSources(pills []Pill) []string {
|
||
out := []string{}
|
||
for _, p := range pills {
|
||
if p.LegalSource != nil {
|
||
out = append(out, *p.LegalSource)
|
||
} else {
|
||
out = append(out, "(null)")
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func mapKeys(m map[string]bool) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func startsWith(s, prefix string) bool {
|
||
if len(s) < len(prefix) {
|
||
return false
|
||
}
|
||
return s[:len(prefix)] == prefix
|
||
}
|