Offer the searches from before while you type
Searches are kept in local storage and the five that best fit what is in
the box are offered under it: the ones it starts, then the ones it starts
a word of, then the ones it appears in anywhere, most recently searched
first. Walk them with the arrows, take one with enter or a tap, dismiss
with escape. The part that matched is picked out, and rows are built from
text nodes -- these strings are the reader's own and they are going back
on screen.
The box searches as you type, so there is no submit to record and storing
every pause would fill the list with the way to a query rather than the
query: a new entry swallows the ones it grew out of ("hot", "hote",
"hotel" leaves "hotel"), and going back to something shorter adds
nothing. Enter records straight away, since that is a reader saying they
meant it.
That turned up a bug the suggestions would have run into constantly: a
search started while the box's own last search was still out was dropped
-- loadVideos saw a load running and returned -- leaving the grid on the
previous query. Changing the result set now stops the load that is
running before starting the next one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -191,6 +191,53 @@ body.theme-light .top-bar {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Recent searches, under the box. The drawers' colours, but laid over the page
|
||||
background rather than letting it through: this one hangs over thumbnails,
|
||||
and a title showing through a suggestion is a title you try to read. */
|
||||
.search-suggest {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(var(--bg-secondary), var(--bg-secondary)), var(--bg-primary);
|
||||
box-shadow: 0 18px 40px var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-suggest-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-suggest-row:hover,
|
||||
.search-suggest-row.is-active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* The part that matched what is typed, so the eye can see why a row is here. */
|
||||
.search-suggest-hit {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
<header class="top-bar">
|
||||
<div class="logo">Jacuzzi</div>
|
||||
<div class="search-container">
|
||||
<input type="text" id="search-input" placeholder="Search videos...">
|
||||
<input type="text" id="search-input" placeholder="Search videos..."
|
||||
autocomplete="off" role="combobox" aria-expanded="false"
|
||||
aria-controls="search-suggest" aria-autocomplete="list">
|
||||
<button class="search-clear-btn" id="search-clear-btn" type="button" aria-label="Clear search" title="Clear search">✕</button>
|
||||
<div id="search-suggest" class="search-suggest" role="listbox" aria-label="Recent searches" hidden></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="icon-btn reload-toggle" id="reload-channel-btn" title="Reload Channel">
|
||||
|
||||
@@ -24,7 +24,8 @@ App.constants = {
|
||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
|
||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
|
||||
SEARCH_HISTORY_KEY: 'searchHistory'
|
||||
};
|
||||
|
||||
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||
|
||||
@@ -48,6 +48,73 @@ App.session = App.session || {};
|
||||
localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Searches
|
||||
//
|
||||
// What was searched for before, newest first. The grid searches as you
|
||||
// type, so the box never sees a "submit" to record: every pause in the
|
||||
// typing is a search of its own and storing each one would fill the list
|
||||
// with the way to a query rather than the query. So a new entry swallows
|
||||
// the ones it grew out of -- typing "hot", "hote", "hotel" leaves "hotel"
|
||||
// -- and going back to something shorter than what is already stored adds
|
||||
// nothing, since that is a step back through the same typing.
|
||||
// ---------------------------------------------------------------------
|
||||
const SEARCH_HISTORY_MAX = 50;
|
||||
|
||||
App.storage.getSearches = function() {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(App.constants.SEARCH_HISTORY_KEY));
|
||||
return Array.isArray(stored) ? stored.filter((q) => typeof q === 'string' && q.trim()) : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
App.storage.rememberSearch = function(query) {
|
||||
const next = (query || '').trim();
|
||||
if (!next) return App.storage.getSearches();
|
||||
const lower = next.toLowerCase();
|
||||
const stored = App.storage.getSearches();
|
||||
// Already on the way to something longer: nothing new to record.
|
||||
if (stored.some((q) => q.toLowerCase() !== lower && q.toLowerCase().startsWith(lower))) {
|
||||
return stored;
|
||||
}
|
||||
const kept = stored.filter((q) => !lower.startsWith(q.toLowerCase()));
|
||||
kept.unshift(next);
|
||||
const capped = kept.slice(0, SEARCH_HISTORY_MAX);
|
||||
try {
|
||||
localStorage.setItem(App.constants.SEARCH_HISTORY_KEY, JSON.stringify(capped));
|
||||
} catch (err) { /* a full or blocked store is not worth failing a search over */ }
|
||||
return capped;
|
||||
};
|
||||
|
||||
App.storage.forgetSearches = function() {
|
||||
localStorage.removeItem(App.constants.SEARCH_HISTORY_KEY);
|
||||
};
|
||||
|
||||
// The past searches worth offering for what is being typed, best first.
|
||||
// Best means where the typing lands in them -- at the front, at the start
|
||||
// of a word, anywhere at all -- and then which was searched for most
|
||||
// recently. What is already typed in full is not a suggestion.
|
||||
App.storage.suggestSearches = function(query, limit) {
|
||||
const want = Math.max(1, limit || 5);
|
||||
const typed = (query || '').trim().toLowerCase();
|
||||
const scored = [];
|
||||
App.storage.getSearches().forEach((entry, age) => {
|
||||
const candidate = entry.toLowerCase();
|
||||
if (candidate === typed) return;
|
||||
let rank = 0;
|
||||
if (typed) {
|
||||
const at = candidate.indexOf(typed);
|
||||
if (at < 0) return;
|
||||
rank = at === 0 ? 3 : (candidate[at - 1] === ' ' ? 2 : 1);
|
||||
}
|
||||
scored.push({ entry: entry, rank: rank, age: age });
|
||||
});
|
||||
scored.sort((a, b) => (b.rank - a.rank) || (a.age - b.age));
|
||||
return scored.slice(0, want).map((s) => s.entry);
|
||||
};
|
||||
|
||||
// Grid density: 'comfortable' (default) or 'compact' (more, smaller columns).
|
||||
App.storage.getDensity = function() {
|
||||
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';
|
||||
|
||||
@@ -297,6 +297,181 @@ App.ui = App.ui || {};
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Recent searches
|
||||
//
|
||||
// The searches from before, offered under the box as you type. Which ones
|
||||
// and in what order is App.storage's business (suggestSearches); this is
|
||||
// the list itself: when it shows, how it is walked, and what taking one
|
||||
// does. Five at a time, because a list you have to read is not a shortcut.
|
||||
// ---------------------------------------------------------------------
|
||||
App.ui.searchHistory = (function() {
|
||||
const SHOWN = 5;
|
||||
let offered = [];
|
||||
let activeIndex = -1;
|
||||
let bound = false;
|
||||
|
||||
const input = () => document.getElementById('search-input');
|
||||
const list = () => document.getElementById('search-suggest');
|
||||
|
||||
const isOpen = function() {
|
||||
const el = list();
|
||||
return !!el && !el.hidden;
|
||||
};
|
||||
|
||||
const close = function() {
|
||||
const el = list();
|
||||
if (el) {
|
||||
el.hidden = true;
|
||||
el.textContent = ''; // no rows left behind for anything to find
|
||||
}
|
||||
offered = [];
|
||||
activeIndex = -1;
|
||||
const box = input();
|
||||
if (box) {
|
||||
box.setAttribute('aria-expanded', 'false');
|
||||
box.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
};
|
||||
|
||||
// The typed part, picked out of the suggestion it matched. Built as
|
||||
// text nodes rather than markup: these strings are the reader's own,
|
||||
// and they are going back on screen.
|
||||
const rowLabel = function(entry, typed) {
|
||||
const label = document.createElement('span');
|
||||
label.className = 'search-suggest-label';
|
||||
const at = typed ? entry.toLowerCase().indexOf(typed.toLowerCase()) : -1;
|
||||
if (at < 0) {
|
||||
label.textContent = entry;
|
||||
return label;
|
||||
}
|
||||
label.appendChild(document.createTextNode(entry.slice(0, at)));
|
||||
const hit = document.createElement('span');
|
||||
hit.className = 'search-suggest-hit';
|
||||
hit.textContent = entry.slice(at, at + typed.length);
|
||||
label.appendChild(hit);
|
||||
label.appendChild(document.createTextNode(entry.slice(at + typed.length)));
|
||||
return label;
|
||||
};
|
||||
|
||||
const highlight = function(next) {
|
||||
const el = list();
|
||||
if (!el) return;
|
||||
const rows = Array.from(el.querySelectorAll('.search-suggest-row'));
|
||||
activeIndex = next;
|
||||
let activeId = '';
|
||||
rows.forEach((row, i) => {
|
||||
const on = i === activeIndex;
|
||||
row.classList.toggle('is-active', on);
|
||||
row.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
if (on) activeId = row.id;
|
||||
});
|
||||
const box = input();
|
||||
if (!box) return;
|
||||
if (activeId) box.setAttribute('aria-activedescendant', activeId);
|
||||
else box.removeAttribute('aria-activedescendant');
|
||||
};
|
||||
|
||||
const choose = function(query) {
|
||||
const box = input();
|
||||
close();
|
||||
if (!query) return;
|
||||
if (box) {
|
||||
box.value = query;
|
||||
box.blur(); // the list has served its purpose; give the page back
|
||||
}
|
||||
App.videos.handleSearch(query);
|
||||
};
|
||||
|
||||
// Shows what the box's current contents suggest, or hides the list when
|
||||
// nothing does.
|
||||
const refresh = function() {
|
||||
const box = input();
|
||||
const el = list();
|
||||
if (!box || !el) return;
|
||||
const typed = box.value.trim();
|
||||
offered = App.storage.suggestSearches(typed, SHOWN);
|
||||
if (!offered.length) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
el.textContent = '';
|
||||
offered.forEach((entry, i) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'search-suggest-row';
|
||||
row.id = 'search-suggest-' + i;
|
||||
row.setAttribute('role', 'option');
|
||||
row.setAttribute('aria-selected', 'false');
|
||||
row.dataset.query = entry;
|
||||
row.appendChild(rowLabel(entry, typed));
|
||||
// Chosen on pointerdown, before the box loses focus: a click
|
||||
// would arrive after the blur that closes the list.
|
||||
row.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
choose(entry);
|
||||
});
|
||||
row.addEventListener('mousemove', () => highlight(i));
|
||||
el.appendChild(row);
|
||||
});
|
||||
el.hidden = false;
|
||||
box.setAttribute('aria-expanded', 'true');
|
||||
highlight(-1);
|
||||
};
|
||||
|
||||
const onKeyDown = function(event) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
if (!isOpen()) {
|
||||
refresh();
|
||||
if (!isOpen()) return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const step = event.key === 'ArrowDown' ? 1 : -1;
|
||||
const count = offered.length;
|
||||
highlight(((activeIndex + step) % count + count) % count);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const box = input();
|
||||
if (isOpen() && activeIndex >= 0) {
|
||||
event.preventDefault();
|
||||
choose(offered[activeIndex]);
|
||||
return;
|
||||
}
|
||||
// Typed and entered: a search the reader meant, so it is kept
|
||||
// now rather than waiting for the typing to settle.
|
||||
close();
|
||||
if (box) {
|
||||
App.storage.rememberSearch(box.value);
|
||||
App.videos.handleSearch(box.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && isOpen()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Escape closes drawers too
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const bind = function() {
|
||||
const box = input();
|
||||
if (bound || !box) return;
|
||||
bound = true;
|
||||
box.addEventListener('focus', refresh);
|
||||
box.addEventListener('input', refresh);
|
||||
box.addEventListener('keydown', onKeyDown);
|
||||
box.addEventListener('blur', () => { window.setTimeout(close, 0); });
|
||||
document.addEventListener('pointerdown', (event) => {
|
||||
if (!isOpen()) return;
|
||||
if (event.target.closest('.search-container')) return;
|
||||
close();
|
||||
});
|
||||
};
|
||||
|
||||
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Channel picker
|
||||
//
|
||||
@@ -1125,6 +1300,7 @@ App.ui = App.ui || {};
|
||||
// Update the clear button immediately for snappy feedback, but
|
||||
// debounce the actual reload so typing doesn't wipe the grid and
|
||||
// fire a backend request on every keystroke.
|
||||
App.ui.searchHistory.bind();
|
||||
searchInput.addEventListener('input', updateClearVisibility);
|
||||
searchInput.addEventListener('input', () => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
|
||||
@@ -922,6 +922,22 @@ App.videos = App.videos || {};
|
||||
// until the tab stops responding.
|
||||
let loadRunning = false;
|
||||
|
||||
// Stops whatever load is running and releases the guard in the same breath,
|
||||
// so the caller can start the next one straight away. Without this a search
|
||||
// typed while the box's own last search was still out was simply dropped --
|
||||
// loadVideos saw a load running and returned -- and the reader was left
|
||||
// looking at results for what they had typed a moment before. The aborted
|
||||
// call unwinds on its own: its fetch rejects, it commits nothing, and its
|
||||
// `finally` clears these again.
|
||||
const abortLoad = function() {
|
||||
if (state.currentLoadController) {
|
||||
state.currentLoadController.abort();
|
||||
state.currentLoadController = null;
|
||||
}
|
||||
loadRunning = false;
|
||||
state.isLoading = false;
|
||||
};
|
||||
|
||||
// Renders the next page: the prefetched one when it's ready (the common
|
||||
// case), otherwise fetched here and rendered as it lands. `opts.force` is for
|
||||
// callers that know they need content and can't be judged by where the grid
|
||||
@@ -1321,6 +1337,13 @@ App.videos = App.videos || {};
|
||||
clearBtn.disabled = !hasValue;
|
||||
}
|
||||
}
|
||||
// Every search worth running is a search worth remembering: this is
|
||||
// the one place they all come through -- the box, a tag, the command
|
||||
// palette. Storage decides what is worth keeping (see rememberSearch).
|
||||
const searched = document.getElementById('search-input');
|
||||
if (App.storage && typeof App.storage.rememberSearch === 'function') {
|
||||
App.storage.rememberSearch(searched ? searched.value : value);
|
||||
}
|
||||
// A search is a new result set, so it leaves the favorites grid.
|
||||
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||
App.favoritesView.close({ silent: true });
|
||||
@@ -1334,6 +1357,7 @@ App.videos = App.videos || {};
|
||||
App.videos.resetGrid = function() {
|
||||
// The held/in-flight page belongs to the old result set.
|
||||
App.videos.resetPrefetch();
|
||||
abortLoad();
|
||||
state.currentPage = 1;
|
||||
state.hasNextPage = true;
|
||||
state.renderedVideoIds.clear();
|
||||
|
||||
249
tests/smoke_search_history.py
Normal file
249
tests/smoke_search_history.py
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Searches are kept, and offered back under the box as you type.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_search_history.py
|
||||
|
||||
The listing is a fixture and records what it was asked for, so "the search
|
||||
actually ran" is something the test can see rather than infer.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.removeItem('searchHistory');
|
||||
}"""
|
||||
|
||||
HISTORY = """() => JSON.parse(localStorage.getItem('searchHistory') || '[]')"""
|
||||
|
||||
ROWS = """() => Array.from(document.querySelectorAll('#search-suggest .search-suggest-row'))
|
||||
.map((row) => row.dataset.query)"""
|
||||
|
||||
OPEN = """() => !document.getElementById('search-suggest').hidden"""
|
||||
|
||||
ACTIVE = """() => {
|
||||
const row = document.querySelector('#search-suggest .search-suggest-row.is-active');
|
||||
return row ? row.dataset.query : null;
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
mark = "PASS" if condition else "FAIL"
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def items(query):
|
||||
return [{"id": f"fix-{query}-{i}", "title": f"{query or 'anything'} {i}",
|
||||
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": "test",
|
||||
"duration": 1, "isLive": False, "tags": []} for i in range(12)]
|
||||
|
||||
|
||||
def wait_for_query(page, asked, query, ms=4000):
|
||||
"""The search may go out on the request already in flight rather than a new
|
||||
one of its own (the box searches as you type, so there usually is one), so
|
||||
what is waited for is the query reaching the server, not a fresh request."""
|
||||
for _ in range(ms // 100):
|
||||
if query in asked:
|
||||
return True
|
||||
page.wait_for_timeout(100)
|
||||
return query in asked
|
||||
|
||||
|
||||
def type_into(page, text):
|
||||
"""Types a query the way a reader would, a character at a time."""
|
||||
page.click("#search-input")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", text, delay=40)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
asked = []
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1100, "height": 900})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
# With `hold` on, listing requests are recorded but left unanswered, so
|
||||
# the test can act while one is still out.
|
||||
hold = {"on": False, "parked": []}
|
||||
|
||||
def answer(route, query):
|
||||
route.fulfill(status=200, content_type="application/json", body=json.dumps(
|
||||
{"items": items(query), "pageInfo": {"hasNextPage": False}}))
|
||||
|
||||
def listing(route):
|
||||
try:
|
||||
body = json.loads(route.request.post_data or "{}")
|
||||
except Exception:
|
||||
body = {}
|
||||
query = body.get("query", "")
|
||||
asked.append(query)
|
||||
if hold["on"]:
|
||||
hold["parked"].append((route, query))
|
||||
return
|
||||
answer(route, query)
|
||||
|
||||
def release():
|
||||
hold["on"] = False
|
||||
while hold["parked"]:
|
||||
route, query = hold["parked"].pop(0)
|
||||
answer(route, query)
|
||||
|
||||
page.route("**/api/videos", listing)
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
print("\nsearching for something")
|
||||
type_into(page, "hotel")
|
||||
page.wait_for_timeout(1200)
|
||||
stored = page.evaluate(HISTORY)
|
||||
c.ok("the search was kept", "hotel" in stored, str(stored))
|
||||
c.ok("and the typing on the way to it was not",
|
||||
stored == ["hotel"], str(stored))
|
||||
c.ok("the search itself ran", asked and asked[-1] == "hotel", str(asked[-3:]))
|
||||
|
||||
print("\nand for something else, then reloading")
|
||||
type_into(page, "sauna")
|
||||
page.wait_for_timeout(1200)
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=30000)
|
||||
stored = page.evaluate(HISTORY)
|
||||
c.ok("both searches survived the reload",
|
||||
stored == ["sauna", "hotel"], str(stored))
|
||||
|
||||
print("\nwith a history to draw on")
|
||||
page.evaluate("""() => localStorage.setItem('searchHistory', JSON.stringify([
|
||||
'hot tub', 'hotel room', 'shot in the dark', 'sauna', 'steam room',
|
||||
'hot spring', 'cold plunge'
|
||||
]))""")
|
||||
page.click("#search-input")
|
||||
page.wait_for_timeout(300)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("the box offers the recent ones when empty", page.evaluate(OPEN) is True)
|
||||
c.ok("five of them, newest first",
|
||||
rows == ['hot tub', 'hotel room', 'shot in the dark', 'sauna', 'steam room'], str(rows))
|
||||
|
||||
print("\ntyping narrows them down")
|
||||
page.type("#search-input", "hot", delay=40)
|
||||
page.wait_for_timeout(200)
|
||||
rows = page.evaluate(ROWS)
|
||||
print(f" (offered: {rows})")
|
||||
c.ok("only the ones it appears in are left",
|
||||
set(rows) == {'hot tub', 'hotel room', 'hot spring', 'shot in the dark'}, str(rows))
|
||||
c.ok("the ones it starts come first",
|
||||
rows[:3] == ['hot tub', 'hotel room', 'hot spring'], str(rows))
|
||||
c.ok("and the one it is buried in comes last",
|
||||
rows[-1] == 'shot in the dark', str(rows))
|
||||
c.ok("the part that matched is picked out",
|
||||
page.evaluate("""() => {
|
||||
const hit = document.querySelector('#search-suggest .search-suggest-hit');
|
||||
return !!hit && hit.textContent.toLowerCase() === 'hot';
|
||||
}"""))
|
||||
|
||||
print("\nwhat is already typed in full is not a suggestion")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", "sauna", delay=20)
|
||||
page.wait_for_timeout(200)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("it offers itself back to nobody",
|
||||
'sauna' not in rows and page.evaluate(OPEN) is False, str(rows))
|
||||
|
||||
print("\ntaking one with the keyboard")
|
||||
page.fill("#search-input", "")
|
||||
page.type("#search-input", "hot", delay=20)
|
||||
page.wait_for_timeout(200)
|
||||
page.keyboard.press("ArrowDown")
|
||||
first = page.evaluate(ACTIVE)
|
||||
page.keyboard.press("ArrowDown")
|
||||
second = page.evaluate(ACTIVE)
|
||||
c.ok("the arrows walk the list", first == 'hot tub' and second == 'hotel room',
|
||||
f"{first} then {second}")
|
||||
asked.clear()
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("enter takes the one highlighted",
|
||||
page.evaluate("() => document.getElementById('search-input').value") == 'hotel room')
|
||||
c.ok("and searches for it", wait_for_query(page, asked, 'hotel room'), str(asked))
|
||||
c.ok("the list is put away", page.evaluate(OPEN) is False)
|
||||
|
||||
print("\ntaking one with the pointer")
|
||||
page.fill("#search-input", "")
|
||||
page.click("#search-input")
|
||||
page.type("#search-input", "st", delay=20)
|
||||
page.wait_for_timeout(250)
|
||||
asked.clear()
|
||||
rows = page.evaluate(ROWS)
|
||||
page.click(f"#search-suggest .search-suggest-row:nth-of-type(1)")
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("the one clicked is what gets searched for",
|
||||
page.evaluate("() => document.getElementById('search-input').value") == rows[0]
|
||||
and wait_for_query(page, asked, rows[0]), f"{rows} then {asked}")
|
||||
|
||||
print("\nescape")
|
||||
page.fill("#search-input", "")
|
||||
page.click("#search-input")
|
||||
page.wait_for_timeout(250)
|
||||
c.ok("the list is up again", page.evaluate(OPEN) is True)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(150)
|
||||
c.ok("escape puts it away", page.evaluate(OPEN) is False)
|
||||
|
||||
print("\nchoosing one while the box's own search is still out")
|
||||
# The box searches as you type: choose a suggestion at the wrong moment
|
||||
# and the search behind it used to be dropped, leaving the results on
|
||||
# the query before it.
|
||||
page.fill("#search-input", "")
|
||||
page.click("#search-input")
|
||||
hold["on"] = True
|
||||
asked.clear()
|
||||
page.type("#search-input", "hot", delay=20)
|
||||
page.wait_for_timeout(700) # long enough for the typed search to go out
|
||||
c.ok("the typed search is on the wire", asked == ['hot'], str(asked))
|
||||
page.keyboard.press("ArrowDown")
|
||||
chosen = page.evaluate(ACTIVE)
|
||||
page.keyboard.press("Enter")
|
||||
landed = wait_for_query(page, asked, chosen, 3000)
|
||||
release()
|
||||
page.wait_for_timeout(800)
|
||||
c.ok(f"the chosen one ({chosen}) is searched for all the same", landed, str(asked))
|
||||
c.ok("and its results are what the grid ends up with",
|
||||
page.evaluate("() => (App.state.loadedVideos[0] || {}).id || ''")
|
||||
.startswith(f"fix-{chosen}"),
|
||||
page.evaluate("() => (App.state.loadedVideos[0] || {}).id || ''"))
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user