Merge branch 'master' into feat-podcast-ux-improvements/5420

This commit is contained in:
Jiho Persy Lee 2026-08-19 10:04:00 +09:00 committed by GitHub
commit f5903dee70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 448 additions and 20 deletions

View File

@ -12,8 +12,10 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/ioutils"
"github.com/zeebo/xxh3"
"golang.org/x/text/unicode/norm"
)
@ -57,10 +59,12 @@ func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bo
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
hasher := xxh3.New()
reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
if err := s.parseM3U(ctx, pls, nil, reader); err != nil {
return nil, err
}
pls.ImportedHash = fingerprint(hasher)
if err := s.updatePlaylist(ctx, pls, sync); err != nil {
return nil, err
}
@ -138,7 +142,9 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
}
defer file.Close()
reader := ioutils.UTF8Reader(file)
// Hash the bytes the parser consumes, giving every imported playlist a content fingerprint
hasher := xxh3.New()
reader := io.TeeReader(ioutils.UTF8Reader(file), hasher)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
@ -146,7 +152,15 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
default:
err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
if err != nil {
return pls, err
}
pls.ImportedHash = fingerprint(hasher)
return pls, nil
}
func fingerprint(h *xxh3.Hasher) string {
return id.Encode(h.Sum128().Bytes())
}
// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode
@ -179,6 +193,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
}
if err == nil {
// Only smart playlists skip on an unchanged file; M3U must re-run so newly-added tracks resolve.
if !forceSync && newPls.IsSmartPlaylist() && newPls.ImportedHash != "" && newPls.ImportedHash == pls.ImportedHash {
log.Trace(ctx, "Playlist file unchanged since last import, skipping", "playlist", pls.Name, "path", pls.Path)
*newPls = *pls // callers must see the stored record, so e.g. ImportFile can still flip Sync
return nil
}
log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path)
newPls.ID = pls.ID
newPls.Name = pls.Name
@ -187,6 +207,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist,
newPls.Public = pls.Public
newPls.UploadedImage = pls.UploadedImage // Preserve manual upload
newPls.EvaluatedAt = nil // force re-evaluation on next read
if newPls.IsSmartPlaylist() {
// Tracks aren't materialized at parse time; carry the stored counters so callers see real values
newPls.SongCount = pls.SongCount
newPls.Duration = pls.Duration
newPls.Size = pls.Size
}
} else {
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
newPls.OwnerID = owner.ID

View File

@ -15,10 +15,12 @@ import (
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/zeebo/xxh3"
"golang.org/x/text/unicode/norm"
)
@ -307,6 +309,33 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.ID).To(BeEmpty())
})
It("stores a content hash but re-imports unchanged M3U playlists", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}}
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
plsFile := filepath.Join(tmpDir, "test.m3u")
Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed())
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
first, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(first.ImportedHash).ToNot(BeEmpty())
// Re-import with a matching stored hash: M3U must still be re-imported, not skipped.
existingPls := &model.Playlist{
ID: "m3u-id", Name: "Test", Path: plsFile, Sync: true,
OwnerID: "123", ImportedHash: first.ImportedHash,
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
mockPlsRepo.Last = nil
_, err = ps.ImportFromFolder(ctx, plsFolder, "test.m3u")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).ToNot(BeNil())
})
It("clears ExternalImageURL on re-scan when directive is removed", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
@ -371,6 +400,85 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.Name).To(Equal("Recently Played"))
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
})
It("preserves counters when re-importing an existing smart playlist", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}],"sort":"title","order":"asc"}`
plsFile := filepath.Join(tmpDir, "smart.nsp")
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "smart-id",
Name: "My Smart",
Path: plsFile,
Sync: true,
OwnerID: "123",
SongCount: 42,
Duration: 123.4,
Size: 5000,
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).ToNot(BeNil())
Expect(mockPlsRepo.Last.IsSmartPlaylist()).To(BeTrue())
Expect(mockPlsRepo.Last.SongCount).To(Equal(42))
Expect(mockPlsRepo.Last.Duration).To(Equal(float32(123.4)))
Expect(mockPlsRepo.Last.Size).To(Equal(int64(5000)))
})
It("skips re-import when the smart playlist file content is unchanged", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
plsFile := filepath.Join(tmpDir, "smart.nsp")
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
OwnerID: "123", SongCount: 42,
ImportedHash: hashOf(nsp),
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).To(BeNil()) // Put never called: nothing re-written
})
It("re-imports when the smart playlist file content changed", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
plsFile := filepath.Join(tmpDir, "smart.nsp")
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
OwnerID: "123", SongCount: 42,
ImportedHash: hashOf("old content"),
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""}
_, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last).ToNot(BeNil()) // Put called: file changed
Expect(mockPlsRepo.Last.ImportedHash).To(Equal(hashOf(nsp)))
})
})
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
@ -760,6 +868,37 @@ var _ = Describe("Playlists - Import", func() {
Expect(pls.ID).To(Equal("existing-id"))
Expect(pls.Sync).To(BeTrue())
})
It("unsyncs a synced smart playlist with sync=false even when content is unchanged", func() {
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
mockFolderRepo := &mockFolderRepoForImport{
folder: &model.Folder{
ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "",
},
}
ds.MockedFolder = mockFolderRepo
ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds))
nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}`
plsFile := filepath.Join(tmpDir, "smart.nsp")
Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed())
existingPls := &model.Playlist{
ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true,
OwnerID: "123", SongCount: 42,
ImportedHash: hashOf(nsp),
}
mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls}
pls, err := ps.ImportFile(ctx, plsFile, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.ID).To(Equal("smart-id"))
Expect(pls.Sync).To(BeFalse())
Expect(mockPlsRepo.Last).ToNot(BeNil())
Expect(mockPlsRepo.Last.Sync).To(BeFalse())
})
})
Describe("ImportM3U", func() {
@ -1083,3 +1222,7 @@ func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.F
}
return nil, model.ErrNotFound
}
func hashOf(content string) string {
return id.Encode(xxh3.Hash128([]byte(content)).Bytes())
}

