Scoped library payloads: load the track list when a view needs it #287
No files matched your search
@@ -3125,6 +3125,19 @@ func (s *Service) EvaluateSmartPlaylist(
|
|||||||
return tracks, nil
|
return tracks, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SuggestSmartPlaylistValues returns values of field present in the
|
||||||
|
// library that contain needle, for the rule editor's value box.
|
||||||
|
func (s *Service) SuggestSmartPlaylistValues(
|
||||||
|
field, needle string,
|
||||||
|
) ([]string, error) {
|
||||||
|
values, err := smartplaylist.SuggestValues(s.db, field, needle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("suggest smart playlist values: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PreviewSmartPlaylist evaluates a rule set from raw JSON without
|
// PreviewSmartPlaylist evaluates a rule set from raw JSON without
|
||||||
// requiring a saved playlist. This powers live preview in the rule
|
// requiring a saved playlist. This powers live preview in the rule
|
||||||
// editor — the frontend sends rules as they are being edited and
|
// editor — the frontend sends rules as they are being edited and
|
||||||
|
|||||||
@@ -614,7 +614,12 @@ const leanTrackQuery = `SELECT
|
|||||||
af.file_size,
|
af.file_size,
|
||||||
af.play_count,
|
af.play_count,
|
||||||
COALESCE(af.last_played, '') AS last_played
|
COALESCE(af.last_played, '') AS last_played
|
||||||
FROM (
|
FROM ` + leanTrackSource
|
||||||
|
|
||||||
|
// leanTrackSource is the joined row every rule's column resolves
|
||||||
|
// against, aliased af. Shared by Evaluate and SuggestValues so a
|
||||||
|
// suggestion is always a value a rule on the same field can match.
|
||||||
|
const leanTrackSource = `(
|
||||||
SELECT
|
SELECT
|
||||||
af.id,
|
af.id,
|
||||||
af.file_path,
|
af.file_path,
|
||||||
@@ -1117,3 +1122,79 @@ func splitGenres(concatenated string) []string {
|
|||||||
|
|
||||||
return strings.Split(concatenated, genreDelimiter)
|
return strings.Split(concatenated, genreDelimiter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// suggestLimit bounds one suggestion answer: a combobox shows a
|
||||||
|
// screenful, and the user narrows by typing.
|
||||||
|
const suggestLimit = 50
|
||||||
|
|
||||||
|
// errNoSuggestions is a field the editor does not offer values for.
|
||||||
|
var errNoSuggestions = errors.New("field has no suggestions")
|
||||||
|
|
||||||
|
// suggestFields are the fields whose values the rule editor suggests.
|
||||||
|
// Every one is in fieldMap; numeric fields other than the years are
|
||||||
|
// ranges, where a list of every value present is no help.
|
||||||
|
var suggestFields = map[string]bool{
|
||||||
|
"title": true, "artist": true, "album": true, "genre": true,
|
||||||
|
"composer": true, "file_type": true, "year": true, "release_year": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestValues returns up to suggestLimit distinct values of field
|
||||||
|
// present in the library that contain needle (case-insensitively),
|
||||||
|
// sorted.
|
||||||
|
//
|
||||||
|
// The rule editor used to build these lists from libraryStore's
|
||||||
|
// whole-library arrays, which made the editor one more reason to fetch
|
||||||
|
// every track at startup (#279) — and gave it an empty list when the
|
||||||
|
// arrays had not landed. Asking for the values that match what has
|
||||||
|
// been typed is a few hundred bytes, whatever the library's size.
|
||||||
|
func SuggestValues(db *database.DB, field, needle string) ([]string, error) {
|
||||||
|
col, ok := fieldMap[field]
|
||||||
|
if !ok || !suggestFields[field] {
|
||||||
|
return nil, fmt.Errorf("%w: %q", errNoSuggestions, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := "%" + escapeLike(needle) + "%"
|
||||||
|
|
||||||
|
// SAFETY: col comes from fieldMap, never from the caller; the
|
||||||
|
// needle is a bound parameter.
|
||||||
|
query := `SELECT DISTINCT CAST(af.` + col + ` AS TEXT) AS v FROM ` +
|
||||||
|
leanTrackSource + `
|
||||||
|
WHERE v != '' AND v != '0' AND v LIKE ? ESCAPE '\'
|
||||||
|
ORDER BY v COLLATE NOCASE LIMIT ?`
|
||||||
|
|
||||||
|
if field == "genre" {
|
||||||
|
query = `SELECT name FROM genres
|
||||||
|
WHERE name != '' AND name LIKE ? ESCAPE '\'
|
||||||
|
ORDER BY name COLLATE NOCASE LIMIT ?`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(query, pattern, suggestLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("suggest %s: %w", field, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
values := make([]string, 0, suggestLimit)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var v string
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, fmt.Errorf("suggest %s: %w", field, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("suggest %s: %w", field, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeLike makes needle match itself literally inside a LIKE pattern
|
||||||
|
// with ESCAPE '\'.
|
||||||
|
func escapeLike(needle string) string {
|
||||||
|
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(needle)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package smartplaylist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"yellowjacket/backend/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The rule editor's value box asks the backend for what matches the
|
||||||
|
// text typed (#279), rather than building every list from the whole
|
||||||
|
// library loaded into the frontend.
|
||||||
|
func TestSuggestValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
seedSmartPlaylistData(t, db)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
field, needle string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
// Case-insensitive substring, distinct, sorted.
|
||||||
|
{"artist", "q", []string{"QOTSA", "Queen"}},
|
||||||
|
{"album", "OPERA", []string{"A Night at the Opera"}},
|
||||||
|
// Genres come from the genre table, one value per genre, not
|
||||||
|
// from a concatenated per-track column.
|
||||||
|
{"genre", "rock", []string{
|
||||||
|
"Funk Rock", "Hard Rock", "Progressive Rock", "Rock", "Stoner Rock",
|
||||||
|
}},
|
||||||
|
// Years are numbers and are suggested as their text.
|
||||||
|
{"year", "199", []string{"1990", "1991"}},
|
||||||
|
// The needle is literal: a LIKE wildcard in it matches only itself.
|
||||||
|
{"title", "%", []string{}},
|
||||||
|
{"composer", "_", []string{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, err := SuggestValues(db, tc.field, tc.needle)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestValues(%q, %q): %v", tc.field, tc.needle, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(got, tc.want) {
|
||||||
|
t.Errorf("SuggestValues(%q, %q) = %q, want %q", tc.field, tc.needle, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestValuesRefusesFieldsItDoesNotSuggest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := database.NewTestDB(t)
|
||||||
|
|
||||||
|
for _, field := range []string{"duration", "file_path", "title; DROP TABLE x"} {
|
||||||
|
if _, err := SuggestValues(db, field, ""); !errors.Is(err, errNoSuggestions) {
|
||||||
|
t.Errorf("SuggestValues(%q) err = %v, want errNoSuggestions", field, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -309,6 +309,14 @@ export function SearchLibrary(query: string): $CancellablePromise<$models.Candid
|
|||||||
return $Call.ByID(3912116995, query);
|
return $Call.ByID(3912116995, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SuggestSmartPlaylistValues returns values of field present in the
|
||||||
|
* library that contain needle, for the rule editor's value box.
|
||||||
|
*/
|
||||||
|
export function SuggestSmartPlaylistValues(field: string, needle: string): $CancellablePromise<string[] | null> {
|
||||||
|
return $Call.ByID(3718198115, field, needle);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ToggleDefaultPlaylistTrack adds or removes a single track
|
* ToggleDefaultPlaylistTrack adds or removes a single track
|
||||||
* from the default playlist. Returns true if the track is now
|
* from the default playlist. Returns true if the track is now
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { designTokens } from '../../styles/tokens.css';
|
|||||||
* keyboard navigation. Accepts a flat `options` string array, filters as
|
* keyboard navigation. Accepts a flat `options` string array, filters as
|
||||||
* the user types, and emits `combobox-change` when a value is selected.
|
* the user types, and emits `combobox-change` when a value is selected.
|
||||||
*
|
*
|
||||||
|
* It also emits `combobox-input` with `{ text }` whenever the text it
|
||||||
|
* filters by changes (typing, and the reset to empty on focus), so a
|
||||||
|
* host whose options are too many to hand over at once can fetch the
|
||||||
|
* ones matching what was typed instead (#279).
|
||||||
|
*
|
||||||
* Key implementation detail: option `<li>` elements use `@mousedown` with
|
* Key implementation detail: option `<li>` elements use `@mousedown` with
|
||||||
* `e.preventDefault()` so that the input's `blur` event does not close the
|
* `e.preventDefault()` so that the input's `blur` event does not close the
|
||||||
* dropdown before the click registers.
|
* dropdown before the click registers.
|
||||||
@@ -196,6 +201,7 @@ export class YjCombobox extends LitElement {
|
|||||||
this.filterText = input.value;
|
this.filterText = input.value;
|
||||||
this.open = true;
|
this.open = true;
|
||||||
this.highlightedIndex = -1;
|
this.highlightedIndex = -1;
|
||||||
|
this.announceInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleFocus() {
|
private handleFocus() {
|
||||||
@@ -203,6 +209,17 @@ export class YjCombobox extends LitElement {
|
|||||||
this.filterText = '';
|
this.filterText = '';
|
||||||
this.open = true;
|
this.open = true;
|
||||||
this.highlightedIndex = -1;
|
this.highlightedIndex = -1;
|
||||||
|
this.announceInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
private announceInput() {
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('combobox-input', {
|
||||||
|
detail: { text: this.filterText },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleBlur() {
|
private handleBlur() {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import * as library from '@go/library/models.js';
|
import * as library from '@go/library/models.js';
|
||||||
import { PreviewSmartPlaylist } from '@go/playlist/service.js';
|
import {
|
||||||
import { libraryStore } from '@store/library-store';
|
PreviewSmartPlaylist,
|
||||||
|
SuggestSmartPlaylistValues,
|
||||||
|
} from '@go/playlist/service.js';
|
||||||
|
import { list } from '@utils/binding';
|
||||||
|
import { LRUMap } from '@utils/lru-map';
|
||||||
import { describeError } from '@utils/describe-error';
|
import { describeError } from '@utils/describe-error';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import '@components/combobox/combobox.ts';
|
import '@components/combobox/combobox.ts';
|
||||||
@@ -91,51 +95,23 @@ function formatOperatorLabel(op: string): string {
|
|||||||
return op.replace(/_/g, ' ');
|
return op.replace(/_/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns autocomplete suggestions for a given field from libraryStore. */
|
/**
|
||||||
function getAutocompleteOptions(field: string): string[] {
|
* Fields whose value box suggests values present in the library. Must
|
||||||
switch (field) {
|
* match `suggestFields` in backend/smartplaylist; any other field gets
|
||||||
case 'artist':
|
* a plain box.
|
||||||
return libraryStore.getCachedArtists()?.map((a) => a.Name) ?? [];
|
*
|
||||||
case 'genre':
|
* Suggestions are asked for as the user types (#279): they used to be
|
||||||
return libraryStore.getCachedGenres()?.map((g) => g.Name) ?? [];
|
* built from libraryStore's whole-library arrays, which made this
|
||||||
case 'album':
|
* editor one more reason to load every track at startup, and gave it
|
||||||
return libraryStore.getCachedAlbums()?.map((a) => a.Name) ?? [];
|
* empty lists whenever those arrays had not landed.
|
||||||
case 'title': {
|
*/
|
||||||
const tracks = libraryStore.getCachedTracks();
|
const SUGGEST_FIELDS = new Set([
|
||||||
if (!tracks) return [];
|
'title', 'artist', 'album', 'genre', 'composer', 'file_type',
|
||||||
return [...new Set(tracks.map((t) => t.TrackName).filter(Boolean))];
|
'year', 'release_year',
|
||||||
}
|
]);
|
||||||
case 'composer': {
|
|
||||||
const tracks = libraryStore.getCachedTracks();
|
/** How long typing must pause before suggestions are asked for. */
|
||||||
if (!tracks) return [];
|
const SUGGEST_DEBOUNCE_MS = 120;
|
||||||
return [...new Set(tracks.map((t) => t.Composer).filter(Boolean))];
|
|
||||||
}
|
|
||||||
case 'file_type': {
|
|
||||||
const tracks = libraryStore.getCachedTracks();
|
|
||||||
if (!tracks) return [];
|
|
||||||
return [...new Set(tracks.map((t) => t.FileType).filter(Boolean))];
|
|
||||||
}
|
|
||||||
case 'year':
|
|
||||||
case 'release_year': {
|
|
||||||
// Both year fields draw suggestions from the set of years
|
|
||||||
// present in the library. The cached Track only carries the
|
|
||||||
// display (original) year, so it seeds both datalists — the
|
|
||||||
// list is just a hint, and the real filter runs server-side.
|
|
||||||
const tracks = libraryStore.getCachedTracks();
|
|
||||||
if (!tracks) return [];
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
tracks
|
|
||||||
.map((t) => t.Year)
|
|
||||||
.filter((y) => y > 0)
|
|
||||||
.map(String),
|
|
||||||
),
|
|
||||||
].sort();
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides for fields whose title-cased name would be ambiguous. The
|
* Overrides for fields whose title-cased name would be ambiguous. The
|
||||||
@@ -206,6 +182,17 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
|
|
||||||
private previewTimer: ReturnType<typeof setTimeout> | null = null;
|
private previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** The value suggestions each rule row is showing, by row index. */
|
||||||
|
@state() private suggestions = new Map<number, readonly string[]>();
|
||||||
|
|
||||||
|
private suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Answers already fetched this session, by field and typed text. */
|
||||||
|
private suggestCache = new LRUMap<string, readonly string[]>(100);
|
||||||
|
|
||||||
|
/** The latest request per row, so a slow answer cannot overwrite a newer one. */
|
||||||
|
private suggestWanted = new Map<number, string>();
|
||||||
|
|
||||||
// ── Styles ──────────────────────────────────────────────────────
|
// ── Styles ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
static override styles = [
|
static override styles = [
|
||||||
@@ -599,6 +586,7 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
// Reset value when field changes to avoid stale autocomplete data
|
// Reset value when field changes to avoid stale autocomplete data
|
||||||
row.value = '';
|
row.value = '';
|
||||||
row.value2 = '';
|
row.value2 = '';
|
||||||
|
this.dropSuggestions();
|
||||||
|
|
||||||
this.ruleRows = [...this.ruleRows];
|
this.ruleRows = [...this.ruleRows];
|
||||||
this.onRulesChanged();
|
this.onRulesChanged();
|
||||||
@@ -628,6 +616,56 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
this.onRulesChanged();
|
this.onRulesChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ask for the values of row `index`'s field that contain `text`. */
|
||||||
|
private requestSuggestions(index: number, text: string) {
|
||||||
|
const field = this.ruleRows[index]?.field ?? '';
|
||||||
|
|
||||||
|
if (!SUGGEST_FIELDS.has(field)) return;
|
||||||
|
|
||||||
|
const key = `${field}\u0000${text}`;
|
||||||
|
|
||||||
|
this.suggestWanted.set(index, key);
|
||||||
|
|
||||||
|
const cached = this.suggestCache.get(key);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
this.setSuggestions(index, cached);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.suggestTimer !== null) clearTimeout(this.suggestTimer);
|
||||||
|
|
||||||
|
this.suggestTimer = setTimeout(() => {
|
||||||
|
this.suggestTimer = null;
|
||||||
|
void list(SuggestSmartPlaylistValues(field, text))
|
||||||
|
.then((values) => {
|
||||||
|
this.suggestCache.set(key, values);
|
||||||
|
|
||||||
|
if (this.suggestWanted.get(index) === key) {
|
||||||
|
this.setSuggestions(index, values);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
// A suggestion is a hint; the box still takes any text.
|
||||||
|
console.error('smart playlist: suggestions failed', err);
|
||||||
|
});
|
||||||
|
}, SUGGEST_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSuggestions(index: number, values: readonly string[]) {
|
||||||
|
const next = new Map(this.suggestions);
|
||||||
|
|
||||||
|
next.set(index, values);
|
||||||
|
this.suggestions = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row indexes and fields changed: what was suggested no longer applies. */
|
||||||
|
private dropSuggestions() {
|
||||||
|
this.suggestions = new Map();
|
||||||
|
this.suggestWanted.clear();
|
||||||
|
}
|
||||||
|
|
||||||
private updateValue2(index: number, newValue: string) {
|
private updateValue2(index: number, newValue: string) {
|
||||||
const row = this.ruleRows[index];
|
const row = this.ruleRows[index];
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
@@ -644,6 +682,7 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
private removeRule(index: number) {
|
private removeRule(index: number) {
|
||||||
if (this.ruleRows.length <= 1) return;
|
if (this.ruleRows.length <= 1) return;
|
||||||
this.ruleRows = this.ruleRows.filter((_, i) => i !== index);
|
this.ruleRows = this.ruleRows.filter((_, i) => i !== index);
|
||||||
|
this.dropSuggestions();
|
||||||
this.onRulesChanged();
|
this.onRulesChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,7 +931,9 @@ export class SmartPlaylistEditor extends LitElement {
|
|||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
<yj-combobox
|
<yj-combobox
|
||||||
.options=${getAutocompleteOptions(row.field)}
|
.options=${this.suggestions.get(index) ?? []}
|
||||||
|
@combobox-input=${(e: CustomEvent<{ text: string }>) =>
|
||||||
|
this.requestSuggestions(index, e.detail.text)}
|
||||||
.value=${row.value}
|
.value=${row.value}
|
||||||
placeholder=${isAnyOf
|
placeholder=${isAnyOf
|
||||||
? 'Comma-separated values'
|
? 'Comma-separated values'
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* The rule editor's value box suggests values from the library by
|
||||||
|
* asking for the ones that match what has been typed (#279).
|
||||||
|
*
|
||||||
|
* It used to build the lists from `libraryStore`'s whole-library
|
||||||
|
* arrays: one more reason to load every track at startup, and an empty
|
||||||
|
* list whenever they had not loaded. These tests stub *no* library
|
||||||
|
* collection — what is asserted is that the suggestions arrive anyway,
|
||||||
|
* from the one call made for them.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/smart-playlist-editor/smart-playlist-editor';
|
||||||
|
import type { YjCombobox } from '@components/combobox/combobox';
|
||||||
|
import { calls, resetHarness, stub } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
const SUGGEST = 'playlist.Service.SuggestSmartPlaylistValues';
|
||||||
|
|
||||||
|
async function editor(field: string): Promise<LitElement> {
|
||||||
|
return fixture<LitElement>('smart-playlist-editor', {
|
||||||
|
rules: JSON.stringify({ rules: [{ field, operator: 'contains', value: '' }] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The row's second combobox: the value box (the first picks the field). */
|
||||||
|
function valueBox(el: LitElement): YjCombobox {
|
||||||
|
return shadowAll<YjCombobox>(el, 'yj-combobox')[1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function type(box: YjCombobox, text: string): Promise<void> {
|
||||||
|
const input = shadow<HTMLInputElement>(box, 'input')!;
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await box.updateComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('smart playlist value suggestions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetHarness();
|
||||||
|
stub(SUGGEST, (field: string, needle: string) =>
|
||||||
|
[`${field}:${needle}:1`, `${field}:${needle}:2`],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for the values matching what was typed, once typing pauses', async () => {
|
||||||
|
const el = await editor('artist');
|
||||||
|
const box = valueBox(el);
|
||||||
|
|
||||||
|
await type(box, 'q');
|
||||||
|
await type(box, 'qu');
|
||||||
|
|
||||||
|
await expect.poll(() => box.options).toEqual(['artist:qu:1', 'artist:qu:2']);
|
||||||
|
// Debounced: the pause after "qu" asked, the keystroke before did not.
|
||||||
|
expect(calls(SUGGEST).map((c) => c.args)).toEqual([['artist', 'qu']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers a repeat from what it already fetched', async () => {
|
||||||
|
const el = await editor('album');
|
||||||
|
const box = valueBox(el);
|
||||||
|
|
||||||
|
await type(box, 'x');
|
||||||
|
await expect.poll(() => box.options).toEqual(['album:x:1', 'album:x:2']);
|
||||||
|
await type(box, 'xy');
|
||||||
|
await expect.poll(() => box.options).toEqual(['album:xy:1', 'album:xy:2']);
|
||||||
|
await type(box, 'x');
|
||||||
|
|
||||||
|
expect(box.options).toEqual(['album:x:1', 'album:x:2']);
|
||||||
|
expect(calls(SUGGEST)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks nothing for a field that has no suggestions', async () => {
|
||||||
|
const el = await editor('duration');
|
||||||
|
const boxes = shadowAll<YjCombobox>(el, 'yj-combobox');
|
||||||
|
|
||||||
|
// A numeric range field may render no value combobox at all; if it
|
||||||
|
// does, typing in it must not ask.
|
||||||
|
if (boxes[1]) await type(boxes[1], '3');
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
|
||||||
|
expect(calls(SUGGEST)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user