package criteria import ( "bytes" "encoding/json" "time" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) var _ = Describe("Criteria", func() { var goObj Criteria var jsonObj string Context("with a complex criteria", func() { BeforeEach(func() { goObj = Criteria{ Expression: All{ Contains{"title": "love"}, NotContains{"title": "hate"}, Any{ IsNot{"artist": "u2"}, Is{"album": "best of"}, }, All{ StartsWith{"comment": "this"}, InTheRange{"year": []int{1980, 1990}}, IsNot{"genre": "Rock"}, Gt{"albumrating": 3}, }, }, Sort: "title", Order: "asc", Limit: 20, Offset: 10, } var b bytes.Buffer err := json.Compact(&b, []byte(` { "all": [ { "contains": {"title": "love"} }, { "notContains": {"title": "hate"} }, { "any": [ { "isNot": {"artist": "u2"} }, { "is": {"album": "best of"} } ] }, { "all": [ { "startsWith": {"comment": "this"} }, { "inTheRange": {"year":[1980,1990]} }, { "isNot": { "genre": "Rock" }}, { "gt": { "albumrating": 3 } } ] } ], "sort": "title", "order": "asc", "limit": 20, "offset": 10 } `)) if err != nil { panic(err) } jsonObj = b.String() }) It("marshals to JSON", func() { j, err := json.Marshal(goObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) }) It("is reversible to/from JSON", func() { var newObj Criteria err := json.Unmarshal([]byte(jsonObj), &newObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) j, err := json.Marshal(newObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) }) }) Context("with both top-level 'all' and 'any'", func() { It("returns an error instead of silently dropping one of the groups", func() { jsonStr := `{"any":[{"inPlaylist":{"path":"a.nsp"}}],"all":[{"notInPlaylist":{"path":"b.nsp"}}]}` var c Criteria err := json.Unmarshal([]byte(jsonStr), &c) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any"))) }) DescribeTable("rejects both keys even when one group is present but empty", func(jsonStr string) { var c Criteria err := json.Unmarshal([]byte(jsonStr), &c) gomega.Expect(err).To(gomega.HaveOccurred()) gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any"))) }, Entry("empty any", `{"any":[],"all":[{"is":{"loved":true}}]}`), Entry("empty all", `{"all":[],"any":[{"is":{"loved":true}}]}`), Entry("null any", `{"any":null,"all":[{"is":{"loved":true}}]}`), ) }) Describe("LimitPercent", func() { Describe("JSON round-trip", func() { It("marshals and unmarshals limitPercent", func() { goObj := Criteria{ Expression: All{Contains{"title": "love"}}, Sort: "title", Order: "asc", LimitPercent: 10, } j, err := json.Marshal(goObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limitPercent":10`)) gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`"limit"`)) var newObj Criteria err = json.Unmarshal(j, &newObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(newObj.LimitPercent).To(gomega.Equal(10)) gomega.Expect(newObj.Limit).To(gomega.Equal(0)) }) It("does not include limitPercent when zero", func() { goObj := Criteria{ Expression: All{Contains{"title": "love"}}, Limit: 50, } j, err := json.Marshal(goObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limit":50`)) gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`limitPercent`)) }) It("backward compatible: JSON with only limit still works", func() { jsonStr := `{"all":[{"contains":{"title":"love"}}],"limit":20}` var c Criteria err := json.Unmarshal([]byte(jsonStr), &c) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(c.Limit).To(gomega.Equal(20)) gomega.Expect(c.LimitPercent).To(gomega.Equal(0)) }) }) Describe("UnmarshalJSON clamping", func() { It("clamps values above 100 to 100", func() { jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":150}` var c Criteria err := json.Unmarshal([]byte(jsonStr), &c) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(c.LimitPercent).To(gomega.Equal(100)) }) It("clamps negative values to 0", func() { jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":-5}` var c Criteria err := json.Unmarshal([]byte(jsonStr), &c) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(c.LimitPercent).To(gomega.Equal(0)) }) }) Describe("EffectiveLimit", func() { It("returns fixed limit when Limit is set", func() { c := Criteria{Limit: 50, LimitPercent: 10} gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(50)) }) It("returns percentage-based limit", func() { c := Criteria{LimitPercent: 10} gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(45)) }) It("returns minimum 1 when totalCount > 0 and percentage rounds to 0", func() { c := Criteria{LimitPercent: 1} gomega.Expect(c.EffectiveLimit(5)).To(gomega.Equal(1)) }) It("returns 0 when totalCount is 0", func() { c := Criteria{LimitPercent: 10} gomega.Expect(c.EffectiveLimit(0)).To(gomega.Equal(0)) }) It("returns 0 when no limit is set", func() { c := Criteria{} gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(0)) }) It("returns full count for 100%", func() { c := Criteria{LimitPercent: 100} gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(450)) }) It("returns 1 for 1% of 50 items", func() { c := Criteria{LimitPercent: 1} gomega.Expect(c.EffectiveLimit(50)).To(gomega.Equal(1)) }) }) Describe("ResolveLimit", func() { It("resolves percentage to absolute limit preserving LimitPercent", func() { c := Criteria{LimitPercent: 10} c.ResolveLimit(450) gomega.Expect(c.Limit).To(gomega.Equal(45)) }) It("does nothing when Limit is already set", func() { c := Criteria{Limit: 50, LimitPercent: 10} c.ResolveLimit(1000) gomega.Expect(c.Limit).To(gomega.Equal(50)) }) It("does nothing when no limit is configured", func() { c := Criteria{} c.ResolveLimit(1000) gomega.Expect(c.Limit).To(gomega.Equal(0)) }) It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() { c := Criteria{LimitPercent: 1} c.ResolveLimit(5) gomega.Expect(c.Limit).To(gomega.Equal(1)) }) It("is idempotent when called twice", func() { c := Criteria{LimitPercent: 10} c.ResolveLimit(450) c.ResolveLimit(450) gomega.Expect(c.Limit).To(gomega.Equal(45)) }) }) Describe("IsPercentageLimit", func() { It("returns true when LimitPercent is set and Limit is 0", func() { c := Criteria{LimitPercent: 10} gomega.Expect(c.IsPercentageLimit()).To(gomega.BeTrue()) }) It("returns false when Limit is set", func() { c := Criteria{Limit: 50, LimitPercent: 10} gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) }) It("returns false when neither is set", func() { c := Criteria{} gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) }) It("returns false when LimitPercent is out of range", func() { c := Criteria{LimitPercent: 150} gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) }) }) }) Describe("refreshDelay", func() { newCriteria := func(extra string) []byte { return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`) } It("unmarshals a valid refreshDelay", func() { var c Criteria gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed()) gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour)) }) It("supports week units", func() { var c Criteria gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed()) gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour)) }) It("leaves RefreshDelay zero when absent", func() { var c Criteria gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed()) gomega.Expect(c.RefreshDelay).To(gomega.BeZero()) }) It("rejects an invalid refreshDelay", func() { var c Criteria err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c) gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) }) It("rejects a negative refreshDelay", func() { var c Criteria err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c) gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) }) It("marshals RefreshDelay back as a duration string", func() { c := Criteria{ Expression: All{Is{"loved": true}}, RefreshDelay: 24 * time.Hour, } j, err := json.Marshal(c) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`)) }) It("omits refreshDelay from JSON when zero", func() { c := Criteria{Expression: All{Is{"loved": true}}} j, err := json.Marshal(c) gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay")) }) It("round-trips through marshal and unmarshal", func() { c := Criteria{ Expression: All{Is{"loved": true}}, RefreshDelay: 36 * time.Hour, } j, err := json.Marshal(c) gomega.Expect(err).ToNot(gomega.HaveOccurred()) var c2 Criteria gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed()) gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour)) }) }) Context("with child playlists", func() { var ( topLevelInPlaylistID string topLevelInPlaylistPath string topLevelNotInPlaylistID string nestedAnyInPlaylistID string nestedAnyNotInPlaylistID string nestedAllInPlaylistID string nestedAllNotInPlaylistID string nestedAnyNotInPlaylistPath string ) BeforeEach(func() { topLevelInPlaylistID = uuid.NewString() topLevelInPlaylistPath = "./test.nsp" topLevelNotInPlaylistID = uuid.NewString() nestedAnyInPlaylistID = uuid.NewString() nestedAnyNotInPlaylistID = uuid.NewString() nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u" nestedAllInPlaylistID = uuid.NewString() nestedAllNotInPlaylistID = uuid.NewString() goObj = Criteria{ Expression: All{ InPlaylist{"id": topLevelInPlaylistID}, InPlaylist{"path": topLevelInPlaylistPath}, NotInPlaylist{"id": topLevelNotInPlaylistID}, Any{ InPlaylist{"id": nestedAnyInPlaylistID}, NotInPlaylist{"id": nestedAnyNotInPlaylistID}, NotInPlaylist{"path": nestedAnyNotInPlaylistPath}, }, All{ InPlaylist{"id": nestedAllInPlaylistID}, NotInPlaylist{"id": nestedAllNotInPlaylistID}, }, }, } }) It("extracts all child smart playlist IDs from expression criteria", func() { ids := goObj.ChildPlaylistIds() gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) }) It("extracts all child smart playlist paths from expression criteria", func() { paths := goObj.ChildPlaylistPaths() gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath)) }) It("ignores empty child playlist paths", func() { c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}} gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty()) }) It("ignores empty child playlist ids", func() { c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}} gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty()) }) It("extracts child smart playlist IDs from deeply nested expression", func() { goObj = Criteria{ Expression: Any{ Any{ All{ Any{ InPlaylist{"id": nestedAnyInPlaylistID}, NotInPlaylist{"id": nestedAnyNotInPlaylistID}, Any{ All{ InPlaylist{"id": nestedAllInPlaylistID}, NotInPlaylist{"id": nestedAllNotInPlaylistID}, }, }, }, }, }, }, } ids := goObj.ChildPlaylistIds() gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) }) It("returns empty list when no child playlist IDs are present", func() { ids := Criteria{}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) It("returns empty list for leaf expressions", func() { ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) It("deduplicates repeated playlist IDs", func() { sharedID := uuid.NewString() goObj = Criteria{ Expression: All{ InPlaylist{"id": sharedID}, Any{ InPlaylist{"id": sharedID}, NotInPlaylist{"id": sharedID}, }, }, } ids := goObj.ChildPlaylistIds() gomega.Expect(ids).To(gomega.Equal([]string{sharedID})) }) }) })