package filter_test import ( "errors" "testing" "atlas9.dev/c/core/assert" "atlas9.dev/c/demo/lib/filter" ) // ---- helpers ---- func cmp(field string, op filter.Op, value string) filter.Expr { return filter.CompareExpr{Field: field, Op: op, Value: value} } func eq(field, value string) filter.Expr { return cmp(field, filter.OpEq, value) } func ne(field, value string) filter.Expr { return cmp(field, filter.OpNe, value) } func lt(field, value string) filter.Expr { return cmp(field, filter.OpLt, value) } func gt(field, value string) filter.Expr { return cmp(field, filter.OpGt, value) } func lte(field, value string) filter.Expr { return cmp(field, filter.OpLe, value) } func gte(field, value string) filter.Expr { return cmp(field, filter.OpGe, value) } func has(field, value string) filter.Expr { return cmp(field, filter.OpHas, value) } func and(l, r filter.Expr) filter.Expr { return filter.AndExpr{Left: l, Right: r} } func or(l, r filter.Expr) filter.Expr { return filter.OrExpr{Left: l, Right: r} } func not(e filter.Expr) filter.Expr { return filter.NotExpr{Operand: e} } func parse(t *testing.T, input string) filter.Expr { t.Helper() expr, err := filter.Parse(input) assert.Ok(t, err) return expr } func parseErr(t *testing.T, input string) { t.Helper() _, err := filter.Parse(input) if !errors.Is(err, filter.ErrInvalid) { t.Errorf("Parse(%q): expected ErrInvalid, got %v", input, err) } } // ---- Parse ---- func TestParse_Empty(t *testing.T) { // empty string and whitespace-only return nil with no error assert.Eq(t, parse(t, ""), nil) assert.Eq(t, parse(t, " "), nil) } func TestParse_Operators(t *testing.T) { assert.Eq(t, parse(t, `status = "open"`), eq("status", "open")) assert.Eq(t, parse(t, `status != "open"`), ne("status", "open")) assert.Eq(t, parse(t, `count < "5"`), lt("count", "5")) assert.Eq(t, parse(t, `count > "5"`), gt("count", "5")) assert.Eq(t, parse(t, `count <= "5"`), lte("count", "5")) assert.Eq(t, parse(t, `count >= "5"`), gte("count", "5")) assert.Eq(t, parse(t, `title:"bug"`), has("title", "bug")) } func TestParse_UnquotedValue(t *testing.T) { // unquoted values are allowed assert.Eq(t, parse(t, `status = open`), eq("status", "open")) assert.Eq(t, parse(t, `title:bug`), has("title", "bug")) } func TestParse_QuotedValueWithSpaces(t *testing.T) { assert.Eq(t, parse(t, `title = "hello world"`), eq("title", "hello world")) assert.Eq(t, parse(t, `title:"hello world"`), has("title", "hello world")) } func TestParse_And(t *testing.T) { // explicit AND assert.Eq(t, parse(t, `status = "open" AND title:"bug"`), and(eq("status", "open"), has("title", "bug"))) // case-insensitive assert.Eq(t, parse(t, `status = "open" and title:"bug"`), and(eq("status", "open"), has("title", "bug"))) } func TestParse_Or(t *testing.T) { // explicit OR assert.Eq(t, parse(t, `status = "open" OR status = "done"`), or(eq("status", "open"), eq("status", "done"))) // case-insensitive assert.Eq(t, parse(t, `status = "open" or status = "done"`), or(eq("status", "open"), eq("status", "done"))) } func TestParse_Not(t *testing.T) { assert.Eq(t, parse(t, `NOT status = "done"`), not(eq("status", "done"))) assert.Eq(t, parse(t, `-status = "done"`), not(eq("status", "done"))) assert.Eq(t, parse(t, `not status = "done"`), not(eq("status", "done"))) } func TestParse_Parens(t *testing.T) { // parens override default precedence assert.Eq(t, parse(t, `(status = "open")`), eq("status", "open")) assert.Eq(t, parse(t, `assignee = "alice" AND (status = "open" OR status = "done")`), and(eq("assignee", "alice"), or(eq("status", "open"), eq("status", "done")))) } func TestParse_Precedence(t *testing.T) { // OR has higher precedence than AND // a AND b OR c → a AND (b OR c) assert.Eq(t, parse(t, `a = "1" AND b = "2" OR c = "3"`), and(eq("a", "1"), or(eq("b", "2"), eq("c", "3")))) // a OR b AND c → (a OR b) AND c assert.Eq(t, parse(t, `a = "1" OR b = "2" AND c = "3"`), and(or(eq("a", "1"), eq("b", "2")), eq("c", "3"))) } func TestParse_ImplicitAnd(t *testing.T) { // adjacent terms are implicitly AND'd assert.Eq(t, parse(t, `status = "open" title:"bug"`), and(eq("status", "open"), has("title", "bug"))) // three terms chain left-to-right assert.Eq(t, parse(t, `a = "1" b = "2" c = "3"`), and(and(eq("a", "1"), eq("b", "2")), eq("c", "3"))) // implicit AND and explicit OR together: a b OR c → a AND (b OR c) assert.Eq(t, parse(t, `a = "1" b = "2" OR c = "3"`), and(eq("a", "1"), or(eq("b", "2"), eq("c", "3")))) } func TestParse_ValueList(t *testing.T) { // field:(v1 v2) expands to field:v1 OR field:v2 assert.Eq(t, parse(t, `status:(open done)`), or(has("status", "open"), has("status", "done"))) // three values assert.Eq(t, parse(t, `status:(open in_progress done)`), or(or(has("status", "open"), has("status", "in_progress")), has("status", "done"))) // single value in parens is valid assert.Eq(t, parse(t, `status:(open)`), has("status", "open")) // quoted values inside list assert.Eq(t, parse(t, `title:("hello world" bug)`), or(has("title", "hello world"), has("title", "bug"))) // value list combined with other filters assert.Eq(t, parse(t, `assignee = "alice" AND status:(open in_progress)`), and(eq("assignee", "alice"), or(has("status", "open"), has("status", "in_progress")))) } func TestParse_Errors(t *testing.T) { // bare word without default fields parseErr(t, `upload`) // quoted string without default fields parseErr(t, `"hello world"`) // missing value after operator parseErr(t, `status =`) // empty value list parseErr(t, `status:()`) // unclosed paren parseErr(t, `(status = "open"`) // unclosed value list parseErr(t, `status:(open done`) // trailing junk parseErr(t, `status = "open" !!`) } // ---- ParseAndValidate ---- var testSchema = filter.Schema{ DefaultFields: []string{"title"}, Fields: []filter.Field{ {Name: "title", Type: filter.TypeString}, {Name: "notes", Type: filter.TypeString}, {Name: "status", Type: filter.TypeEnum, Values: []string{"open", "in_progress", "done"}}, {Name: "assignee", Type: filter.TypeString}, }, } func validate(t *testing.T, input string) filter.Expr { t.Helper() expr, err := testSchema.ParseAndValidate(input) assert.Ok(t, err) return expr } func validateErr(t *testing.T, input string) { t.Helper() _, err := testSchema.ParseAndValidate(input) if !errors.Is(err, filter.ErrInvalid) { t.Errorf("ParseAndValidate(%q): expected ErrInvalid, got %v", input, err) } } func TestParseAndValidate_BareText(t *testing.T) { // bare word expands to default field contains assert.Eq(t, validate(t, `upload`), has("title", "upload")) // quoted bare string assert.Eq(t, validate(t, `"hello world"`), has("title", "hello world")) // multiple bare words are implicitly AND'd assert.Eq(t, validate(t, `upload bug`), and(has("title", "upload"), has("title", "bug"))) // bare text combined with field filter assert.Eq(t, validate(t, `upload status = "open"`), and(has("title", "upload"), eq("status", "open"))) } func TestParseAndValidate_MultipleDefaultFields(t *testing.T) { s := filter.Schema{ DefaultFields: []string{"title", "notes"}, Fields: []filter.Field{ {Name: "title", Type: filter.TypeString}, {Name: "notes", Type: filter.TypeString}, }, } // bare word expands to OR across all default fields expr, err := s.ParseAndValidate(`upload`) assert.Ok(t, err) assert.Eq(t, expr, or(has("title", "upload"), has("notes", "upload"))) } func TestParseAndValidate_NoDefaultFields_BareTextIsError(t *testing.T) { s := filter.Schema{ Fields: []filter.Field{ {Name: "title", Type: filter.TypeString}, }, } _, err := s.ParseAndValidate(`upload`) if !errors.Is(err, filter.ErrInvalid) { t.Errorf("expected ErrInvalid for bare text with no default fields, got %v", err) } } func TestParseAndValidate_UnknownField(t *testing.T) { validateErr(t, `badfield = "x"`) validateErr(t, `status = "open" AND badfield = "x"`) } func TestParseAndValidate_EnumValidation(t *testing.T) { // valid enum values pass assert.Eq(t, validate(t, `status = "open"`), eq("status", "open")) assert.Eq(t, validate(t, `status = "in_progress"`), eq("status", "in_progress")) assert.Eq(t, validate(t, `status = "done"`), eq("status", "done")) // invalid enum value fails validateErr(t, `status = "invalid"`) // case-insensitive: "Open" normalizes to the canonical "open" assert.Eq(t, validate(t, `status = "Open"`), eq("status", "open")) assert.Eq(t, validate(t, `status = "IN_PROGRESS"`), eq("status", "in_progress")) } func TestParseAndValidate_ValueListEnum(t *testing.T) { // all valid enum values in a list assert.Eq(t, validate(t, `status:(open done)`), or(has("status", "open"), has("status", "done"))) // any invalid value in the list fails validateErr(t, `status:(open invalid)`) } func TestParseAndValidate_Empty(t *testing.T) { expr, err := testSchema.ParseAndValidate("") assert.Ok(t, err) assert.Eq(t, expr, nil) }