Add deal filtering engine with BOGO, category, department, and keyword support
Composable filter pipeline that processes SavingItem slices through chained predicates: BOGO detection (category match), exact category match, substring department match, and keyword search across title and description fields. All text matching is case-insensitive. Includes utility functions for HTML entity unescaping (CleanText), nil-safe string pointer dereferencing (Deref), and case-insensitive slice membership (ContainsIgnoreCase). An optional limit truncates results after all filters are applied. Tests cover each filter in isolation, combined filters, nil field safety, and the Categories aggregation helper.
This commit is contained in:
104
internal/filter/filter.go
Normal file
104
internal/filter/filter.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package filter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tayloree/publix-deals/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options holds all filter criteria.
|
||||||
|
type Options struct {
|
||||||
|
BOGO bool
|
||||||
|
Category string
|
||||||
|
Department string
|
||||||
|
Query string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters a slice of SavingItems according to the given options.
|
||||||
|
func Apply(items []api.SavingItem, opts Options) []api.SavingItem {
|
||||||
|
result := items
|
||||||
|
|
||||||
|
if opts.BOGO {
|
||||||
|
result = where(result, func(i api.SavingItem) bool {
|
||||||
|
return ContainsIgnoreCase(i.Categories, "bogo")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Category != "" {
|
||||||
|
result = where(result, func(i api.SavingItem) bool {
|
||||||
|
return ContainsIgnoreCase(i.Categories, opts.Category)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Department != "" {
|
||||||
|
dept := strings.ToLower(opts.Department)
|
||||||
|
result = where(result, func(i api.SavingItem) bool {
|
||||||
|
return strings.Contains(strings.ToLower(Deref(i.Department)), dept)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Query != "" {
|
||||||
|
q := strings.ToLower(opts.Query)
|
||||||
|
result = where(result, func(i api.SavingItem) bool {
|
||||||
|
title := strings.ToLower(CleanText(Deref(i.Title)))
|
||||||
|
desc := strings.ToLower(CleanText(Deref(i.Description)))
|
||||||
|
return strings.Contains(title, q) || strings.Contains(desc, q)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Limit > 0 && opts.Limit < len(result) {
|
||||||
|
result = result[:opts.Limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories returns a map of category name to count across all items.
|
||||||
|
func Categories(items []api.SavingItem) map[string]int {
|
||||||
|
cats := make(map[string]int)
|
||||||
|
for _, item := range items {
|
||||||
|
for _, c := range item.Categories {
|
||||||
|
cats[c]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deref safely dereferences a string pointer, returning "" for nil.
|
||||||
|
func Deref(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanText unescapes HTML entities and normalizes whitespace.
|
||||||
|
func CleanText(s string) string {
|
||||||
|
s = html.UnescapeString(s)
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", " ")
|
||||||
|
s = strings.ReplaceAll(s, "\r", " ")
|
||||||
|
s = strings.ReplaceAll(s, "\n", " ")
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func where(items []api.SavingItem, fn func(api.SavingItem) bool) []api.SavingItem {
|
||||||
|
var result []api.SavingItem
|
||||||
|
for _, item := range items {
|
||||||
|
if fn(item) {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainsIgnoreCase reports whether any element in slice matches val case-insensitively.
|
||||||
|
func ContainsIgnoreCase(slice []string, val string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if strings.EqualFold(s, val) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
154
internal/filter/filter_test.go
Normal file
154
internal/filter/filter_test.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package filter_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/tayloree/publix-deals/internal/api"
|
||||||
|
"github.com/tayloree/publix-deals/internal/filter"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ptr(s string) *string { return &s }
|
||||||
|
|
||||||
|
func sampleItems() []api.SavingItem {
|
||||||
|
return []api.SavingItem{
|
||||||
|
{
|
||||||
|
ID: "1",
|
||||||
|
Title: ptr("Chicken Breasts"),
|
||||||
|
Department: ptr("Meat"),
|
||||||
|
Categories: []string{"meat"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "2",
|
||||||
|
Title: ptr("Nutella Spread"),
|
||||||
|
Savings: ptr("Buy 1 Get 1 FREE"),
|
||||||
|
Department: ptr("Peanut Butter & Jelly"),
|
||||||
|
Categories: []string{"bogo", "grocery"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "3",
|
||||||
|
Title: ptr("Organic Spinach"),
|
||||||
|
Description: ptr("Fresh baby spinach, 5-oz pkg."),
|
||||||
|
Department: ptr("Produce"),
|
||||||
|
Categories: []string{"produce"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "4",
|
||||||
|
Title: ptr("Dog Food"),
|
||||||
|
Department: ptr("Pet Food"),
|
||||||
|
Categories: []string{"bogo", "pet", "pet-bogos"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "5",
|
||||||
|
Title: nil,
|
||||||
|
Department: nil,
|
||||||
|
Categories: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_NoFilters(t *testing.T) {
|
||||||
|
items := sampleItems()
|
||||||
|
result := filter.Apply(items, filter.Options{})
|
||||||
|
assert.Len(t, result, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_BOGO(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{BOGO: true})
|
||||||
|
assert.Len(t, result, 2)
|
||||||
|
assert.Equal(t, "2", result[0].ID)
|
||||||
|
assert.Equal(t, "4", result[1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_Category(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Category: "meat"})
|
||||||
|
assert.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "1", result[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_CategoryCaseInsensitive(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Category: "BOGO"})
|
||||||
|
assert.Len(t, result, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_Department(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Department: "produce"})
|
||||||
|
assert.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "Organic Spinach", *result[0].Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_DepartmentPartialMatch(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Department: "pet"})
|
||||||
|
assert.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "4", result[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_Query(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Query: "chicken"})
|
||||||
|
assert.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "1", result[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_QueryMatchesDescription(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Query: "spinach"})
|
||||||
|
assert.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "3", result[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_QueryNoMatch(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Query: "xyz123"})
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_Limit(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Limit: 2})
|
||||||
|
assert.Len(t, result, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_CombinedFilters(t *testing.T) {
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{
|
||||||
|
BOGO: true,
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
assert.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "2", result[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_NilFields(t *testing.T) {
|
||||||
|
// Item 5 has nil title/department/categories — should not panic
|
||||||
|
result := filter.Apply(sampleItems(), filter.Options{Query: "anything"})
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCategories(t *testing.T) {
|
||||||
|
cats := filter.Categories(sampleItems())
|
||||||
|
|
||||||
|
assert.Equal(t, 2, cats["bogo"])
|
||||||
|
assert.Equal(t, 1, cats["meat"])
|
||||||
|
assert.Equal(t, 1, cats["grocery"])
|
||||||
|
assert.Equal(t, 1, cats["produce"])
|
||||||
|
assert.Equal(t, 1, cats["pet"])
|
||||||
|
assert.Equal(t, 1, cats["pet-bogos"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeref(t *testing.T) {
|
||||||
|
s := "hello"
|
||||||
|
assert.Equal(t, "hello", filter.Deref(&s))
|
||||||
|
assert.Equal(t, "", filter.Deref(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanText(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"Hello & World", "Hello & World"},
|
||||||
|
{"Line1\r\nLine2", "Line1 Line2"},
|
||||||
|
{" spaces ", "spaces"},
|
||||||
|
{"Eight O'Clock", "Eight O'Clock"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, filter.CleanText(tt.input), "CleanText(%q)", tt.input)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user