View File

@ -136,6 +136,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod
if rulesChanged {
current.Rules = entity.Rules
current.EvaluatedAt = nil // force re-evaluation on next read
current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it
}
if sent("sync") && current.Path != "" && current.Sync != entity.Sync {
current.Sync = entity.Sync

View File

@ -160,6 +160,25 @@ var _ = Describe("REST Adapter", func() {
Expect(mockPlsRepo.Last.Rules).To(Equal(newRules))
})
It("invalidates the imported hash when rules change, so the next scan re-syncs the file", func() {
mockPlsRepo.Data["smart-1"] = &model.Playlist{
ID: "smart-1",
Name: "Smart Playlist",
OwnerID: "user-1",
Path: "/music/smart.nsp",
Sync: true,
ImportedHash: hashOf("file content"),
Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}},
}
ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false})
repo = ps.NewRepository(ctx).(rest.Persistable)
newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}}
pls := &model.Playlist{Rules: newRules}
err := repo.Update("smart-1", pls, "rules")
Expect(err).ToNot(HaveOccurred())
Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty())
})
It("allows toggling sync for file-backed playlists", func() {
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
mockPlsRepo.Data["file-pls"] = &model.Playlist{

View File

@ -0,0 +1,5 @@
-- +goose Up
alter table playlist add imported_hash varchar default '' not null;
-- +goose Down
alter table playlist drop column imported_hash;

View File

@ -31,6 +31,7 @@ type Playlist struct {
ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
ImportedHash string `structs:"imported_hash" json:"-"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`

View File

@ -39,6 +39,10 @@ func (p dbPlaylist) PostMapArgs(args map[string]any) error {
if err != nil {
return fmt.Errorf("invalid criteria expression: %w", err)
}
// Smart playlist counters are owned by refreshCounters (evaluation), never by callers
delete(args, "song_count")
delete(args, "duration")
delete(args, "size")
return nil
}
delete(args, "rules")

View File

@ -7,6 +7,7 @@ import (
"github.com/deluan/rest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/slice"
. "github.com/onsi/ginkgo/v2"
@ -230,6 +231,32 @@ var _ = Describe("PlaylistRepository", func() {
})
})
Describe("Put", func() {
It("does not overwrite counters when saving a smart playlist", func() {
pls := model.Playlist{Name: "Smart Counters", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "love"}},
}}
Expect(repo.Put(&pls)).To(Succeed())
DeferCleanup(func() { Expect(repo.Delete(pls.ID)).To(Succeed()) })
// Simulate a previous evaluation having stored the counters
_, err := GetDBXBuilder().NewQuery("update playlist set song_count = 42, duration = 123, size = 456 where id = {:id}").
Bind(dbx.Params{"id": pls.ID}).Execute()
Expect(err).ToNot(HaveOccurred())
pls.SongCount = 0
pls.Duration = 0
pls.Size = 0
Expect(repo.Put(&pls)).To(Succeed())
saved, err := repo.Get(pls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(saved.SongCount).To(Equal(42))
Expect(saved.Duration).To(Equal(float32(123)))
Expect(saved.Size).To(Equal(int64(456)))
})
})
It("Put/Exists/Delete", func() {
By("saves the playlist to the DB")
newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}

View File

@ -58,6 +58,41 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() {
})
})
Context("re-imported from disk", func() {
// The scanner re-imports every playlist in a touched folder, and a freshly parsed
// .nsp carries no counters — saving it must not wipe the ones already evaluated.
It("keeps the stored counters when a freshly parsed playlist is saved over it", func() {
rules = &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Antenna"},
},
}
pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true}
Expect(repo.Put(&pls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(pls.ID) })
evaluated, err := repo.GetWithTracks(pls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(evaluated.SongCount).To(BeNumerically(">", 0))
stored, err := repo.Get(pls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(stored.SongCount).To(Equal(evaluated.SongCount))
reimported := model.Playlist{
ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules,
Path: pls.Path, Sync: true,
}
Expect(repo.Put(&reimported)).To(Succeed())
afterImport, err := repo.Get(pls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(afterImport.SongCount).To(Equal(stored.SongCount))
Expect(afterImport.Duration).To(Equal(stored.Duration))
Expect(afterImport.Size).To(Equal(stored.Size))
})
})
Context("child smart playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())

View File

@ -17,9 +17,10 @@ import (
)
type translation struct {
ID string `json:"id"`
Name string `json:"name"`
Data string `json:"data"`
ID string `json:"id"`
Name string `json:"name"`
Data string `json:"data"`
TermCount int `json:"termCount"`
}
func newTranslationRepository(context.Context) rest.Repository {
@ -97,27 +98,46 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err
// Load translation from json file
file, err := fsys.Open(filePath)
if err != nil {
return
return translation, err
}
data, err := io.ReadAll(file)
if err != nil {
return
return translation, err
}
var out map[string]any
if err = json.Unmarshal(data, &out); err != nil {
return
return translation, err
}
// Compress JSON
buf := new(bytes.Buffer)
if err = json.Compact(buf, data); err != nil {
return
return translation, err
}
translation.Data = buf.String()
translation.Name = out["languageName"].(string)
translation.ID = id
return
translation.TermCount = countTranslatedTerms(out)
return translation, nil
}
// countTranslatedTerms counts non-empty leaf values, matching the UI's notion of a translated term
func countTranslatedTerms(obj map[string]any) int {
count := 0
for _, v := range obj {
switch v := v.(type) {
case map[string]any:
count += countTranslatedTerms(v)
case string:
if v != "" {
count++
}
default:
count++
}
}
return count
}
var _ rest.Repository = (*translationRepository)(nil)

View File

@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
"testing/fstest"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/resources"
@ -45,5 +46,16 @@ var _ = Describe("Translations", func() {
var out map[string]any
Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil())
})
It("counts only non-empty leaf terms", func() {
fsys := fstest.MapFS{
"i18n/test.json": &fstest.MapFile{
Data: []byte(`{"languageName":"Test","a":"x","b":"","nested":{"c":"y","d":""}}`),
},
}
tr, err := loadTranslation(fsys, "test.json")
Expect(err).To(BeNil())
Expect(tr.TermCount).To(Equal(3))
})
})
})

