feat: add optional natural sort order for names and titles (#6015)

* feat: add optional natural sort order for names and titles

Album, artist, song and playlist lists sort with a plain text comparison, so
names containing numbers come out as "Foo 1, Foo 10, Foo 2" instead of
"Foo 1, Foo 2, Foo 10" (issue #4554).

Adds an EnableNaturalSorting option, default off, that switches those sorts to
a NATSORT collation registered on every connection and backed by
natural.CompareFold. natural.Compare gained an ASCII case-folding variant
because it replaces 'collate nocase': sort_* columns hold raw tag values, so
without folding they would order uppercase before lowercase.

Applying the collation only inside mapSortOrder would have missed the default
configuration entirely, since that mapper runs only when PreferSortTags is on.
setSortMappings now also rewrites the order_* columns when natural sorting is
enabled on its own. Sorts over plain text columns that are not order_* columns
(playlist.name, album.name, media_file.title, playlist_tracks title) are
wrapped explicitly, and qualified with their table because 'user' is joined and
also has a 'name' column.

The option defaults to off because the collation cannot use the existing
indexes: measured on a synthetic 110k album library, the first page of an
album-by-name listing goes from 0.03ms to 14ms. Indexing the expression was
rejected outright - an index declared with a custom collation makes the whole
database unreadable to any tool that does not register it, including the
sqlite3 CLI, which fails even on 'select count(*)' and 'pragma integrity_check'.

* refactor: fold the two sort-order mappers into one

mapSortOrder and mapNaturalOrder shared the same regex and loop, differing only
in the expression they substituted, and setSortMappings picked between them with
a two-case switch. mapSortOrder now selects the column shape itself and defers to
collatedSort for the collation, so the 'collate' clause is emitted in one place
and the caller only has to decide whether any mapping is needed at all.

The mapper tests were three near-identical cases that each hard-coded one flag
combination; they are now a DescribeTable covering all four combinations of
PreferSortTags and EnableNaturalSorting, which the previous set did not. The
album sorting specs collapse the same way. Behavior is unchanged.

* fix: leave plain sort columns alone when natural sorting is off

collatedSort wrapped its column unconditionally, so the tiebreakers added for
plain text columns picked up 'collate nocase' even with EnableNaturalSorting
off. media_file.title, the playlist_tracks alias of it, and user.user_name are
all declared without a collation, so a default install would have silently
switched those tiebreaks from binary to case-insensitive ordering. Only
playlist.name was already NOCASE and genuinely unaffected.

The helper is now naturalSort and returns the column untouched unless the option
is on, so the default path keeps the collation each column was declared with.
sortCollation had a single remaining caller and folded into mapSortOrder.

Tests: the CompareFold table body was a verbatim copy of the Compare one, so
both now go through one expectOrder helper, and the album sorting specs inline
two single-use closures.

* fix(natural): defer the leading-zero tie-break to keep ordering transitive

Compare applied the padding difference between numerically equal digit runs only
when one side ended at the digit boundary, and ignored it mid-string. That made
the relation intransitive: CompareFold("1","1a") < 0 and CompareFold("1a","01a")
== 0, yet CompareFold("1","01a") > 0.

SQLite requires a collating function to be transitive and leaves ORDER BY
undefined otherwise, so registering this as NATSORT was not safe. Reproduced with
the real driver on three artist names that occur in practice - "3", "3 doors
down" and "03 greedo" - where paging one row at a time returned "03 greedo"
twice and dropped "3" entirely.

The padding difference is now carried as a tie-break that is applied only when
the strings are otherwise equal, which restores transitivity while keeping the
documented intent (a01 < a1, a0 < a00). Three existing entries changed: each
asserted that two distinct strings compare equal, which was the same defect seen
from the other side.

Found by the Codex review on #6015.
This commit is contained in:
Deluan Quintão 2026-08-23 14:31:37 -04:00 committed by GitHub
parent fc9d93d22a
commit 3e55886195
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 284 additions and 51 deletions

View File

@ -73,6 +73,7 @@ type configOptions struct {
Matcher matcherOptions `json:",omitzero"`
RecentlyAddedByModTime bool
PreferSortTags bool
EnableNaturalSorting bool
IgnoredArticles string
IndexGroups string
FFmpegPath string
@ -973,6 +974,7 @@ func setViperDefaults() {
viper.SetDefault("matcher.fuzzythreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
viper.SetDefault("enablenaturalsorting", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("ffmpegpath", "")

View File

@ -13,10 +13,15 @@ import (
_ "github.com/navidrome/navidrome/db/migrations"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/utils/hasher"
"github.com/navidrome/navidrome/utils/natural"
"github.com/navidrome/navidrome/utils/singleton"
"github.com/pressly/goose/v3"
)
// NaturalCollation sorts embedded numbers by value. It is registered on every
// connection, but only referenced when conf.Server.EnableNaturalSorting is on.
const NaturalCollation = "NATSORT"
var (
Dialect = "sqlite3"
Driver = Dialect + "_custom"
@ -32,7 +37,10 @@ func Db() *sql.DB {
return singleton.GetInstance(func() *sql.DB {
sql.Register(Driver, &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
return err
}
return conn.RegisterCollation(NaturalCollation, natural.CompareFold)
},
})
Path = conf.Server.DbPath

View File

@ -113,7 +113,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"artist": "compilation, order_album_artist_name, order_album_name",
"album_artist": "compilation, order_album_artist_name, order_album_name",
// TODO Rename this to just year (or date)
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name",
"max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + naturalSort("album.name"),
"random": "random",
"recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at",

View File

@ -10,6 +10,7 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
@ -38,6 +39,44 @@ var _ = Describe("AlbumRepository", func() {
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
})
Describe("natural sorting", func() {
var ids []string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ids = nil
for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} {
aid := "nat-" + n
ids = append(ids, aid)
Expect(albumRepo.Put(&model.Album{
ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n,
})).To(Succeed())
}
DeferCleanup(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ids}))
})
})
DescribeTable("sorts albums by name",
func(naturalSorting, preferSortTags bool, expected []string) {
conf.Server.EnableNaturalSorting = naturalSorting
conf.Server.PreferSortTags = preferSortTags
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
albums, err := albumRepo.GetAll(model.QueryOptions{
Sort: "name", Filters: squirrel.Eq{"album.id": ids},
})
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(albums, func(a model.Album) string { return a.Name })).To(Equal(expected))
},
Entry("lexicographically by default", false, false,
[]string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"}),
Entry("by number value when natural sorting is enabled", true, false,
[]string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}),
Entry("by number value with sort tags preferred too", true, true,
[]string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}),
)
})
Describe("Get", func() {
var Get = func(id string) (*model.Album, error) {
album, err := albumRepo.Get(id)

View File

@ -9,6 +9,8 @@ import (
"github.com/Masterminds/squirrel"
"github.com/fatih/structs"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
)
type PostMapper interface {
@ -82,11 +84,28 @@ func (e existsCond) ToSql() (string, []any, error) {
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
// Convert the order_* columns to an expression using sort_* columns. Example:
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
// naturalSort makes a plain text column sort numbers by value, leaving it alone
// otherwise so it keeps its declared collation. Parens guard buildSortOrder's space split.
func naturalSort(col string) string {
if !conf.Server.EnableNaturalSorting {
return col
}
return fmt.Sprintf("(%s collate %s)", col, db.NaturalCollation)
}
// Convert the order_* columns to a collated sort expression, falling back to the
// sort_* column when those are preferred. Example:
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
// It finds order column names anywhere in the substring
func mapSortOrder(tableName, order string) string {
order = strings.ToLower(order)
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
return sortOrderRegex.ReplaceAllString(order, repl)
col := tableName + ".order_$1"
if conf.Server.PreferSortTags {
col = fmt.Sprintf("coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)", tableName)
}
collation := "nocase"
if conf.Server.EnableNaturalSorting {
collation = db.NaturalCollation
}
repl := fmt.Sprintf("(%s collate %s)", col, collation)
return sortOrderRegex.ReplaceAllString(strings.ToLower(order), repl)
}

View File

@ -4,6 +4,8 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -85,22 +87,51 @@ var _ = Describe("Helpers", func() {
})
Describe("mapSortOrder", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("does not change the sort string if there are no order columns", func() {
sort := "album_name asc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(sort))
})
It("changes order columns to sort expression", func() {
sort := "ORDER_ALBUM_NAME asc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
` collate nocase) asc`))
Expect(mapSortOrder("album", "album_name asc")).To(Equal("album_name asc"))
})
DescribeTable("maps order columns to a collated expression",
func(preferSortTags, naturalSorting bool, expected string) {
conf.Server.PreferSortTags = preferSortTags
conf.Server.EnableNaturalSorting = naturalSorting
Expect(mapSortOrder("album", "ORDER_ALBUM_NAME asc")).To(Equal(expected))
},
Entry("qualified column", false, false,
"(album.order_album_name collate nocase) asc"),
Entry("natural collation", false, true,
"(album.order_album_name collate NATSORT) asc"),
Entry("sort tags preferred", true, false,
`(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate nocase) asc`),
Entry("sort tags preferred, natural collation", true, true,
`(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate NATSORT) asc`),
)
It("changes multiple order columns to sort expressions", func() {
conf.Server.PreferSortTags = true
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
mapped := mapSortOrder("album", sort)
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
Expect(mapSortOrder("album", sort)).To(Equal(
`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
})
})
Describe("naturalSort", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("leaves the column alone by default, keeping its declared collation", func() {
Expect(naturalSort("media_file.title")).To(Equal("media_file.title"))
})
It("applies the natural collation when enabled", func() {
conf.Server.EnableNaturalSorting = true
Expect(naturalSort("media_file.title")).To(Equal("(media_file.title collate NATSORT)"))
})
})
})

View File

@ -86,7 +86,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"title": "order_title",
"artist": "order_artist_name, order_album_name, release_date, disc_number, track_number",
"album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"),
"random": "random",
"created_at": "media_file.created_at",
"recently_added": mediaFileRecentlyAddedSort(),

View File

@ -60,7 +60,8 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
"starred": annotationBoolFilter("starred"),
})
r.setSortMappings(map[string]string{
"owner_name": "owner_name",
"name": naturalSort("playlist.name"),
"owner_name": naturalSort("owner_name"),
})
return r
}

View File

@ -5,6 +5,8 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
@ -24,6 +26,39 @@ var _ = Describe("PlaylistRepository", func() {
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
})
Describe("natural sorting", func() {
var ids []string
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableNaturalSorting = true
ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
ids = nil
for _, n := range []string{"mix 1", "mix 10", "mix 2"} {
pls := model.Playlist{Name: n, OwnerID: "userid"}
Expect(repo.Put(&pls)).To(Succeed())
ids = append(ids, pls.ID)
}
DeferCleanup(func() {
for _, id := range ids {
_ = repo.Delete(id)
}
})
})
It("sorts playlist names by number value", func() {
all, err := repo.GetAll(model.QueryOptions{
Sort: "name", Filters: squirrel.Eq{"playlist.id": ids},
})
Expect(err).ToNot(HaveOccurred())
Expect(slice.Map(all, func(p model.Playlist) string { return p.Name })).To(
Equal([]string{"mix 1", "mix 2", "mix 10"}))
})
})
Describe("Count", func() {
It("returns the number of playlists in the DB", func() {
Expect(repo.CountAll()).To(Equal(int64(2)))

View File

@ -56,7 +56,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
"id": "playlist_tracks.id",
"artist": "order_artist_name",
"album_artist": "order_album_artist_name",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"),
"title": "order_title",
"random": "random()",
// To make sure these fields will be whitelisted

View File

@ -113,10 +113,9 @@ func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ..
if len(tableName) > 0 {
tn = tableName[0]
}
if conf.Server.PreferSortTags {
if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting {
for k, v := range mappings {
v = mapSortOrder(tn, v)
mappings[k] = v
mappings[k] = mapSortOrder(tn, v)
}
}
r.sortMappings = mappings

View File

@ -10,15 +10,32 @@ import "strings"
// or a positive value if a > b using natural sort ordering.
//
// When two numeric segments are numerically equal (e.g. "01" vs "1"),
// comparison continues with the remaining suffixes. If one or both
// strings end at the digit boundary, the raw strings are compared
// lexically, which makes leading zeros significant as a tie-breaker
// (e.g. "a01" < "a1", "a0" < "a00").
// comparison continues with the remaining suffixes, and the padding
// difference is kept as a final tie-breaker that only decides strings
// that are otherwise equal (e.g. "a01" < "a1", "a0" < "a00"). Deferring
// it that way is what keeps the ordering transitive, which SQLite
// requires of a collating function.
func Compare(a, b string) int {
return compare(a, b, false)
}
// CompareFold is Compare with ASCII case folding, matching SQLite's NOCASE
// collation: only A-Z fold, bytes >= 0x80 are compared as-is.
func CompareFold(a, b string) int {
return compare(a, b, true)
}
func compare(a, b string, fold bool) int {
ia, ib := 0, 0
// Set when two runs are numerically equal but differently padded. Applying it
// immediately would break transitivity, so it only decides otherwise-equal strings.
padTie := 0
for ia < len(a) && ib < len(b) {
ca, cb := a[ia], b[ib]
da, db := isDigit(ca), isDigit(cb)
if fold {
ca, cb = lower(ca), lower(cb)
}
switch {
case da && db:
@ -35,17 +52,11 @@ func Compare(a, b string) int {
if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 {
return c
}
// Numerically equal. If both sides have trailing data, continue
// comparing after the digit runs. Otherwise fall through to
// lexical comparison of the full remaining strings (which makes
// leading-zero differences significant as a tie-breaker).
if endA < len(a) && endB < len(b) {
ia = endA
ib = endB
continue
if t := strings.Compare(a[ia:endA], b[ib:endB]); t != 0 {
padTie = t
}
return strings.Compare(a[ia:], b[ib:])
ia = endA
ib = endB
case da != db:
return int(ca) - int(cb)
default:
@ -56,7 +67,10 @@ func Compare(a, b string) int {
ib++
}
}
return (len(a) - ia) - (len(b) - ib)
if c := (len(a) - ia) - (len(b) - ib); c != 0 {
return c
}
return padTie
}
// compareNumbers compares two digit strings numerically.
@ -96,3 +110,10 @@ func stripZeros(s string) string {
func isDigit(c byte) bool {
return c >= '0' && c <= '9'
}
func lower(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + 'a' - 'A'
}
return c
}

View File

@ -13,17 +13,23 @@ func TestNatural(t *testing.T) {
RunSpecs(t, "Natural Suite")
}
// expectOrder asserts the sign of cmp(a, b) matches expected.
func expectOrder(cmp func(string, string) int, a, b string, expected int) {
result := cmp(a, b)
switch {
case expected < 0:
ExpectWithOffset(1, result).To(BeNumerically("<", 0), "expected %q < %q", a, b)
case expected > 0:
ExpectWithOffset(1, result).To(BeNumerically(">", 0), "expected %q > %q", a, b)
default:
ExpectWithOffset(1, result).To(Equal(0), "expected %q == %q", a, b)
}
}
var _ = Describe("Compare", func() {
DescribeTable("returns correct ordering",
func(a, b string, expected int) {
result := natural.Compare(a, b)
if expected < 0 {
Expect(result).To(BeNumerically("<", 0), "expected %q < %q", a, b)
} else if expected > 0 {
Expect(result).To(BeNumerically(">", 0), "expected %q > %q", a, b)
} else {
Expect(result).To(Equal(0), "expected %q == %q", a, b)
}
expectOrder(natural.Compare, a, b, expected)
},
// Basic string ordering
Entry("a < b", "a", "b", -1),
@ -67,7 +73,9 @@ var _ = Describe("Compare", func() {
Entry("a00b00 < a0b1", "a00b00", "a0b1", -1),
Entry("a00b00 > a0b0", "a00b00", "a0b0", 1),
Entry("a00b01 > a0b00", "a00b01", "a0b00", 1),
Entry("a00b00 == a0b00", "a00b00", "a0b00", 0),
// Distinct strings must not compare equal: the padding difference in the first
// run decides once everything else matches.
Entry("a00b00 > a0b00", "a00b00", "a0b00", 1),
// Leading zeros at end of string — lexical tie-break
Entry("file01 < file1", "file01", "file1", -1),
@ -109,8 +117,78 @@ var _ = Describe("Compare", func() {
Entry("large: equal",
"a100000000000000000000", "a100000000000000000000", 0),
Entry("large: leading zeros with trailing data",
"a00000000000000000000001x", "a1x", 0),
"a00000000000000000000001x", "a1x", -1),
Entry("large: leading zeros with trailing data (2)",
"a099999999999999999999x", "a99999999999999999999x", 0),
"a099999999999999999999x", "a99999999999999999999x", -1),
)
})
var _ = Describe("CompareFold", func() {
DescribeTable("orders case-insensitively",
func(a, b string, expected int) {
expectOrder(natural.CompareFold, a, b, expected)
},
Entry("numbers compare numerically", "foo 2", "foo 10", -1),
Entry("numbers compare numerically, reversed", "foo 10", "foo 2", 1),
Entry("case is ignored", "apple 2", "Banana 10", -1),
Entry("case is ignored, reversed", "Banana 10", "apple 2", 1),
Entry("same word, different case, is equal", "ABC", "abc", 0),
Entry("case ignored while comparing numbers", "Vol 2", "vol 10", -1),
Entry("uppercase digits boundary", "Track9", "track10", -1),
Entry("empty vs empty", "", "", 0),
Entry("empty sorts first", "", "a", -1),
Entry("non-ASCII is left untouched", "café 2", "café 10", -1),
)
// SQLite requires a collating function to be transitive; if it is not, the behavior of
// ORDER BY is undefined and paginated queries can drop or duplicate rows.
It("is transitive, as a SQLite collation requires", func() {
var corpus []string
var build func(prefix string, depth int)
build = func(prefix string, depth int) {
if prefix != "" {
corpus = append(corpus, prefix)
}
if depth == 0 {
return
}
for _, c := range []string{"0", "1", "a"} {
build(prefix+c, depth-1)
}
}
build("", 3)
sign := func(n int) int {
switch {
case n < 0:
return -1
case n > 0:
return 1
}
return 0
}
for _, a := range corpus {
for _, b := range corpus {
ab := sign(natural.CompareFold(a, b))
for _, c := range corpus {
bc := sign(natural.CompareFold(b, c))
ac := sign(natural.CompareFold(a, c))
if ab == 0 && bc == 0 {
Expect(ac).To(Equal(0), "%q==%q and %q==%q but %q vs %q is %d", a, b, b, c, a, c, ac)
}
if ab < 0 && bc < 0 {
Expect(ac).To(BeNumerically("<", 0), "%q<%q<%q but %q vs %q is %d", a, b, c, a, c, ac)
}
}
}
}
})
It("matches Compare when both sides are already lowercase", func() {
pairs := [][2]string{{"foo 2", "foo 10"}, {"a01", "a1"}, {"a", "aa"}, {"vol 3", "vol 3"}}
for _, p := range pairs {
Expect(natural.CompareFold(p[0], p[1])).To(Equal(natural.Compare(p[0], p[1])),
"CompareFold(%q,%q) should match Compare", p[0], p[1])
}
})
})