505 lines
18 KiB
Go
505 lines
18 KiB
Go
package processing
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
|
|
)
|
|
|
|
type stubCompleter struct {
|
|
fn func(system, user string) (Completion, error)
|
|
}
|
|
|
|
func (s stubCompleter) Complete(_ context.Context, system, user string) (Completion, error) {
|
|
return s.fn(system, user)
|
|
}
|
|
|
|
func TestResolveSteps(t *testing.T) {
|
|
cases := map[string][]string{
|
|
"full": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize, StepAIEnhance},
|
|
"enhance_only": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance},
|
|
"title": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance},
|
|
"description": {StepNormalize, StepParseSpecs, StepFillFields, StepCategorize, StepAIEnhance},
|
|
"attributes_only": {StepNormalize, StepParseSpecs, StepFillFields},
|
|
"eprel_only": {StepNormalize, StepParseSpecs, StepEPREL},
|
|
"normalize_only": {StepNormalize},
|
|
"categorize": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize},
|
|
"categorize_only": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize},
|
|
"categorize_enhance": {StepNormalize, StepParseSpecs, StepFillFields, StepEPREL, StepCategorize, StepAIEnhance},
|
|
}
|
|
for in, want := range cases {
|
|
got := resolveSteps(in)
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("%s: got %v want %v", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_fullMock(t *testing.T) {
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"Red Runner","description":"A fine shoe."}`, TotalTokens: 7, Raw: map[string]any{"ok": true}}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
EPREL: nil,
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "123", Name: "Runner", Description: "shoe",
|
|
Mapped: map[string]any{"brand": "Acme"},
|
|
}, "full", nil, StepPolicy{AllowAI: true, AllowEPREL: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.ProcessedName != "Red Runner" {
|
|
t.Fatalf("name=%q", out.ProcessedName)
|
|
}
|
|
if out.TotalTokens < 1 {
|
|
t.Fatalf("tokens=%d", out.TotalTokens)
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_enhanceOnly(t *testing.T) {
|
|
calls := 0
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
calls++
|
|
return Completion{Text: `{"name":"N","description":"D"}`, TotalTokens: 1}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
_, err := e.RunSteps(context.Background(), "co", ProductInput{Name: "x"}, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("calls=%d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_enhanceOnly_keepsPriorCategoryWhenMappedEmpty(t *testing.T) {
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"N","description":"D"}`, TotalTokens: 1}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "5905575903198",
|
|
Name: "Adler",
|
|
Mapped: map[string]any{"name": "Adler", "description": "radiator"},
|
|
PriorCategory: "Radiators",
|
|
}, "enhance_only", nil, StepPolicy{AllowAI: true, AllowEPREL: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Category != "Radiators" {
|
|
t.Fatalf("category=%q want Radiators", out.Category)
|
|
}
|
|
if src, _ := out.FieldSources["category"].(string); src != "prior_processed" {
|
|
t.Fatalf("field_sources.category=%v", out.FieldSources["category"])
|
|
}
|
|
}
|
|
|
|
func TestHeuristicCompleter_usesNameNotCategoryLine(t *testing.T) {
|
|
h := HeuristicCompleter{}
|
|
user := ProductEnhanceUser("120", "Adler LED radiator", "Bathroom heater", map[string]any{"brand": "ADLER"})
|
|
comp, err := h.Complete(context.Background(), `Return JSON with "name" and "description".`, user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
obj, err := ParseJSONObject(comp.Text)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := SanitizeOutput(fmt.Sprint(obj["name"]))
|
|
if isPromptLabelTitle(name) || strings.HasPrefix(strings.ToLower(name), "category:") {
|
|
t.Fatalf("heuristic echoed prompt label as title: %q", name)
|
|
}
|
|
if !strings.Contains(strings.ToLower(name), "adler") {
|
|
t.Fatalf("name=%q want Adler from Name: line", name)
|
|
}
|
|
}
|
|
|
|
func TestEnhance_rejectsPromptLabelTitle(t *testing.T) {
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"Category:","description":"ok"}`, TotalTokens: 2}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "1",
|
|
Name: "Good Title",
|
|
Mapped: map[string]any{"name": "Good Title", "description": "d", "category": "demo-electronics"},
|
|
}, "enhance_only", nil, StepPolicy{AllowAI: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.ProcessedName != "Good Title" {
|
|
t.Fatalf("ProcessedName=%q want Good Title (reject Category:)", out.ProcessedName)
|
|
}
|
|
if out.Name == "Category:" || isPromptLabelTitle(out.Name) {
|
|
t.Fatalf("Name polluted: %q", out.Name)
|
|
}
|
|
if out.Category != "demo-electronics" {
|
|
t.Fatalf("category=%q", out.Category)
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_enhanceOnly_keepsMappedTitleWhenAIReturnsCategoryLabel(t *testing.T) {
|
|
// A1 Elkotex Adler shape: title in mapped_data, not name.
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"Category:","description":"Category: 120"}`, TotalTokens: 2}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "5905575903198",
|
|
Mapped: map[string]any{
|
|
"title": "Adler LED kopalniski radiator lestev 600W AD7824",
|
|
"description": "Bathroom radiator",
|
|
"category": "120",
|
|
},
|
|
PriorCategory: "120",
|
|
PriorProcessedName: "Category:",
|
|
}, "enhance_only", nil, StepPolicy{AllowAI: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if isPromptLabelTitle(out.ProcessedName) || out.ProcessedName == "Category:" {
|
|
t.Fatalf("ProcessedName=%q", out.ProcessedName)
|
|
}
|
|
if !strings.Contains(out.ProcessedName, "Adler") {
|
|
t.Fatalf("ProcessedName=%q want mapped title", out.ProcessedName)
|
|
}
|
|
if out.Category != "120" {
|
|
t.Fatalf("category=%q", out.Category)
|
|
}
|
|
}
|
|
|
|
func TestPreferredProductTitle_skipsPromptLabels(t *testing.T) {
|
|
got := preferredProductTitle("99", "Category:", "Category: 120", "Name:", "Real Product")
|
|
if got != "Real Product" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
got = preferredProductTitle("99", "Category:", "")
|
|
if got != "Product 99" {
|
|
t.Fatalf("fallback got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPreferredProductTitle_skipsFormulaLeakage(t *testing.T) {
|
|
leak := "short retail title; follow any Title formula constraints that follow; use Attrs"
|
|
got := preferredProductTitle("1", leak, "follow any Title formula constraints that follow; use Attrs", "Vox SWA-8000W")
|
|
if got != "Vox SWA-8000W" {
|
|
t.Fatalf("got %q want feed title", got)
|
|
}
|
|
got = preferredProductTitle("", leak, "Schema: {\"name\":\"string\"}", "Reply with ONLY JSON")
|
|
if got != "Product" {
|
|
t.Fatalf("all-leakage fallback got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPreferredProductTitle_skipsBrandOnlyWhenFullNameExists(t *testing.T) {
|
|
got := preferredProductTitle("1", "ANKER", "Anker Soundcore Space One Pro")
|
|
if got != "Anker Soundcore Space One Pro" {
|
|
t.Fatalf("got %q want full product name", got)
|
|
}
|
|
got = preferredProductTitle("1", "Anker", "Anker Soundcore Space One Pro Wireless")
|
|
if got != "Anker Soundcore Space One Pro Wireless" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
// Brand alone when no longer candidate.
|
|
got = preferredProductTitle("1", "ANKER")
|
|
if got != "ANKER" {
|
|
t.Fatalf("brand-only alone got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestIsPromptLabelTitle(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"Category:": true,
|
|
"category: 120": true,
|
|
"Category : 46": true,
|
|
"Name:": true,
|
|
"Desc:": true,
|
|
"Adler LED": false,
|
|
"": false,
|
|
}
|
|
for in, want := range cases {
|
|
if got := isPromptLabelTitle(in); got != want {
|
|
t.Fatalf("%q: got %v want %v", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsPromptLeakageTitle(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"follow any Title formula constraints that follow; use Attrs": true,
|
|
"short retail title; follow any Title formula constraints…": true,
|
|
"write name in {{language}}": true,
|
|
"Schema: {\"name\":\"string\",\"description\":\"string\"}": true,
|
|
"Reply with ONLY JSON (no markdown)": true,
|
|
"Emit description as ONE HTML string covering each section in order": true,
|
|
"Description formula (REQUIRED — overrides any shorter \"1-2 sentences\" rule)": true,
|
|
"when a Description formula follows, emit ONE HTML string": true,
|
|
"never copy name as description": true,
|
|
"Vox SWA-8000W dishwasher": false,
|
|
"Acme Widget Pro": false,
|
|
"": false,
|
|
}
|
|
for in, want := range cases {
|
|
if got := isPromptLeakageTitle(in); got != want {
|
|
t.Fatalf("%q: leakage got %v want %v", in, got, want)
|
|
}
|
|
if want && !isPromptLabelTitle(in) {
|
|
t.Fatalf("%q: isPromptLabelTitle should include leakage", in)
|
|
}
|
|
}
|
|
// Long dump with formula keyword (>120 runes).
|
|
long := strings.Repeat("x", 100) + " Title formula scaffolding " + strings.Repeat("y", 30)
|
|
if !isPromptLeakageTitle(long) {
|
|
t.Fatalf("long formula dump should leak: len=%d", len([]rune(long)))
|
|
}
|
|
}
|
|
|
|
func TestIsUnusableCategoryValue_rejectsNameAndFormula(t *testing.T) {
|
|
t.Parallel()
|
|
title := "NOSILEC W53070 81-140CM 180ST VOGELS"
|
|
if !isUnusableCategoryValue(title, title) {
|
|
t.Fatal("product title must not be usable as category")
|
|
}
|
|
if !isUnusableCategoryValue("Title formula (order matters; join with \" \")", title) {
|
|
t.Fatal("title formula must not be usable as category")
|
|
}
|
|
if !isUnusableCategoryValue("Emit description as ONE HTML string covering each section", title) {
|
|
t.Fatal("description formula must not be usable as category")
|
|
}
|
|
if !isUnusableCategoryValue("Category:", title) {
|
|
t.Fatal("prompt label must not be usable as category")
|
|
}
|
|
if isUnusableCategoryValue("28", title) {
|
|
t.Fatal("taxonomy unique_id must remain usable")
|
|
}
|
|
if isUnusableCategoryValue("TV mounts", title) {
|
|
t.Fatal("real category display name must remain usable")
|
|
}
|
|
}
|
|
|
|
func TestScrubCategoryPollution_clearsNameAsCategory(t *testing.T) {
|
|
t.Parallel()
|
|
title := "Vogel's WALL 3245 TV Wall Mount"
|
|
out := StepResult{
|
|
Name: title,
|
|
ProcessedName: title,
|
|
Category: title,
|
|
CategoryName: title,
|
|
FieldSources: map[string]any{"category": "vector"},
|
|
}
|
|
scrubCategoryPollution(&out, nil, nil)
|
|
if out.Category != "" || out.CategoryName != "" {
|
|
t.Fatalf("expected category cleared, got Category=%q CategoryName=%q", out.Category, out.CategoryName)
|
|
}
|
|
if src, _ := out.FieldSources["category"].(string); src != "cleared_invalid" {
|
|
t.Fatalf("field_sources.category=%v want cleared_invalid", out.FieldSources["category"])
|
|
}
|
|
}
|
|
|
|
func TestEnhance_rejectsDescriptionFormulaLeakageTitle(t *testing.T) {
|
|
leak := "Emit description as ONE HTML string covering each section in order in Slovenian"
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"` + leak + `","description":"A solid washer."}`, TotalTokens: 2}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "8712285326882",
|
|
Name: "Vox SWA-8000W",
|
|
Mapped: map[string]any{"name": "Vox SWA-8000W", "description": "Washer", "category": "demo-electronics"},
|
|
}, "enhance_only", nil, StepPolicy{AllowAI: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.ProcessedName != "Vox SWA-8000W" {
|
|
t.Fatalf("ProcessedName=%q want Vox SWA-8000W (reject description formula leakage)", out.ProcessedName)
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_rejectsProductNameAsCategory(t *testing.T) {
|
|
title := "NOSILEC W53070 81-140CM 180ST VOGELS"
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"` + title + `","description":"TV wall mount for large screens."}`, TotalTokens: 2}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "8712285326882",
|
|
Name: title,
|
|
PriorCategory: title,
|
|
Mapped: map[string]any{"name": title, "description": title, "category": title},
|
|
}, "enhance_only", nil, StepPolicy{AllowAI: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Category == title || strings.EqualFold(out.Category, out.ProcessedName) {
|
|
t.Fatalf("Category=%q must not equal product title", out.Category)
|
|
}
|
|
if categoryDisplayLabel(out) == title {
|
|
t.Fatalf("display category leaked product title: %q", categoryDisplayLabel(out))
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_rejectsFormulaAsCategory(t *testing.T) {
|
|
leak := "Title formula (order matters; join with \" \"). Build name from Attrs"
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"Vox SWA-8000W","description":"Washer."}`, TotalTokens: 2}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "8712285326882",
|
|
Name: "Vox SWA-8000W",
|
|
PriorCategory: leak,
|
|
Mapped: map[string]any{"name": "Vox SWA-8000W", "description": "Washer", "category": leak},
|
|
}, "enhance_only", nil, StepPolicy{AllowAI: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Category != "" {
|
|
t.Fatalf("Category=%q want empty (formula rejected)", out.Category)
|
|
}
|
|
if categoryDisplayLabel(out) != "" {
|
|
t.Fatalf("display=%q want empty", categoryDisplayLabel(out))
|
|
}
|
|
}
|
|
|
|
func TestEnhance_rejectsFormulaLeakageTitle(t *testing.T) {
|
|
leak := "short retail title; follow any Title formula constraints that follow; use Attrs"
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"` + leak + `","description":"A solid washer."}`, TotalTokens: 2}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "8712285326882",
|
|
Name: "Vox SWA-8000W",
|
|
Mapped: map[string]any{"name": "Vox SWA-8000W", "description": "Washer", "category": "demo-electronics"},
|
|
}, "enhance_only", nil, StepPolicy{AllowAI: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.ProcessedName != "Vox SWA-8000W" {
|
|
t.Fatalf("ProcessedName=%q want Vox SWA-8000W (reject formula leakage)", out.ProcessedName)
|
|
}
|
|
if isPromptLeakageTitle(out.Name) || isPromptLabelTitle(out.Name) {
|
|
t.Fatalf("Name polluted: %q", out.Name)
|
|
}
|
|
}
|
|
|
|
func TestHeuristicCompleter_keepsNameWhenFormulaConstraintsPresent(t *testing.T) {
|
|
h := HeuristicCompleter{}
|
|
// Shape matches aiprompts.CategoryEnhanceUserTemplate: instruction "- name:"
|
|
// appears BEFORE the real "Name:" field — labeledPromptValue must skip leakage.
|
|
user := `Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string"} only (system schema). Write all fields in English (do not hardcode a language).
|
|
- name: short retail title; follow any Title formula constraints that follow; use Attrs
|
|
- description: prefer 1-3 factual paragraphs as ONE string
|
|
|
|
Category: demo-electronics
|
|
Name: Vox SWA-8000W
|
|
Description: Washer
|
|
Attrs: {"brand":"Vox"}
|
|
|
|
Title formula (order matters; join with " "). Build name from Attrs using this structure:
|
|
1. attr [brand]
|
|
Prefer Attrs values for [attr] slots; write name in English.`
|
|
comp, err := h.Complete(context.Background(), `Return JSON with "name" and "description".`, user)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
obj, err := ParseJSONObject(comp.Text)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
name := SanitizeOutput(fmt.Sprint(obj["name"]))
|
|
if isPromptLeakageTitle(name) || isPromptLabelTitle(name) {
|
|
t.Fatalf("heuristic leaked formula into name: %q", name)
|
|
}
|
|
if !strings.Contains(strings.ToLower(name), "vox") {
|
|
t.Fatalf("name=%q want Vox from Name: line", name)
|
|
}
|
|
}
|
|
|
|
func TestLabeledPromptValue_skipsInstructionNameBullet(t *testing.T) {
|
|
user := `- name: short retail title; follow any Title formula constraints that follow; use Attrs
|
|
Name: Vox SWA-8000W
|
|
Desc: Washer
|
|
Attrs: {}`
|
|
got := labeledPromptValue(user, "name:", "current name:")
|
|
if got != "Vox SWA-8000W" {
|
|
t.Fatalf("got %q want Vox SWA-8000W", got)
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_full_mappedCategoryWinsOverPrior(t *testing.T) {
|
|
e := &Engine{
|
|
Completer: stubCompleter{fn: func(system, user string) (Completion, error) {
|
|
return Completion{Text: `{"name":"N","description":"D"}`, TotalTokens: 1}, nil
|
|
}},
|
|
Vector: NoopVectorCategorizer{},
|
|
}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
GTIN: "6970995789942",
|
|
Mapped: map[string]any{
|
|
"name": "Roborock",
|
|
"description": "vacuum",
|
|
"category": "Robot Vacuums",
|
|
},
|
|
PriorCategory: "Old Category",
|
|
}, "full", nil, StepPolicy{AllowAI: true, AllowEPREL: false})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Category != "Robot Vacuums" {
|
|
t.Fatalf("category=%q want Robot Vacuums", out.Category)
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_normalizeOnly_keepsPriorWhenMappedMissing(t *testing.T) {
|
|
e := &Engine{Vector: NoopVectorCategorizer{}}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
Mapped: map[string]any{"name": "x"},
|
|
PriorCategory: "FromProcessed",
|
|
}, "normalize_only", nil, StepPolicy{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Category != "FromProcessed" {
|
|
t.Fatalf("category=%q", out.Category)
|
|
}
|
|
}
|
|
|
|
func TestRunSteps_skipsEPRELWithoutEntitlement(t *testing.T) {
|
|
st := &stubEprel{enabled: true, data: &eprel.Data{ID: "1", EnergyClass: "A"}}
|
|
e := &Engine{EPREL: st}
|
|
out, err := e.RunSteps(context.Background(), "co", ProductInput{
|
|
Raw: map[string]any{"EPRELID": "1"},
|
|
}, "eprel_only", nil, StepPolicy{AllowEPREL: false})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.calls != 0 {
|
|
t.Fatal("EPREL should not run without entitlement")
|
|
}
|
|
if len(out.Notes) == 0 {
|
|
t.Fatal("expected skip note")
|
|
}
|
|
}
|