View File

@ -42,13 +42,14 @@ const removeEmpty = (obj) => {
const prepareLanguage = (lang) => {
removeEmpty(lang)
// Aliases below go on the merged copy: mutating `en` would corrupt the completion baseline
const merged = deepmerge(en, lang)
// Make "albumSong" and "playlistTrack" resource use the same translations as "song"
lang.resources.albumSong = lang.resources.song
lang.resources.playlistTrack = lang.resources.song
merged.resources.albumSong = merged.resources.song
merged.resources.playlistTrack = merged.resources.song
// ra.boolean.null should always be empty
lang.ra.boolean.null = ''
// Fallback to english translations
return deepmerge(en, lang)
merged.ra.boolean.null = ''
return merged
}
export default polyglotI18nProvider((locale) => {

View File

@ -0,0 +1,19 @@
import { describe, it, expect, vi } from 'vitest'
import en from './en.json'
vi.mock('../dataProvider', () => ({ default: { getOne: vi.fn() } }))
const countLeaves = (obj) =>
Object.values(obj).reduce(
(sum, v) =>
sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0),
0,
)
describe('i18n provider', () => {
it('does not mutate the bundled English translations', async () => {
const before = countLeaves(en)
await import('./provider')
expect(countLeaves(en)).toEqual(before)
})
})

View File

@ -1,5 +1,22 @@
// React Hook to get a list of all languages available. English is hardcoded
import { useGetList } from 'react-admin'
import en from './en.json'
const countLeaves = (obj) =>
Object.values(obj).reduce(
(sum, v) =>
sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0),
0,
)
const enTermCount = countLeaves(en)
const withPercentage = ({ id, name, termCount }) => {
if (!termCount) return { id, name }
const pct = Math.min(100, Math.round((100 * termCount) / enTermCount))
// Isolate the percentage, or it renders as "(%61)" next to a right-to-left name
return { id, name: `${name} ⁦(${pct}%)⁩` }
}
const useGetLanguageChoices = () => {
const { ids, data, loaded, loading } = useGetList(
@ -9,13 +26,19 @@ const useGetLanguageChoices = () => {
{},
)
const choices = [{ id: 'en', name: 'English' }]
const languages = [{ id: 'en', name: 'English', termCount: enTermCount }]
if (loaded) {
ids.forEach((id) => choices.push({ id: id, name: data[id].name }))
ids.forEach((id) =>
languages.push({
id,
name: data[id].name,
termCount: data[id].termCount,
}),
)
}
choices.sort((a, b) => a.name.localeCompare(b.name))
languages.sort((a, b) => a.name.localeCompare(b.name))
return { choices, loaded, loading }
return { choices: languages.map(withPercentage), loaded, loading }
}
export default useGetLanguageChoices

View File

@ -0,0 +1,92 @@
import { describe, it, expect, vi } from 'vitest'
import { renderHook } from '@testing-library/react-hooks'
import { useGetList } from 'react-admin'
import en from './en.json'
import useGetLanguageChoices from './useGetLanguageChoices'
vi.mock('react-admin', () => ({
useGetList: vi.fn(),
}))
const countLeaves = (obj) =>
Object.values(obj).reduce(
(sum, v) =>
sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0),
0,
)
const enTermCount = countLeaves(en)
// The percentage is wrapped in a left-to-right isolate, so it reads the same
// next to right-to-left language names
const label = (pct) => `⁦(${pct}%)⁩`
const mockLanguages = (languages) => {
const data = {}
languages.forEach((l) => (data[l.id] = l))
useGetList.mockReturnValue({
ids: languages.map((l) => l.id),
data,
loaded: true,
loading: false,
})
}
const choiceFor = (id) => {
const { result } = renderHook(() => useGetLanguageChoices())
return result.current.choices.find((c) => c.id === id)
}
describe('useGetLanguageChoices', () => {
it('appends the completion percentage to incomplete languages', () => {
const termCount = Math.round(enTermCount * 0.62)
mockLanguages([{ id: 'cs', name: 'Čeština', termCount }])
const pct = Math.round((100 * termCount) / enTermCount)
expect(choiceFor('cs').name).toEqual(`Čeština ${label(pct)}`)
})
it('shows 100% for a complete language', () => {
mockLanguages([{ id: 'de', name: 'Deutsch', termCount: enTermCount }])
expect(choiceFor('de').name).toEqual(`Deutsch ${label(100)}`)
})
it('caps the percentage at 100 when a language has extra terms', () => {
mockLanguages([
{ id: 'pt', name: 'Português', termCount: enTermCount + 20 },
])
expect(choiceFor('pt').name).toEqual(`Português ${label(100)}`)
})
it('isolates the percentage next to a right-to-left name', () => {
const termCount = Math.round(enTermCount * 0.61)
mockLanguages([{ id: 'ar', name: 'العربية', termCount }])
const pct = Math.round((100 * termCount) / enTermCount)
expect(choiceFor('ar').name).toEqual(`العربية ⁦(${pct}%)⁩`)
})
it('omits the percentage when the server does not send a term count', () => {
mockLanguages([{ id: 'fr', name: 'Français' }])
expect(choiceFor('fr').name).toEqual('Français')
})
it('shows 100% for the bundled English', () => {
mockLanguages([])
expect(choiceFor('en').name).toEqual(`English ${label(100)}`)
})
it('sorts by language name, ignoring the percentage', () => {
mockLanguages([
{ id: 'no', name: 'Norsk', termCount: enTermCount },
{ id: 'da', name: 'Dansk', termCount: 1 },
])
const { result } = renderHook(() => useGetLanguageChoices())
expect(result.current.choices.map((c) => c.id)).toEqual(['da', 'en', 'no'])
})
})