🏡 index : quine-global/quintodrome.git

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}))
		})
	})
})