Files
paliad/internal/services/deadline_search_service_test.go
mAi 24f3baf61f mAi: #97 - t-paliad-266 — event-type modal: narrow cross-cutting trigger pills by court system
Cross-cutting Wiedereinsetzung sub-rows (PatG §123 / ZPO §233 /
EPC Art.122 / DPMA PatG §123 / UPC R.320) used to bypass the
forum-bucket chip selection by design — every chip combination
returned all five rows. m/paliad#97: chip the chips through
to triggers via legal_source inference.

  - mig 123 backfills the missing deadline_rules row for trigger
    207 (UPC R.320 Wiedereinsetzung, orphaned by mig 063 because
    mig 092 dropped event_deadlines before that path was seeded)
    and rebuilds paliad.deadline_search with a LEFT JOIN on
    deadline_rules so cross-cutting trigger pills carry their
    structured legal_source.
  - DeadlineSearchService gains ForumToLegalSourcePrefixes (10
    buckets → UPC. / DE.ZPO. / DE.PatG. / EU.EPC + EU.EPÜ)
    paralleling ForumToProceedingCodes. Rule pills still narrow
    by proceeding_code; trigger pills now narrow by legal_source
    LIKE prefix. Multiple chips union the prefix allow-list as
    expected.
  - Live golden-table test gains a Wiedereinsetzung×forum matrix
    plus a multi-chip union case, and the existing 4-pill assertion
    is updated to the now-5-pill state (mig 063 added trigger 207).

Branch: mai/hermes/gitster-event-type-modal.
2026-05-25 15:36:08 +02:00

648 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 5 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 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
// 200..203 from mig 046 plus 207 from mig 063.
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, 207: true}
if len(triggerIDs) != 5 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
}
for _, id := range triggerIDs {
if !want[id] {
t.Errorf("Wiedereinsetzung card has unexpected trigger id %d", id)
}
}
})
// t-paliad-266 / m/paliad#97 — court-system filter narrows
// cross-cutting trigger pills via legal_source inference.
t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
// Each pair is (forum slug, expected trigger_event_ids).
cases := []struct {
name string
forum string
wantTrigIDs []int64
}{
{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{tc.forum},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{}
for _, id := range tc.wantTrigIDs {
want[id] = true
}
for id := range got {
if !want[id] {
t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
for id := range want {
if !got[id] {
t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
})
}
})
t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{"upc_cfi", "de_lg"},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{201: true, 207: true}
for id := range got {
if !want[id] {
t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
}
}
for id := range want {
if !got[id] {
t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
}
}
})
t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
// No forum chips = all 5 triggers stay visible.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
count := 0
for _, p := range card.Pills {
if p.Kind == "trigger" {
count++
}
}
if count != 5 {
t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
}
})
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
}