package criteriaimport ("bytes""encoding/json""time""github.com/google/uuid". "github.com/onsi/ginkgo/v2""github.com/onsi/gomega")var _ = Describe("Criteria", func() {var goObj Criteriavar jsonObj stringContext("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.Buffererr := 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 Criteriaerr := 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 Criteriaerr := 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 Criteriaerr := 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 Criteriaerr = 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 Criteriaerr := 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 Criteriaerr := 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 Criteriaerr := 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 Criteriagomega.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 Criteriagomega.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 Criteriagomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed())gomega.Expect(c.RefreshDelay).To(gomega.BeZero())})It("rejects an invalid refreshDelay", func() {var c Criteriaerr := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c)gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay")))})It("rejects a negative refreshDelay", func() {var c Criteriaerr := 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 Criteriagomega.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 stringtopLevelInPlaylistPath stringtopLevelNotInPlaylistID stringnestedAnyInPlaylistID stringnestedAnyNotInPlaylistID stringnestedAllInPlaylistID stringnestedAllNotInPlaylistID stringnestedAnyNotInPlaylistPath 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}))})})})