Compare commits
3 Commits
a60a5a92ca
...
b90c9ce51a
| Author | SHA1 | Date | |
|---|---|---|---|
| b90c9ce51a | |||
| a8177711f4 | |||
| e1aca0ba34 |
@ -9,6 +9,12 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.59.0] — 2026-10-04
|
||||
### Changed
|
||||
- **Characters: global cast view in the sidebar, per-book characters inside Studio.** *Library > Characters / Cast* is now titled "All characters (all books)": one collapsible band per book (heading, per-book count, `aria-expanded`), first book open and the rest closed and not rendered until opened (DOM budget), and a *Search all books* field. Studio > Voices lists only the book picked in *Source*, under a "Book: <title>" chip with **Show characters of all books**; the Characters step shows the same chip; with no book picked Voices shows a prompt (Pick a book / show all books). Per-book actions stay in the band headers. Tests: `tests/characters-scope.spec.js`.
|
||||
|
||||
---
|
||||
|
||||
## [1.58.0] — 2026-10-03
|
||||
### Added
|
||||
- **Magazine UI shows page labels, display titles and merge suggestions.** The article index (list, playlist, driving-mode Index sheet and driving header) shows `display_title` (falls back to `title`) with the `page_label` ("Seite 33") in smaller muted text, plus small kind badges (advert, photo, contents, imprint, continuation) and an "Audio is out of date: re-render" badge when `audio_stale` is set. Review shows a suggestion box: the proposed merge target with confidence and a collapsible plain-language "Why do we think so?" list, **Merge into previous article** (`POST .../merge-into-previous {into}`; a 409 "proposal changed" refreshes the suggestion; undoable) and **Use suggested title** (`POST .../accept-title`; undoable). Controls are labelled, keyboard operable and 44 px high. Tests: `tests/magazine.spec.js` ("untitled entries", driving index).
|
||||
@ -4702,7 +4708,8 @@ Initial feature-complete release.
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.0...HEAD
|
||||
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.59.0...HEAD
|
||||
[1.59.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.58.0...v1.59.0
|
||||
[1.58.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.57.0...v1.58.0
|
||||
[1.57.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.3...v1.57.0
|
||||
[1.56.3]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.2...v1.56.3
|
||||
|
||||
@ -199,6 +199,15 @@ locally against your own database; nothing is sent anywhere.
|
||||
|
||||
---
|
||||
|
||||
### Characters: all books or one book
|
||||
|
||||
**Purpose:** two views of the same character library, so you always know which book you are working in.
|
||||
|
||||
- **All characters (all books)** (sidebar *Library > Characters / Cast*, or the Library *Cast* tab): every character of every book, one band per book with a heading and a count. The first book is open, the others are closed and only drawn when opened. *Search all books* (name, alias, archetype, book) opens every band with matches and shows "2 of 9". Cards/Table, Sort and every per-book action stay in each band header.
|
||||
- **Studio > Voices** (step 3): after you pick a book in *Source*, only that book's characters are shown, under a **Book: <title>** chip. **Show characters of all books** leads to the global view. The Characters step (2) shows the same chip. With no book picked, Voices offers **Pick a book** and the link to the global view instead of listing every book.
|
||||
|
||||
**Errors:** if the book title cannot be read, the prompt to pick a book is shown. **Mobile/accessibility:** bands are headings with a show/hide button (`aria-expanded`); the search field is labelled; layouts fit 320 px. **Offline/privacy:** nothing new is sent; the view reads the local library.
|
||||
|
||||
## Magazines
|
||||
|
||||
### Purpose
|
||||
|
||||
@ -6,10 +6,11 @@ Update in the same commit as the change it describes.
|
||||
## Current phase
|
||||
|
||||
Magazine projects, **P0 and P1 complete** (frontend reachable at Studio → Magazines). Audiobook/Rehearser/voice-library
|
||||
features are mature and in daily use. Latest release v1.58.0: driving-mode menu with switches and theme selector, auto-advance, text fit and text
|
||||
amount, article index with page labels/display titles, continuation and untitled-page detection with title and merge
|
||||
suggestions, distinct face profiles and English prompt translation for characters, vision-response cache, character
|
||||
record hygiene fixes and the review Approve fix. v1.57.0 added driving mode with sentence/word-level sync via `tts-aligner`.
|
||||
features are mature and in daily use. Latest release v1.59.0: global "All characters" view in the sidebar and per-book
|
||||
characters inside Studio, untitled-page cleanup with an exclude option, duplicate/clean tools. v1.58.0 added the
|
||||
driving-mode menu, auto-advance, article index with page labels, merge suggestions, distinct face profiles, English
|
||||
prompt translation, vision-response cache and the review Approve fix. v1.57.0 added driving mode with sentence/word-level
|
||||
sync via `tts-aligner`.
|
||||
|
||||
## Deployment
|
||||
|
||||
@ -33,7 +34,7 @@ Two self-hosted Docker instances, both port 7890:
|
||||
- **`tts-aligner`** (forced-aligner sidecar, `aligner/`) exists only in the DGX `docker-compose.yml`. The NUC compose
|
||||
file deliberately omits it: with `ALIGNER_URL` unset the app quietly falls back to estimated word timing, so the NUC
|
||||
works unchanged. Known limitation: no measured word times on the NUC.
|
||||
- **Rollback** (either host): check out the previous tag (`git checkout v1.57.0`; on the NUC re-rsync that tree), then
|
||||
- **Rollback** (either host): check out the previous tag (`git checkout v1.58.0`; on the NUC re-rsync that tree), then
|
||||
`docker compose up -d --force-recreate` (rebuild only if `Dockerfile`/`requirements.txt` changed). Stop the aligner
|
||||
with `docker compose stop tts-aligner`; `audio/<id>.timings.json` version 2 files stay readable by v1 readers.
|
||||
|
||||
@ -97,8 +98,10 @@ which is the writable volume.
|
||||
**unresolved** — the compose files currently point at ghcr.
|
||||
## Known limitations
|
||||
|
||||
- Live untitled-page cleanup is not yet applied: `scripts/magazine_untitled.py` needs the owner's `--apply`.
|
||||
- `scripts/clean_character_records.py` has only been run as a dry run; 33 duplicate character pairs need owner decisions.
|
||||
- Live data changes made on the DGX only (not on the NUC, which has its own DB/config): book KONTROLLE removed, 5 duplicate
|
||||
characters resolved, untitled-page cleanup applied (backups under `config/backups/`).
|
||||
- 18 re-rendered/stale audio articles are pending the owner's voice choice.
|
||||
- `scripts/clean_character_records.py` was applied for 5 pairs; the 17 remaining duplicate-sheet pairs need owner decisions.
|
||||
- Audiobook attribution (span mode) is measured, not perfect: on three hand-corrected novels about
|
||||
90-92 % of all words end up with the right voice and ~75-80 % of spoken words with the right
|
||||
speaker. The references themselves contain label noise (1-2 points is not meaningful). Open, real
|
||||
|
||||
@ -552,9 +552,13 @@ def merge_entries(layout: dict[str, Any], articles: list[dict[str, Any]], source
|
||||
def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_confidence: float = HIGH_AT,
|
||||
cache: dict[str, Any] | None = None,
|
||||
llm_check: Callable[[str, str], bool | None] | None = None,
|
||||
titler: Callable[[str, str], str | None] | None = None) -> dict[str, Any]:
|
||||
titler: Callable[[str, str], str | None] | None = None,
|
||||
exclude: Sequence[str] = ()) -> dict[str, Any]:
|
||||
"""Simulate merges (page order, chains resolve) and titles on a copy. Returns
|
||||
{articles, merges, titles, before}. Nothing is written."""
|
||||
{articles, merges, titles, before}. Nothing is written. Entries in ``exclude`` are
|
||||
left as they are: never merged away into another entry and never retitled (they may still
|
||||
receive merge-ins from the pages after them)."""
|
||||
skip = {str(x) for x in exclude}
|
||||
work = json.loads(json.dumps(list(articles)))
|
||||
language = doc_language(layout)
|
||||
before = report(layout, articles, cache)
|
||||
@ -564,7 +568,7 @@ def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_conf
|
||||
order = [r["id"] for r in report(layout, work, cache)["entries"]]
|
||||
for aid in order:
|
||||
a = next((x for x in work if x["id"] == aid), None)
|
||||
if a is None:
|
||||
if a is None or aid in skip:
|
||||
continue
|
||||
s = suggestion_for(_Index(layout, work), a, llm_check)
|
||||
if not s or s["confidence"] < min_confidence:
|
||||
@ -581,7 +585,7 @@ def plan(layout: dict[str, Any], articles: Sequence[dict[str, Any]], *, min_conf
|
||||
ann = annotate(layout, work, cache, language)
|
||||
for a in work:
|
||||
info = ann[a["id"]]
|
||||
if info.get("title_source") != "suggested":
|
||||
if a["id"] in skip or info.get("title_source") != "suggested":
|
||||
continue
|
||||
title, source = info["suggested_title"], info["suggested_title_source"]
|
||||
if titler is not None and source == "sentence":
|
||||
|
||||
@ -7,7 +7,7 @@ suggestion at or above ``--min-confidence`` (page order, so chains of continuati
|
||||
into their article), marks merged articles ``audio_stale`` (audio files are kept), and writes
|
||||
suggested titles into placeholder titles only. Titles someone set are never touched.
|
||||
|
||||
python3 scripts/magazine_untitled.py <doc-id | path/to/doc_dir> [--apply] [--no-llm] [--json out.json]
|
||||
python3 scripts/magazine_untitled.py <doc-id | path/to/doc_dir> [--apply] [--no-llm] [--exclude ID,ID] [--json out.json]
|
||||
|
||||
See architecture/magazine-ingestion.md, "Untitled entries".
|
||||
"""
|
||||
@ -76,7 +76,7 @@ def make_chat(timeout: float = 30.0):
|
||||
return chat
|
||||
|
||||
|
||||
def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None) -> dict:
|
||||
def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None, exclude=()) -> dict:
|
||||
layout = mag.load_layout(doc_dir)
|
||||
articles = mag.load_articles(doc_dir)
|
||||
if not layout or not articles:
|
||||
@ -92,7 +92,7 @@ def run(doc_dir: Path, *, apply: bool, min_confidence: float, chat=None) -> dict
|
||||
return mu.llm_title(chat, text, language)
|
||||
|
||||
result = mu.plan(layout, articles, min_confidence=min_confidence, cache=cache,
|
||||
llm_check=llm_check, titler=titler if chat else None)
|
||||
llm_check=llm_check, titler=titler if chat else None, exclude=exclude)
|
||||
index = mu._Index(layout, result["articles"])
|
||||
for t in result["titles"]:
|
||||
if t["source"] == "llm":
|
||||
@ -151,12 +151,15 @@ def main(argv: list[str] | None = None) -> int:
|
||||
mode.add_argument("--apply", action="store_true", help="write changes (backup first)")
|
||||
p.add_argument("--min-confidence", type=float, default=mu.HIGH_AT,
|
||||
help=f"merge threshold (default {mu.HIGH_AT})")
|
||||
p.add_argument("--exclude", action="append", default=[], metavar="ID[,ID...]",
|
||||
help="leave these article ids as they are (not merged into another entry, no new title); repeatable")
|
||||
p.add_argument("--no-llm", action="store_true", help="heuristics and deterministic titles only")
|
||||
p.add_argument("--json", help="also write the full result as JSON to this path")
|
||||
args = p.parse_args(argv)
|
||||
doc_dir = resolve_doc_dir(args.doc)
|
||||
chat = None if args.no_llm else make_chat()
|
||||
summary = run(doc_dir, apply=args.apply, min_confidence=args.min_confidence, chat=chat)
|
||||
summary = run(doc_dir, apply=args.apply, min_confidence=args.min_confidence, chat=chat,
|
||||
exclude=[i.strip() for v in args.exclude for i in v.split(",") if i.strip()])
|
||||
print_summary(summary)
|
||||
if args.json:
|
||||
Path(args.json).write_text(json.dumps(summary, ensure_ascii=False, indent=1), "utf-8")
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.58.0">
|
||||
<meta name="app-version" content="1.59.0">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,12 +27,12 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.58.0">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.58.0">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.58.0">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.58.0">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.58.0">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.58.0">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.59.0">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.59.0">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.59.0">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.59.0">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.59.0">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.59.0">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.58.0"></script>
|
||||
<script src="/static/loader.js?v=1.59.0"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -42,15 +42,37 @@ async function _libReviewVisualPairs(button, container) {
|
||||
}
|
||||
}
|
||||
|
||||
async function libraryRenderCharacters() {
|
||||
let _libCharsCache = null;
|
||||
|
||||
// Studio hands this view a single book (Voices step); the sidebar/Library entry
|
||||
// shows every book. The view is "scoped" exactly while #lib-chars-list is
|
||||
// borrowed into Studio's Voices slot, so leaving Studio always restores the
|
||||
// global view without any flag to reset.
|
||||
function _libShowAllBooks() {
|
||||
if (typeof window.studioShowAllCharacters === 'function') window.studioShowAllCharacters();
|
||||
else if (typeof window.navLibraryView === 'function') window.navLibraryView('characters');
|
||||
}
|
||||
|
||||
function _libCharHaystack(rec) {
|
||||
const sh = rec.sheet || {};
|
||||
const parts = [rec.name, rec.form_name, rec.book, sh.archetype, sh.species, sh.real_name, sh.profession];
|
||||
['aliases', 'alias', 'also_known_as'].forEach(function (k) {
|
||||
const v = sh[k];
|
||||
if (Array.isArray(v)) parts.push(v.join(' ')); else if (v) parts.push(v);
|
||||
});
|
||||
return parts.filter(function (x) { return typeof x === 'string' || typeof x === 'number'; }).join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
async function libraryRenderCharacters(opts) {
|
||||
const container = document.getElementById('lib-chars-list');
|
||||
if (!container) return;
|
||||
const useCache = !!(opts && opts.cached === true && _libCharsCache);
|
||||
const renderSeq = ++_libCharsRenderSeq;
|
||||
|
||||
// This page can open before the shared voice library has finished loading.
|
||||
// Ratings live in window._voices, so rendering during that gap makes every
|
||||
// assigned voice look unrated even when its .meta.json still has a rating.
|
||||
if ((!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
|
||||
if (!useCache && (!Array.isArray(window._voices) || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
|
||||
try { await loadVoiceLibrary(); } catch (e) { console.warn('[characters] voice library load failed', e); }
|
||||
if (renderSeq !== _libCharsRenderSeq) return;
|
||||
}
|
||||
@ -62,13 +84,15 @@ async function libraryRenderCharacters() {
|
||||
// while a bulk voice-design run elsewhere was mid-restart. Leave whatever
|
||||
// is already on screen alone and let the next real refresh fix it.
|
||||
let all = [];
|
||||
try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; }
|
||||
if (useCache) all = _libCharsCache;
|
||||
else try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; }
|
||||
catch (e) {
|
||||
console.warn('[characters] load failed', e);
|
||||
if (typeof toast === 'function') toast('Failed to load characters — keeping the current view', 'error');
|
||||
return;
|
||||
}
|
||||
if (renderSeq !== _libCharsRenderSeq) return;
|
||||
_libCharsCache = all;
|
||||
|
||||
// Every action on this page (remove voice, auto-design, delete...) re-runs
|
||||
// this full rebuild via `refresh()`. Collapsing the container down to a
|
||||
@ -84,7 +108,24 @@ async function libraryRenderCharacters() {
|
||||
|
||||
const byId = new Map(all.map(function (rec) { return [rec.id, rec]; }));
|
||||
|
||||
if (!all.length) {
|
||||
const inStudio = !!container.closest('#stu-voices-slot');
|
||||
const scopeBook = inStudio ? String(window._libCharsStudioBook || '') : '';
|
||||
if (inStudio && !scopeBook) {
|
||||
container.innerHTML = '<div class="lib-chars-scope-prompt card" role="status">'
|
||||
+ '<h3><span class="mdi mdi-book-open-page-variant-outline"></span> Pick a book first</h3>'
|
||||
+ '<p>Voices are assigned per book. Choose one of your uploaded books in the Source step, then come back here to see only its characters.</p>'
|
||||
+ '<div class="lib-chars-scope-actions">'
|
||||
+ '<button type="button" class="btn-primary btn-sm" id="lib-chars-pick-book"><span class="mdi mdi-file-document-outline"></span> Pick a book</button>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm" id="lib-chars-show-all"><span class="mdi mdi-bookshelf"></span> Show characters of all books</button>'
|
||||
+ '</div></div>';
|
||||
container.querySelector('#lib-chars-pick-book').addEventListener('click', function () {
|
||||
if (typeof window.showStudioPhase === 'function') window.showStudioPhase(1);
|
||||
});
|
||||
container.querySelector('#lib-chars-show-all').addEventListener('click', _libShowAllBooks);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!all.length && !inStudio) {
|
||||
container.innerHTML = '<div class="lib-chars-toolbar">'
|
||||
+ '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
|
||||
+ '</div>'
|
||||
@ -108,6 +149,13 @@ async function libraryRenderCharacters() {
|
||||
byBook[bk].push(rec);
|
||||
});
|
||||
|
||||
if (inStudio) {
|
||||
// Studio is working inside ONE book: drop every other production.
|
||||
const only = byBook[scopeBook] || [];
|
||||
Object.keys(byBook).forEach(function (k) { delete byBook[k]; });
|
||||
byBook[scopeBook] = only;
|
||||
}
|
||||
|
||||
// The Narrator reads every non-dialogue line but was never a real Library
|
||||
// record (castWriteBack deliberately never creates one, to keep narrators
|
||||
// out of the cast library) — so there was nowhere in Assign Voices to give
|
||||
@ -188,6 +236,32 @@ async function libraryRenderCharacters() {
|
||||
});
|
||||
container.appendChild(bar);
|
||||
|
||||
const searchQuery = inStudio ? '' : String(window._libCharsQuery || '').trim().toLowerCase();
|
||||
const bookNames = Object.keys(byBook).sort();
|
||||
const head = document.createElement('div');
|
||||
head.className = 'lib-chars-scope';
|
||||
if (inStudio) {
|
||||
head.innerHTML = '<span class="lib-chars-book-chip" id="lib-chars-book-chip"><span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> Book: <b>' + escHtml(scopeBook) + '</b></span>'
|
||||
+ '<button type="button" class="btn-secondary btn-sm" id="lib-chars-show-all"><span class="mdi mdi-bookshelf"></span> Show characters of all books</button>';
|
||||
head.querySelector('#lib-chars-show-all').addEventListener('click', _libShowAllBooks);
|
||||
} else {
|
||||
head.innerHTML = '<h3 class="lib-chars-global-title" id="lib-chars-global-title"><span class="mdi mdi-account-box-multiple-outline" aria-hidden="true"></span> All characters (all books)</h3>'
|
||||
+ '<span class="lib-chars-global-count" id="lib-chars-global-count">' + all.length + ' characters in ' + bookNames.length + ' book' + (bookNames.length === 1 ? '' : 's') + '</span>'
|
||||
+ '<label class="lib-chars-search"><span class="lib-chars-search-label">Search all books</span>'
|
||||
+ '<input type="search" id="lib-chars-search" placeholder="Name, alias, archetype, book…" spellcheck="false" autocomplete="off" value="' + escHtml(String(window._libCharsQuery || '')) + '"></label>';
|
||||
const searchEl = head.querySelector('#lib-chars-search');
|
||||
let searchTimer = null;
|
||||
searchEl.addEventListener('input', function () {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(function () {
|
||||
window._libCharsQuery = searchEl.value;
|
||||
window._libCharsFocusSearch = true;
|
||||
libraryRenderCharacters({ cached: true });
|
||||
}, 180);
|
||||
});
|
||||
}
|
||||
container.appendChild(head);
|
||||
|
||||
const _charSortCmp = {
|
||||
tier: function (a, b) {
|
||||
const tierOrder = { main: 0, supporting: 1, minor: 2 };
|
||||
@ -219,7 +293,8 @@ async function libraryRenderCharacters() {
|
||||
const sortDir = localStorage.getItem('ttsvc_libchars_sort_dir') === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
const productions = document.createDocumentFragment();
|
||||
Object.keys(byBook).sort().forEach(function (book) {
|
||||
let shownBands = 0;
|
||||
Object.keys(byBook).sort().forEach(function (book, bandIndex) {
|
||||
const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier);
|
||||
if (sortDir === 'desc') chars.reverse();
|
||||
// Narrator stays pinned first regardless of sort — same convention as
|
||||
@ -230,13 +305,18 @@ async function libraryRenderCharacters() {
|
||||
// Keep large productions responsive: the complete record set remains in
|
||||
// memory for sorting, relationship lookups and bulk operations, while
|
||||
// only one 50-row page is put into the DOM at a time.
|
||||
const matched = searchQuery
|
||||
? chars.filter(function (r) { return _libCharHaystack(r).indexOf(searchQuery) !== -1; })
|
||||
: chars;
|
||||
if (searchQuery && !matched.length) return;
|
||||
shownBands++;
|
||||
const pageKey = 'ttsvc_libchars_page::' + book;
|
||||
// Keep every page comfortably below the DOM budget on large casts. The
|
||||
// complete record set remains available for sorting and bulk operations.
|
||||
const pageSize = 25;
|
||||
const requestedPage = Math.max(1, parseInt(localStorage.getItem(pageKey) || '1', 10) || 1);
|
||||
const visibleCount = Math.min(chars.length, requestedPage * pageSize);
|
||||
const visibleChars = chars.slice(0, visibleCount);
|
||||
const visibleCount = Math.min(matched.length, requestedPage * pageSize);
|
||||
const visibleChars = matched.slice(0, visibleCount);
|
||||
|
||||
const cov = libBookCover(book);
|
||||
const prod = document.createElement('div');
|
||||
@ -249,12 +329,22 @@ async function libraryRenderCharacters() {
|
||||
// production shut, so a 40+ character roster from an unrelated book
|
||||
// doesn't bury the one actually being worked on.
|
||||
const collapseKey = 'ttsvc_libchars_collapsed::' + book;
|
||||
let isCollapsed = localStorage.getItem(collapseKey) === '1';
|
||||
// Global view: collapsed unless the user opened it (the first book starts
|
||||
// open); a search opens every band that has matches. Collapsed bands are
|
||||
// not rendered at all (lazy) to keep the DOM small; opening one re-renders.
|
||||
const storedCollapse = localStorage.getItem(collapseKey);
|
||||
let isCollapsed = storedCollapse === null ? bandIndex > 0 : storedCollapse === '1';
|
||||
if (window._libCharsScrollToBook) isCollapsed = (book !== window._libCharsScrollToBook);
|
||||
if (searchQuery) isCollapsed = false;
|
||||
if (inStudio) isCollapsed = false;
|
||||
const lazy = isCollapsed;
|
||||
const bodyId = 'lib-chars-body-' + bandIndex;
|
||||
if (inStudio) prod.classList.add('lib-chars-prod-scoped');
|
||||
prod.dataset.lazy = lazy ? '1' : '0';
|
||||
if (isCollapsed) prod.classList.add('lib-chars-production-collapsed');
|
||||
prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">'
|
||||
+ '<button type="button" class="lib-chars-prod-collapse-btn" title="Collapse/expand this production"><span class="mdi mdi-chevron-down"></span></button>'
|
||||
+ '<div class="lib-chars-prod-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>'
|
||||
+ '<button type="button" class="lib-chars-prod-collapse-btn" title="Collapse/expand this production" aria-expanded="' + (isCollapsed ? 'false' : 'true') + '" aria-controls="' + bodyId + '" aria-label="' + escHtml(book) + ' (' + chars.length + ' characters): show or hide"><span class="mdi mdi-chevron-down" aria-hidden="true"></span></button>'
|
||||
+ '<h3 class="lib-chars-prod-title"><span class="mdi mdi-bookshelf" aria-hidden="true"></span> <span class="lib-chars-prod-name">' + escHtml(book) + '</span> <span class="lib-chars-prod-count" title="Characters in this book">' + (searchQuery ? matched.length + ' of ' + chars.length : chars.length) + '</span></h3>'
|
||||
+ '<div class="lib-chars-prod-actions">'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-bookctx-btn" data-book="' + escHtml(book) + '" title="Set genre, setting, era and language for this book — gives every voice/image prompt real context instead of guessing per character"><span class="mdi mdi-book-cog-outline"></span> Context</button>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-casting-btn" data-book="' + escHtml(book) + '" title="Back to the casting script (speaker attribution)"><span class="mdi mdi-drama-masks"></span> Casting</button>'
|
||||
@ -280,14 +370,14 @@ async function libraryRenderCharacters() {
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-bulk-delete-btn" data-book="' + escHtml(book) + '" title="Delete every checked character from the library — use this to clear out stale/corrupted entries before a fresh recast" disabled><span class="mdi mdi-trash-can-outline"></span> Delete selected (<span class="lib-chars-bulk-count-delete">0</span>)</button>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-fix-lang-btn" data-book="' + escHtml(book) + '" title="Find every character whose current voice does not match this book\'s language, and design a properly-matching one — no selection needed, scans the whole production"><span class="mdi mdi-earth-arrow-right"></span> Fix wrong-language voices</button>'
|
||||
+ '</div></div>'
|
||||
+ '<div class="lib-chars-prod-body">'
|
||||
+ (viewMode === 'table'
|
||||
+ '<div class="lib-chars-prod-body" id="' + bodyId + '" role="region" aria-label="' + escHtml(book) + '">'
|
||||
+ (lazy ? '' : viewMode === 'table'
|
||||
? _charsTableHtml(visibleChars, sortMode, sortDir)
|
||||
: '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
|
||||
+ visibleChars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
|
||||
+ '</div>')
|
||||
+ (visibleCount < chars.length
|
||||
? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + chars.length + ')</button>'
|
||||
+ (!lazy && visibleCount < matched.length
|
||||
? '<button type="button" class="btn-secondary btn-sm lib-chars-load-more" style="margin:12px 0">Show more characters (' + visibleCount + ' / ' + matched.length + ')</button>'
|
||||
: '')
|
||||
+ '</div>';
|
||||
|
||||
@ -298,7 +388,17 @@ async function libraryRenderCharacters() {
|
||||
|
||||
prod.querySelector('.lib-chars-prod-collapse-btn').addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (inStudio) return;
|
||||
if (prod.dataset.lazy === '1') {
|
||||
// Not rendered yet: remember the choice and rebuild with this band open.
|
||||
localStorage.setItem(collapseKey, '0');
|
||||
window._libCharsFocusBand = book;
|
||||
libraryRenderCharacters({ cached: true });
|
||||
return;
|
||||
}
|
||||
const collapsed = prod.classList.toggle('lib-chars-production-collapsed');
|
||||
prod.querySelector('.lib-chars-prod-collapse-btn').setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
if (collapsed) prod.dataset.lazy = '0';
|
||||
localStorage.setItem(collapseKey, collapsed ? '1' : '0');
|
||||
});
|
||||
// The whole head bar toggles too, not just the small chevron button —
|
||||
@ -307,6 +407,7 @@ async function libraryRenderCharacters() {
|
||||
// hit area than the chevron alone.
|
||||
prod.querySelector('.lib-chars-prod-head').addEventListener('click', function (e) {
|
||||
if (e.target.closest('button, select, input, a')) return;
|
||||
if (inStudio) return;
|
||||
prod.querySelector('.lib-chars-prod-collapse-btn').click();
|
||||
});
|
||||
|
||||
@ -594,6 +695,22 @@ async function libraryRenderCharacters() {
|
||||
productions.appendChild(prod);
|
||||
});
|
||||
container.appendChild(productions);
|
||||
if (searchQuery && !shownBands) {
|
||||
const none = document.createElement('p');
|
||||
none.className = 'lib-chars-search-empty';
|
||||
none.setAttribute('role', 'status');
|
||||
none.textContent = 'No character matches "' + String(window._libCharsQuery || '').trim() + '" in any book.';
|
||||
container.appendChild(none);
|
||||
}
|
||||
if (window._libCharsFocusSearch) {
|
||||
window._libCharsFocusSearch = false;
|
||||
const se = container.querySelector('#lib-chars-search');
|
||||
if (se) { se.focus(); try { se.setSelectionRange(se.value.length, se.value.length); } catch (_) {} }
|
||||
} else if (window._libCharsFocusBand) {
|
||||
const fb = window._libCharsFocusBand; window._libCharsFocusBand = null;
|
||||
const fp = [...container.querySelectorAll('.lib-chars-production')].find(function (p) { return p.dataset.book === fb; });
|
||||
fp?.querySelector('.lib-chars-prod-collapse-btn')?.focus();
|
||||
}
|
||||
if (typeof window.ttvcObserveImages === 'function') window.ttvcObserveImages(container);
|
||||
|
||||
// Scroll to a specific book's production block — set by callers that land
|
||||
|
||||
@ -188,6 +188,51 @@ function _stuInstallNavGuardOnce() {
|
||||
// the function.
|
||||
function _stuCallSuppressingNav(fn) { return fn(); }
|
||||
|
||||
// Title of the book Studio is working on ('' when none is picked).
|
||||
async function _stuResolveBookTitle() {
|
||||
let title = null;
|
||||
if (window._audiobook && window._audiobook.bookId) {
|
||||
try {
|
||||
const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
|
||||
if (r.ok) { const doc = await r.json(); title = doc.title || null; }
|
||||
} catch (_) {}
|
||||
}
|
||||
return title || (window.readerState && window.readerState.title) || '';
|
||||
}
|
||||
|
||||
// Header chip on the Characters step: which book this step works on.
|
||||
async function _stuUpdateBookChip() {
|
||||
const row = document.getElementById('stu-book-chip-row');
|
||||
if (!row) return;
|
||||
const title = await _stuResolveBookTitle();
|
||||
row.textContent = '';
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'lib-chars-book-chip';
|
||||
chip.id = 'stu-book-chip';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'mdi mdi-book-open-page-variant-outline';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
chip.appendChild(icon);
|
||||
chip.appendChild(document.createTextNode(title ? ' Book: ' : ' No book picked yet '));
|
||||
if (title) { const b = document.createElement('b'); b.textContent = title; chip.appendChild(b); }
|
||||
row.appendChild(chip);
|
||||
const mk = function (id, label, fn) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button'; btn.className = 'btn-secondary btn-sm'; btn.id = id; btn.textContent = label;
|
||||
btn.addEventListener('click', fn); row.appendChild(btn);
|
||||
};
|
||||
if (!title) mk('stu-pick-book-btn', 'Pick a book', function () { showStudioPhase(1); });
|
||||
mk('stu-show-all-chars-btn', 'Show characters of all books', function () { window.studioShowAllCharacters(); });
|
||||
}
|
||||
|
||||
// Leave the per-book Studio view for the global cast view (Library > Characters / Cast).
|
||||
// Studio's nav guard drops internal navTo('s-library') calls, so allow this one.
|
||||
window.studioShowAllCharacters = function () {
|
||||
_stuAllowNextNav = true;
|
||||
if (typeof window.navLibraryView === 'function') window.navLibraryView('characters');
|
||||
else if (typeof window.navTo === 'function') window.navTo('s-library');
|
||||
};
|
||||
|
||||
// Per-phase borrow wiring — called by showStudioPhase whenever a phase
|
||||
// becomes active. Each case borrows the real subtree that section already
|
||||
// renders into/wires listeners on; no logic is duplicated here.
|
||||
@ -200,6 +245,7 @@ function _stuEnterPhase(n) {
|
||||
if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks();
|
||||
});
|
||||
} else if (n === 2) {
|
||||
_stuUpdateBookChip();
|
||||
_stuShowCastView(_stuCastView);
|
||||
} else if (n === 3) {
|
||||
_stuBorrow('lib-chars-list', 'stu-voices-slot');
|
||||
@ -214,14 +260,12 @@ function _stuEnterPhase(n) {
|
||||
// whichever book's cast session is active, so resolve the title from
|
||||
// that via the server instead of trusting readerState.title first.
|
||||
(async () => {
|
||||
let title = null;
|
||||
if (window._audiobook && window._audiobook.bookId) {
|
||||
try {
|
||||
const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
|
||||
if (r.ok) { const doc = await r.json(); title = doc.title || null; }
|
||||
} catch (_) {}
|
||||
}
|
||||
window._libCharsScrollToBook = title || (window.readerState && readerState.title) || null;
|
||||
// Studio works inside ONE book: the Voices step lists only that book's
|
||||
// characters (library-characters.js reads _libCharsStudioBook while the
|
||||
// list is borrowed into this slot; '' shows the "pick a book" prompt).
|
||||
const title = await _stuResolveBookTitle();
|
||||
window._libCharsStudioBook = title || '';
|
||||
window._libCharsScrollToBook = null;
|
||||
_stuCallSuppressingNav(function () {
|
||||
if (typeof window.libraryRender === 'function') window.libraryRender('characters');
|
||||
});
|
||||
|
||||
@ -56,6 +56,7 @@
|
||||
|
||||
<!-- ── Phase 2: Characters ─────────────────────────────────────── -->
|
||||
<div class="stu-phase" id="stu-phase-2" hidden>
|
||||
<div class="stu-book-chip-row" id="stu-book-chip-row" aria-live="polite"></div>
|
||||
<div class="stu-inner-tabs" id="stu-cast-inner-tabs">
|
||||
<button type="button" class="stu-book-context-btn" id="stu-change-book-btn" title="Choose a different uploaded book"><span class="mdi mdi-book-open-page-variant-outline"></span> Change book</button>
|
||||
<button type="button" class="stu-book-context-btn" id="stu-book-context-btn" title="Edit genre, setting, era and language before identifying characters"><span class="mdi mdi-book-cog-outline"></span> Edit book context</button>
|
||||
|
||||
@ -5989,3 +5989,29 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night
|
||||
.mag-suggest summary:focus-visible, .mag-suggest .mag-suggest-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
||||
@media (max-width: 480px) { .mag-suggest .mag-suggest-btn { width: 100%; justify-content: center; } }
|
||||
.mag-stale-badge.mag-pill { color: var(--text); background: rgba(217,119,6,.16); border: 1px solid var(--yellow); }
|
||||
|
||||
/* ── Global cast view / per-book Studio scope ───────────────────────────────── */
|
||||
.lib-chars-scope, .stu-book-chip-row { display:flex; flex-wrap:wrap; align-items:center; gap:10px 14px; margin:0 0 14px; min-width:0; }
|
||||
.stu-book-chip-row:empty { display:none; }
|
||||
.lib-chars-book-chip {
|
||||
display:inline-flex; align-items:center; gap:6px; max-width:100%; padding:6px 12px; border-radius:999px;
|
||||
border:1px solid color-mix(in srgb, var(--accent) 55%, var(--border));
|
||||
background:color-mix(in srgb, var(--accent) 12%, var(--surface)); color:var(--text); font-size:14px; overflow-wrap:anywhere;
|
||||
}
|
||||
.lib-chars-global-title { margin:0; font-size:17px; font-weight:700; color:var(--text); display:flex; align-items:center; gap:8px; }
|
||||
.lib-chars-global-count { color:var(--subtext); font-size:13px; }
|
||||
.lib-chars-search { display:flex; flex-direction:column; gap:2px; flex:1 1 220px; min-width:0; max-width:420px; margin-left:auto; }
|
||||
.lib-chars-search-label { font-size:12px; font-weight:600; color:var(--subtext); }
|
||||
.lib-chars-search input { width:100%; box-sizing:border-box; min-height:36px; padding:6px 10px; border-radius:6px; border:1px solid var(--border); background:var(--surface); color:var(--text); font-size:14px; }
|
||||
.lib-chars-search input:focus-visible, .lib-chars-prod-collapse-btn:focus-visible { outline:3px solid var(--accent); outline-offset:2px; }
|
||||
.lib-chars-search-empty { color:var(--subtext); padding:12px 0; }
|
||||
.lib-chars-prod-title { margin:0; color:inherit; min-width:0; flex:1 1 auto; overflow-wrap:anywhere; }
|
||||
.lib-chars-prod-name { min-width:0; }
|
||||
.lib-chars-prod-count { display:inline-block; padding:1px 8px; border-radius:999px; background:rgba(0,0,0,.35); color:#fff; font-size:12px; font-weight:700; }
|
||||
.lib-chars-prod-scoped .lib-chars-prod-collapse-btn { display:none; }
|
||||
.lib-chars-prod-scoped .lib-chars-prod-head { cursor:default; }
|
||||
.lib-chars-scope-prompt { padding:20px; max-width:560px; }
|
||||
.lib-chars-scope-prompt h3 { margin:0 0 8px; font-size:17px; }
|
||||
.lib-chars-scope-prompt p { margin:0 0 14px; color:var(--subtext); line-height:1.5; }
|
||||
.lib-chars-scope-actions { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
#lib-chars-list { min-width:0; max-width:100%; }
|
||||
|
||||
115
tests/characters-scope.spec.js
Normal file
115
tests/characters-scope.spec.js
Normal file
@ -0,0 +1,115 @@
|
||||
// @ts-check
|
||||
// Global cast view (sidebar) vs. per-book characters inside Studio.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const mk = (book, n, prefix) => Array.from({ length: n }, (_, i) => ({
|
||||
id: `${prefix}-${i}`, book, name: `${prefix}Hero${i}`, tags: '', voice: null,
|
||||
sheet: { gender: i % 2 ? 'F' : 'M', archetype: i === 0 ? 'Rogue' : '' },
|
||||
}));
|
||||
const BOOK_A = 'Alpha Saga 01';
|
||||
const BOOK_B = 'Beta Saga 02';
|
||||
const BOOK_C = 'Gamma Saga 03';
|
||||
const CHARACTERS = [...mk(BOOK_A, 3, 'Al'), ...mk(BOOK_B, 4, 'Be'), ...mk(BOOK_C, 2, 'Ga')];
|
||||
|
||||
async function setup(page, { resume } = {}) {
|
||||
await page.route('**/api/characters*', route => route.fulfill({ json: { characters: CHARACTERS } }));
|
||||
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
|
||||
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
|
||||
await page.route('**/api/reader/docs/book-b', route => route.fulfill({ json: { id: 'book-b', title: BOOK_B, sentences: [] } }));
|
||||
await page.addInitScript(r => {
|
||||
try { localStorage.clear(); if (r) localStorage.setItem('ttsvc_studio_resume', JSON.stringify(r)); } catch (_) { /* ignore */ }
|
||||
}, resume || null);
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function openGlobal(page) {
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
||||
await expect(page.locator('#lib-chars-global-title')).toHaveText(/All characters \(all books\)/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
test('global cast view groups all books in collapsible, lazily rendered bands', async ({ page }) => {
|
||||
await setup(page);
|
||||
await openGlobal(page);
|
||||
const bands = page.locator('#lib-chars-list .lib-chars-production');
|
||||
await expect(bands).toHaveCount(3);
|
||||
await expect(page.locator('#lib-chars-global-count')).toContainText('9 characters in 3 books');
|
||||
await expect(page.locator('#lib-chars-list h3.lib-chars-prod-title')).toHaveCount(3);
|
||||
// First band open, the others collapsed and not rendered at all.
|
||||
const first = bands.nth(0);
|
||||
const second = bands.nth(1);
|
||||
await expect(first.locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(second.locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(first.locator('.lib-char-card')).toHaveCount(4); // 3 + synthetic Narrator
|
||||
await expect(second.locator('.lib-char-card')).toHaveCount(0);
|
||||
await expect(first.locator('.lib-chars-prod-count')).toHaveText('4');
|
||||
// Opening a collapsed band renders its characters.
|
||||
await second.locator('.lib-chars-prod-collapse-btn').click();
|
||||
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-char-card')).toHaveCount(5);
|
||||
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'true');
|
||||
// Collapsing again toggles without a rebuild.
|
||||
await page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn').click();
|
||||
await expect(page.locator('#lib-chars-list .lib-chars-production').nth(1).locator('.lib-chars-prod-collapse-btn')).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('global cast view searches across all books', async ({ page }) => {
|
||||
await setup(page);
|
||||
await openGlobal(page);
|
||||
const search = page.locator('#lib-chars-search');
|
||||
await search.fill('GaHero1');
|
||||
const bands = page.locator('#lib-chars-list .lib-chars-production');
|
||||
await expect(bands).toHaveCount(1);
|
||||
await expect(bands.first().locator('.lib-chars-prod-name')).toHaveText(BOOK_C);
|
||||
await expect(bands.first().locator('.lib-char-card')).toHaveCount(1);
|
||||
await expect(search).toBeFocused();
|
||||
await expect(bands.first().locator('.lib-chars-prod-count')).toHaveText('1 of 3');
|
||||
await search.fill('rogue'); // archetype of the first character of every book
|
||||
await expect(bands).toHaveCount(3);
|
||||
await search.fill('nobody-here');
|
||||
await expect(page.locator('.lib-chars-search-empty')).toBeVisible();
|
||||
await search.fill('');
|
||||
await expect(bands).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('Studio Voices shows only the picked book and links to the global view', async ({ page }) => {
|
||||
await setup(page, { resume: { phase: 3, bookId: 'book-b', bookTitle: BOOK_B } });
|
||||
await page.evaluate(() => window.navTo('s-caststudio'));
|
||||
await expect(page.locator('#stu-voices-slot #lib-chars-book-chip')).toContainText(`Book: ${BOOK_B}`, { timeout: 20_000 });
|
||||
const bands = page.locator('#stu-voices-slot .lib-chars-production');
|
||||
await expect(bands).toHaveCount(1);
|
||||
await expect(bands.first().locator('.lib-chars-prod-name')).toHaveText(BOOK_B);
|
||||
await expect(page.locator('#stu-voices-slot')).not.toContainText(BOOK_A);
|
||||
await expect(page.locator('#stu-voices-slot .lib-char-card')).toHaveCount(5);
|
||||
// Per-book actions stay in the band header.
|
||||
await expect(bands.first().locator('.lib-chars-bulk-voice-btn')).toBeVisible();
|
||||
await expect(bands.first().locator('.lib-chars-select-all-btn')).toBeVisible();
|
||||
await page.locator('#lib-chars-show-all').click();
|
||||
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
|
||||
await expect(page.locator('#lib-chars-global-title')).toBeVisible();
|
||||
await expect(page.locator('#lib-chars-list .lib-chars-production')).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('Studio Characters step shows the book chip', async ({ page }) => {
|
||||
await setup(page, { resume: { phase: 2, bookId: 'book-b', bookTitle: BOOK_B } });
|
||||
await page.evaluate(() => window.navTo('s-caststudio'));
|
||||
await expect(page.locator('#stu-book-chip')).toContainText(`Book: ${BOOK_B}`, { timeout: 20_000 });
|
||||
await expect(page.locator('#stu-show-all-chars-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Studio Voices without a picked book offers to pick one instead of listing all books', async ({ page }) => {
|
||||
await setup(page);
|
||||
await page.evaluate(() => { window.navTo('s-caststudio'); });
|
||||
await page.waitForFunction(() => typeof window.showStudioPhase === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => window.showStudioPhase(3));
|
||||
await expect(page.locator('#stu-voices-slot .lib-chars-scope-prompt')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator('#stu-voices-slot .lib-chars-production')).toHaveCount(0);
|
||||
await expect(page.locator('#stu-voices-slot #lib-chars-show-all')).toBeVisible();
|
||||
await page.locator('#stu-voices-slot #lib-chars-pick-book').click();
|
||||
await expect(page.locator('#stu-phase-1')).toBeVisible();
|
||||
});
|
||||
@ -316,6 +316,18 @@ class PlanTests(unittest.TestCase):
|
||||
layout, articles = sample()
|
||||
self.assertEqual(mu.plan(layout, articles, min_confidence=1.01)["merges"], [])
|
||||
|
||||
def test_excluded_entries_are_left_untouched(self):
|
||||
layout, articles = sample()
|
||||
base = mu.plan(layout, articles)
|
||||
self.assertTrue(base["merges"])
|
||||
src = base["merges"][0]["source"]
|
||||
result = mu.plan(layout, articles, exclude=[src])
|
||||
self.assertTrue(all(m["source"] != src for m in result["merges"]))
|
||||
self.assertNotIn(src, {t["id"] for t in result["titles"]})
|
||||
kept = next(a for a in result["articles"] if a["id"] == src)
|
||||
orig = next(a for a in articles if a["id"] == src)
|
||||
self.assertEqual(kept["title"], orig["title"])
|
||||
|
||||
def test_apply_titles_only_touches_placeholders(self):
|
||||
_, articles = sample()
|
||||
n = mu.apply_titles(articles, [{"id": "article-001", "after": "X"}, {"id": "article-004", "after": "Anzeige",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user