diff --git a/CHANGELOG.md b/CHANGELOG.md index 92016f3..e00e03c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 identity at both ends by skipped neighbours is still reported as a conflict. API only — no UI wiring yet (#26). +- **Per-section sync review UI (#26): apply or skip each section.** A new + "Per-section sync (review sections)…" item in the Tempo/Grid menu opens a + review list: one row per section with its confidence badge, the scale + factor/shift it would apply, and an Apply/Skip toggle. Low-confidence sections + default to skip and are never applied unless the user explicitly selects them; + failed sections are not selectable. "Apply all high-confidence" re-selects just + the trusted sections and bakes the accepted map through the same + `applyPerSectionSync` path as #25, so the preview and the committed edit are + one map. A single-section song collapses to the uniform model (one full-domain + row). + - **The undo stack is no longer invisible: a last-action label and a Revert button sit beside Undo/Redo (#43).** Every committed edit now names itself — the label is derived from the command's own class (or an explicit `label` on diff --git a/screen.html b/screen.html index 27abca7..3a34089 100644 --- a/screen.html +++ b/screen.html @@ -881,6 +881,34 @@ + + + diff --git a/src/main.js b/src/main.js index 5cf1ad2..5d1fdad 100644 --- a/src/main.js +++ b/src/main.js @@ -88,6 +88,10 @@ import { } from './sync-tempo.js'; import { applyPerSectionSync, _applyPerSectionSyncPure } from './tempo.js'; import { _buildPiecewiseMapPure } from './section-sync.js'; +import { + editorApplySectionSync, editorHideSectionSync, editorShowSectionSync, + editorToggleSectionSync +} from './section-sync-ui.js'; import { getMousePos, onDblClick, onMouseDown, onMouseMove, onMouseUp, onWheel } from './mouse.js'; @@ -743,6 +747,12 @@ window.applyPerSectionSync = applyPerSectionSync; window._applyPerSectionSyncPure = _applyPerSectionSyncPure; window._buildPiecewiseMapPure = _buildPiecewiseMapPure; +// Per-section sync review (#26) — section-sync-ui.js owns the logic. +window.editorShowSectionSync = editorShowSectionSync; +window.editorHideSectionSync = editorHideSectionSync; +window.editorToggleSectionSync = editorToggleSectionSync; +window.editorApplySectionSync = editorApplySectionSync; + // Tab preview (tab-preview.js owns the logic; HTML calls these by name). window.editorShowTabPreview = _editorShowTabPreview; window.editorRefreshTabPreview = editorRefreshTabPreview; diff --git a/src/menu-bar.js b/src/menu-bar.js index 896613c..90bec6e 100644 --- a/src/menu-bar.js +++ b/src/menu-bar.js @@ -276,6 +276,7 @@ export const EDITOR_MENUS = Object.freeze([ { sep: true }, { cmd: 'songFit' }, { label: 'Sync tempo to audio', fn: 'editorSyncTempo', audioOnly: true }, + { label: 'Per-section sync (review sections)…', fn: 'editorShowSectionSync', audioOnly: true }, { label: 'Scan for tempo zones…', fn: 'editorScanTempoZones', audioOnly: true }, { label: 'Apply rough map from tempo zones', fn: 'editorApplyTempoZones', audioOnly: true }, { label: 'Map Health (grid-vs-recording drift)', fn: 'editorToggleMapHealth', audioOnly: true }, diff --git a/src/section-sync-ui.js b/src/section-sync-ui.js new file mode 100644 index 0000000..6956e4d --- /dev/null +++ b/src/section-sync-ui.js @@ -0,0 +1,229 @@ +// Per-section sync results UI (#26) — sub-issue 5/5 of per-section syncing (#8). +// +// Presents the #24 analysis one section at a time: a row with the section name, +// its confidence badge, and whether the section will be applied or skipped. +// Low-confidence sections default to SKIP and are never applied unless the user +// explicitly selects them; failed sections are not selectable at all. "Apply all +// high-confidence" re-selects just the trusted sections; "Apply" bakes the +// current selection through the same `applyPerSectionSync` path as #25, so a +// preview and the committed edit are one map. A single-section song collapses to +// the uniform model — one full-domain row. +// +// Pure decisions live in `section-sync.js` (`_sectionSyncPlanPure`); this module +// only gathers onsets, renders, and hands the plan to the apply path. The +// window.editor* entry points are attached by main.js (the repo convention). + +import { S } from './state.js'; +import { _ensureOnsetsShifted } from './audio.js'; +import { applyPerSectionSync } from './tempo.js'; +import { + _alignSectionsPure, _sectionSyncHighConfidenceDisabledPure, _sectionSyncPlanPure, +} from './section-sync.js'; +import { _editorEscHtml, _installModalKeyboard, setStatus } from './ui.js'; +import { host } from './host.js'; + +let _analysis = null; +let _decisions = null; // section index -> boolean (true = apply, false = skip) +let _prevFocus = null; +let _kbInstalled = false; + +const MODAL_ID = 'editor-section-sync-modal'; + +function _modal() { + return typeof document === 'undefined' ? null : document.getElementById(MODAL_ID); +} + +// Focus a live control in the modal. A disabled button refuses focus, so fall +// back through the primary action, any enabled control, then the modal root +// (which is `tabIndex = -1` and inside range of the keydown trap). +function _focusModal() { + if (typeof document === 'undefined') return; + const modal = _modal(); + if (!modal) return; + const apply = document.getElementById('editor-section-sync-apply'); + if (apply && !apply.disabled && apply.focus) { apply.focus(); return; } + if (typeof modal.querySelector === 'function') { + const first = modal.querySelector('button:not([disabled])'); + if (first && first.focus) { first.focus(); return; } + } + if (modal.focus) modal.focus(); +} + +export function editorShowSectionSync() { + if (typeof document === 'undefined') return; + if (S.beats.length < 2) { setStatus('Need at least two beats to sync.'); return; } + if (!S.audioBuffer && !S.waveformPeaks) { + setStatus('Load audio before per-section sync.'); + return; + } + const onsets = _ensureOnsetsShifted() || []; + if (!onsets.length) { + setStatus('No audio onsets detected yet — cannot per-section sync.'); + return; + } + const analysis = _alignSectionsPure({ + sections: S.sections || [], + beats: S.beats, + source: { arrangements: S.arrangements, drumTab: S.drumTab }, + audioOnsets: onsets, + }); + if (!analysis.ok) { + setStatus(`Per-section sync unavailable (${analysis.reason}).`); + return; + } + _analysis = analysis; + _decisions = new Map(); + const modal = _modal(); + if (!modal) return; + _renderSectionSync(); + _prevFocus = document.activeElement; + modal.classList.remove('hidden'); + if (!_kbInstalled) { + _kbInstalled = true; + // The panel (firstElementChild) owns the focusables; the overlay root + // only needs to stay focusable so a backdrop click can be redirected in. + _installModalKeyboard(modal, modal.firstElementChild, editorHideSectionSync); + } + _focusModal(); + setStatus('Review per-section sync results.'); +} + +export function editorHideSectionSync() { + const modal = _modal(); + if (modal) modal.classList.add('hidden'); + const prev = _prevFocus; + _prevFocus = null; + if (prev && prev.isConnected && prev.focus) prev.focus(); +} + +export function editorToggleSectionSync(index) { + if (!_analysis || !_decisions) return; + const row = _sectionSyncPlanPure(_analysis, _decisions) + .rows.find((r) => r.index === index); + if (!row) return; + if (!row.forceable) { + setStatus('That section has no usable alignment to apply.'); + return; + } + // Flip relative to the DECISION intent, not the conflict-demoted acceptance: + // an explicitly-skipped section that a conflict would otherwise hide must + // still toggle back on, and a demoted run must not become toggle-stuck. + _decisions.set(index, !row.wanted); + _renderSectionSync(); + // The re-render replaced the rows container, so the clicked button is gone + // and focus fell to — which would let global shortcuts through the + // overlay. Put focus back on this row's toggle (or a safe fallback). + _focusSectionToggle(index); +} + +// Restore focus to a row's toggle after a re-render, falling back inside the +// modal. `document.querySelector` is absent in some DOM stubs; the fallback +// covers that. +function _focusSectionToggle(index) { + if (typeof document === 'undefined') return; + const btn = typeof document.querySelector === 'function' + ? document.querySelector(`[data-section-ss="${index}"]`) : null; + if (btn && btn.focus) { btn.focus(); return; } + _focusModal(); +} + +export function editorApplySectionSync(allHighConfidence) { + if (!_analysis) return; + // "All high-confidence" is the default decision set: the confidence floor + // alone decides, i.e. low-confidence sections are skipped. + const decisions = allHighConfidence ? new Map() : _decisions; + const plan = _sectionSyncPlanPure(_analysis, decisions); + if (!plan.ok) { setStatus('Per-section sync analysis is unavailable.'); return; } + if (plan.conflicts.length) { + setStatus('Cannot apply: a section conflicts with the fit. Skip it and try again.'); + return; + } + if (!plan.summary.applied) { setStatus('No sections selected to apply.'); return; } + const res = applyPerSectionSync({ + proposals: plan.proposals, + confidenceFloor: plan.confidenceFloor, + domain: plan.domain, + }); + if (!res || !res.applied) { + setStatus(`Per-section sync refused (${(res && res.reason) || 'unknown'}).`); + return; + } + const n = plan.summary.applied; + editorHideSectionSync(); + host.draw(); + setStatus(`Applied per-section sync to ${n} section${n === 1 ? '' : 's'}.`); +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +function _tierBadge(row) { + const cls = row.tier === 'high' + ? 'bg-emerald-500/20 text-emerald-300' + : row.tier === 'low' + ? 'bg-amber-500/20 text-amber-300' + : 'bg-red-500/20 text-red-300'; + return `` + + `${row.confidencePct}%`; +} + +function _rowDetail(row) { + if (row.conflict) return 'conflict'; + if (!row.accepted) { + return row.tier === 'high' ? 'skipped' + : row.tier === 'low' ? 'below threshold' : 'no match'; + } + const shift = row.shift >= 0 ? `+${row.shift.toFixed(2)}` : row.shift.toFixed(2); + return `×${row.factor.toFixed(3)} · ${shift}s`; +} + +function _sectionRowHtml(row) { + const toggle = row.forceable + ? `` + : '—'; + return '
' + + '' + + `${_editorEscHtml(row.label)}` + + `${_tierBadge(row)}` + + `${_rowDetail(row)}` + + `${toggle}
`; +} + +function _summaryText(plan) { + const s = plan.summary; + if (!s.sections) return 'No sections to sync.'; + const parts = [`${s.applied}/${s.sections} selected`]; + // Count only low-confidence sections that are NOT being applied — a section + // the user explicitly selected must not read as "skipped". + const lowSkipped = plan.rows.filter((r) => r.tier === 'low' && !r.accepted).length; + if (lowSkipped) parts.push(`${lowSkipped} low-confidence skipped`); + if (s.failed) parts.push(`${s.failed} unmatched`); + if (plan.conflicts.length) parts.push(`${plan.conflicts.length} conflict`); + return parts.join(' · '); +} + +function _renderSectionSync() { + if (typeof document === 'undefined') return; + const plan = _sectionSyncPlanPure(_analysis, _decisions); + const rowsEl = document.getElementById('editor-section-sync-rows'); + if (rowsEl) { + const scroll = rowsEl.scrollTop || 0; + rowsEl.innerHTML = plan.rows.map(_sectionRowHtml).join(''); + rowsEl.scrollTop = scroll; + } + const summaryEl = document.getElementById('editor-section-sync-summary'); + if (summaryEl) summaryEl.textContent = _summaryText(plan); + const applyBtn = document.getElementById('editor-section-sync-apply'); + if (applyBtn) { + const n = plan.summary.applied; + applyBtn.disabled = n === 0; + applyBtn.textContent = n > 0 + ? `Apply ${n} section${n === 1 ? '' : 's'}` + : 'Apply selected'; + } + const highBtn = document.getElementById('editor-section-sync-apply-high'); + if (highBtn) highBtn.disabled = _sectionSyncHighConfidenceDisabledPure(_analysis); +} diff --git a/src/section-sync.js b/src/section-sync.js index 8287412..57c54a3 100644 --- a/src/section-sync.js +++ b/src/section-sync.js @@ -35,6 +35,14 @@ import { _detectTempoFromOnsetsPure } from './sync-tempo.js'; const EPS = 1e-9; +// The confidence a section must clear before it is trusted (applied) WITHOUT an +// explicit user choice (#26). The engine's own analysis floor is looser (0.5, +// so a low-confidence section still surfaces for review); applying/baking uses +// this stricter default so a "review and apply" run never silently carries a +// marginal section. The review UI may override it per section (see +// `_proposalAcceptedPure`'s `selected: true`), never for the whole run at once. +export const SECTION_SYNC_CONFIDENCE_FLOOR = 0.7; + function _clamp01(v) { return v < 0 ? 0 : (v > 1 ? 1 : v); } function _round4(v) { return Math.round(v * 1e4) / 1e4; } function _median(sortedOrRaw) { @@ -609,10 +617,18 @@ export function _alignSectionsPure(params) { * slope. A nonpositive slope would make the map non-monotone — event order * could reverse — so it is treated as inapplicable and its section skipped, * matching the design's rejection rule (PER-SECTION-SYNC-DESIGN.md L78-80). + * + * An explicit `selected` override from the #26 review UI wins over the + * confidence floor: `selected: false` is a hard skip (never applied), and + * `selected: true` forces a low-confidence (but otherwise usable) section + * through. The affine-validity checks are NOT overridable — a section with no + * finite, positive-slope line can never be applied, forced or not. */ -function _proposalAcceptedPure(p, confidenceFloor) { +export function _proposalAcceptedPure(p, confidenceFloor) { + if (!p || p.selected === false) return false; + const forced = p.selected === true; return Boolean(p.ok) - && p.confidence >= confidenceFloor + && (forced || p.confidence >= confidenceFloor) && Number.isFinite(p.slope) && p.slope > 0 && Number.isFinite(p.intercept); } @@ -719,6 +735,7 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) const intervals = sorted.map((p) => { const accepted = _proposalAcceptedPure(p, confidenceFloor); return { + index: p.index, start: p.start, end: p.end, slope: accepted ? p.slope : 1, @@ -736,4 +753,121 @@ export function _buildPiecewiseMapPure(proposals, domain, confidenceFloor = 0.7) map: (t) => _piecewiseAtPure(intervals, t), }; } + +/** + * A human label for a review row: the section's authored name, else its number, + * else a positional fallback. An unnamed region in a multi-region song is the + * explicit lead-in before the first marker; an unnamed single region is the + * whole song (the uniform case). + */ +function _sectionLabelPure(p, count) { + if (p.name) return p.name; + if (Number.isFinite(p.number) && p.number) return `Section ${p.number}`; + if (count > 1) return p.index === 0 ? 'Lead-in' : `Section ${p.index + 1}`; + return 'Full song'; +} + +/** + * The #26 review model: given an `_alignSectionsPure` result and the user's + * per-section decisions, build the map "Apply" would commit plus one row per + * section for the UI. `decisions` maps a section `index` to `true` (apply) or + * `false` (skip); an ABSENT index follows the confidence default, so a + * low-confidence section is never applied unless the user explicitly selects + * it — the "default never applies low confidence" acceptance rule. A section + * with no usable fit (`ok:false`) is not selectable at all. + * + * Returns `{ok, reason, domain, confidenceFloor, proposals, map, rows, + * conflicts, summary}`. `proposals` are the decisions folded in and are + * exactly what the apply path (`applyPerSectionSync`) takes, so the preview and + * the committed edit are the same map. + */ +export function _sectionSyncPlanPure(analysis, decisions, options = {}) { + const confidenceFloor = options.confidenceFloor != null + ? options.confidenceFloor : SECTION_SYNC_CONFIDENCE_FLOOR; + if (!analysis || !analysis.ok) { + return { + ok: false, + reason: analysis && analysis.reason ? analysis.reason : 'no-analysis', + domain: analysis ? analysis.domain : null, + confidenceFloor, + proposals: [], + map: null, + rows: [], + conflicts: [], + summary: { sections: 0, applied: 0, high: 0, low: 0, failed: 0 }, + }; + } + const overrides = decisions || {}; + const proposals = (analysis.proposals || []).map((p) => { + const over = overrides instanceof Map ? overrides.get(p.index) : overrides[p.index]; + return over === undefined ? p : { ...p, selected: !!over }; + }); + const map = _buildPiecewiseMapPure(proposals, analysis.domain, confidenceFloor); + const intervalByIndex = new Map((map.intervals || []).map((iv) => [iv.index, iv])); + const rows = proposals.map((p) => { + const iv = intervalByIndex.get(p.index); + const trusted = _proposalAcceptedPure({ ...p, selected: undefined }, confidenceFloor); + // `wanted` is the decision intent (confidence default + explicit + // override, ignoring conflict demotion); `accepted` is what the builder + // actually commits after run continuity. They differ when a run is + // demoted to identity by a conflict, which the UI shows as a conflict. + const wanted = _proposalAcceptedPure(p, confidenceFloor); + const accepted = Boolean(iv && iv.accepted); + const slope = accepted && Number.isFinite(iv.slope) ? iv.slope : 1; + const intercept = accepted && Number.isFinite(iv.intercept) ? iv.intercept : 0; + const confidence = _clamp01(p.confidence || 0); + return { + index: p.index, + name: p.name || '', + number: Number.isFinite(p.number) ? p.number : 0, + label: _sectionLabelPure(p, proposals.length), + start: p.start, + end: p.end, + confidence, + confidencePct: Math.round(confidence * 100), + reason: p.reason || null, + // high = trusted by the confidence floor, low = analysed but below + // it, failed = no usable fit. A low row can still be forced on (see + // _proposalAcceptedPure); a failed row is not selectable. + tier: trusted ? 'high' : (p.ok ? 'low' : 'failed'), + forceable: _proposalAcceptedPure({ ...p, selected: true }, confidenceFloor), + wanted, + conflict: wanted && !accepted, + selected: accepted, + accepted, + factor: _round4(Number.isFinite(p.factor) ? p.factor : 1), + shift: _round4(slope * p.start + intercept - p.start), + }; + }); + return { + ok: true, + reason: null, + domain: analysis.domain, + confidenceFloor, + proposals, + map, + conflicts: map.conflicts || [], + rows, + summary: { + sections: rows.length, + applied: rows.filter((r) => r.accepted).length, + high: rows.filter((r) => r.tier === 'high').length, + low: rows.filter((r) => r.tier === 'low').length, + failed: rows.filter((r) => r.tier === 'failed').length, + }, + }; +} + +/** + * The availability of the #26 "Apply all high-confidence" action. That button + * always commits the DEFAULT decision set (the confidence floor alone), so its + * gate must be read from the default plan — never from the user's current + * `decisions`, whose skips can clear a conflict the default plan still carries + * and enable a button whose click is then refused. True when the action would + * be refused: no trusted section, or a run-continuity conflict in that plan. + */ +export function _sectionSyncHighConfidenceDisabledPure(analysis) { + const plan = _sectionSyncPlanPure(analysis); + return plan.summary.high === 0 || plan.conflicts.length > 0; +} /* @pure:section-sync:end */ diff --git a/tests/section_sync_ui.test.mjs b/tests/section_sync_ui.test.mjs new file mode 100644 index 0000000..767c205 --- /dev/null +++ b/tests/section_sync_ui.test.mjs @@ -0,0 +1,229 @@ +/* + * Per-section sync REVIEW model (#26): the pure decision layer the review UI + * renders and applies. + * + * Pins the acceptance rules from PER-SECTION-SYNC-DESIGN.md L150-158 that a + * builder test alone can't reach: + * • a low-confidence section is skipped by DEFAULT — the default action never + * applies it (design's do-no-harm rule, #22); + * • explicitly selecting a low-confidence section applies it; deselecting a + * trusted one skips it; + * • a section with no usable fit is never selectable; + * • a single-section song collapses to one full-domain row; + * • the review plan is the SAME map `applyPerSectionSync` commits, so a + * skipped section's notes keep their exact seconds while selected ones bake. + * + * Run: node tests/section_sync_ui.test.mjs + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EditHistory } from '../src/history.js'; +import { seedState } from './_history_env.mjs'; +import { applyPerSectionSync } from '../src/tempo.js'; +import { + SECTION_SYNC_CONFIDENCE_FLOOR, _proposalAcceptedPure, _sectionSyncHighConfidenceDisabledPure, + _sectionSyncPlanPure, +} from '../src/section-sync.js'; + +function prop(index, start, end, opts = {}) { + const ok = opts.ok !== false; + const confidence = opts.confidence != null ? opts.confidence : 0.9; + return { + ok, + index, + name: opts.name !== undefined ? opts.name : `Part ${index}`, + number: opts.number !== undefined ? opts.number : index + 1, + start, + end, + reason: opts.reason || (ok && confidence >= SECTION_SYNC_CONFIDENCE_FLOOR ? null : 'low-confidence'), + confidence, + factor: opts.factor != null ? opts.factor : 2, + slope: opts.slope != null ? opts.slope : 0.5, + intercept: opts.intercept != null ? opts.intercept : 0, + }; +} + +function analysis(props) { + return { ok: true, reason: null, domain: { start: 0, end: 30 }, proposals: props }; +} + +test('the default decision applies trusted sections and skips low-confidence ones', () => { + const a = analysis([ + prop(0, 0, 10, { confidence: 0.9, name: 'Intro' }), + prop(1, 10, 20, { confidence: 0.3, name: 'Solo' }), + prop(2, 20, 30, { confidence: 0.8, name: 'Outro' }), + ]); + const plan = _sectionSyncPlanPure(a); + assert.strictEqual(plan.ok, true); + assert.strictEqual(plan.confidenceFloor, SECTION_SYNC_CONFIDENCE_FLOOR); + const [intro, solo, outro] = plan.rows; + assert.strictEqual(intro.tier, 'high'); + assert.strictEqual(intro.accepted, true, 'trusted section applies by default'); + assert.strictEqual(solo.tier, 'low'); + assert.strictEqual(solo.accepted, false, 'low-confidence section is skipped by default'); + assert.strictEqual(solo.selected, false); + assert.strictEqual(solo.forceable, true, 'a low-confidence section can be forced on'); + assert.strictEqual(outro.accepted, true); + assert.deepStrictEqual(plan.conflicts, []); +}); + +test('an explicit decision overrides the confidence default both ways', () => { + const a = analysis([ + prop(0, 0, 10, { confidence: 0.9 }), + prop(1, 10, 20, { confidence: 0.3 }), + ]); + // Force the low-confidence section on. + const forced = _sectionSyncPlanPure(a, new Map([[1, true]])); + assert.strictEqual(forced.rows[1].accepted, true, 'selected low-confidence section applies'); + assert.strictEqual(forced.rows[1].wanted, true); + assert.ok(forced.rows[1].tier === 'low', 'it stays labelled low even when forced'); + // Skip the trusted section. + const skipped = _sectionSyncPlanPure(a, new Map([[0, false]])); + assert.strictEqual(skipped.rows[0].accepted, false, 'deselected trusted section is skipped'); + assert.strictEqual(skipped.rows[0].wanted, false); +}); + +test('a section with no usable fit is never selectable, even when forced', () => { + const a = analysis([ + prop(0, 0, 10, { ok: false, confidence: 0, name: 'Broken' }), + ]); + const plan = _sectionSyncPlanPure(a, new Map([[0, true]])); + const row = plan.rows[0]; + assert.strictEqual(row.tier, 'failed'); + assert.strictEqual(row.forceable, false); + assert.strictEqual(row.accepted, false, 'forcing a failed section does nothing'); + assert.strictEqual(plan.summary.applied, 0); +}); + +test('a single-section song collapses to one full-domain row', () => { + const a = analysis([prop(0, 0, 30, { confidence: 0.85, name: '', number: 0 })]); + const plan = _sectionSyncPlanPure(a); + assert.strictEqual(plan.rows.length, 1); + assert.strictEqual(plan.rows[0].label, 'Full song'); + assert.strictEqual(plan.rows[0].accepted, true); + assert.strictEqual(plan.summary.applied, 1); + // Uniform flow: the lone accepted section keeps its affine map (no pins). + assert.strictEqual(plan.map.map(10), 5); + assert.strictEqual(plan.map.map(20), 10); +}); + +test('a run pinned to identity at both ends surfaces a conflict the UI can block on', () => { + const a = analysis([ + prop(0, 0, 10, { confidence: 0.3 }), + prop(1, 10, 20, { confidence: 0.9, slope: 2 }), + prop(2, 20, 30, { confidence: 0.3 }), + ]); + const plan = _sectionSyncPlanPure(a); + assert.strictEqual(plan.conflicts.length, 1); + assert.strictEqual(plan.conflicts[0].reason, 'identity-conflict'); + assert.strictEqual(plan.summary.applied, 0); + // The middle row still WANTS to apply (trusted, no user decision) but the + // conflict demotes it — the UI distinguishes the two so its toggle is not + // stuck reading "Skip". + const middle = plan.rows[1]; + assert.strictEqual(middle.wanted, true); + assert.strictEqual(middle.accepted, false); + assert.strictEqual(middle.conflict, true); + // Skipping the middle section (the decision intent) removes the conflict. + const skipped = _sectionSyncPlanPure(a, new Map([[1, false]])); + assert.deepStrictEqual(skipped.conflicts, []); + assert.strictEqual(skipped.rows[1].wanted, false); +}); + +test('the "Apply all high-confidence" gate reads the DEFAULT plan, not current decisions', () => { + const a = analysis([ + prop(0, 0, 10, { confidence: 0.3 }), + prop(1, 10, 20, { confidence: 0.9, slope: 2 }), + prop(2, 20, 30, { confidence: 0.3 }), + ]); + // The default plan pins the trusted middle run at both ends -> conflict, so + // the button that commits that plan is blocked. + assert.strictEqual(_sectionSyncHighConfidenceDisabledPure(a), true); + // Skipping the middle section clears the CURRENT plan's conflict, but the + // button rebuilds the default decision set, so it must stay blocked — the + // regression the gate is derived from the default plan to avoid. + const current = _sectionSyncPlanPure(a, new Map([[1, false]])); + assert.deepStrictEqual(current.conflicts, []); + assert.strictEqual(_sectionSyncHighConfidenceDisabledPure(a), true); +}); + +test('_proposalAcceptedPure honours selected overrides but never affine validity', () => { + assert.strictEqual(_proposalAcceptedPure(prop(0, 0, 10, { confidence: 0.1 }), 0.7), false); + assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10, { confidence: 0.1 }), selected: true }, 0.7), true); + assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10), selected: false }, 0.7), false); + // A nonpositive slope stays inapplicable even when forced. + assert.strictEqual(_proposalAcceptedPure({ ...prop(0, 0, 10, { slope: 0 }), selected: true }, 0.7), false); +}); + +// ── Integration: the review plan drives the same apply path as #25 ────────── + +function scenario() { + const s = seedState(); + s.beats = []; + for (let t = 0; t <= 30; t += 5) s.beats.push({ time: t, measure: -1, den: 4 }); + s.sections = [ + { name: 'Intro', number: 1, start_time: 0 }, + { name: 'Solo', number: 2, start_time: 10 }, + ]; + s.arrangements = [{ + notes: [ + { time: 5, sustain: 1, midi: 60 }, // Intro [0,10) — low confidence + { time: 12, sustain: 1, midi: 62 }, // Solo [10,30) — trusted + { time: 22, sustain: 1, midi: 64 }, // Solo [10,30) — trusted + ], + chords: [], anchors: [], anchors_user: [], handshapes: [], phrases: [], + }]; + s.drumTab = null; + s.history = new EditHistory(); + return s; +} + +test('a skipped low-confidence section keeps its notes while neighbours bake', () => { + const s = scenario(); + const a = { + ok: true, + domain: { start: 0, end: 30 }, + proposals: [ + prop(0, 0, 10, { confidence: 0.2, slope: 2, name: 'Intro' }), + prop(1, 10, 30, { confidence: 0.9, slope: 2, name: 'Solo' }), + ], + }; + const plan = _sectionSyncPlanPure(a); + assert.strictEqual(plan.rows[0].accepted, false, 'Intro defaults to skip'); + assert.strictEqual(plan.rows[1].accepted, true, 'Solo applies'); + assert.deepStrictEqual(plan.conflicts, []); + + const res = applyPerSectionSync({ + proposals: plan.proposals, + confidenceFloor: plan.confidenceFloor, + domain: plan.domain, + }); + assert.strictEqual(res.applied, true); + const notes = s.arrangements[0].notes; + assert.strictEqual(notes[0].time, 5, 'skipped section note untouched'); + assert.strictEqual(notes[1].time, 14, 'trusted section note baked'); + assert.strictEqual(notes[2].time, 34, 'trusted section note baked'); +}); + +test('selecting the low-confidence section applies it through the same plan', () => { + const s = scenario(); + const a = { + ok: true, + domain: { start: 0, end: 30 }, + proposals: [ + prop(0, 0, 10, { confidence: 0.2, slope: 2, name: 'Intro' }), + prop(1, 10, 30, { confidence: 0.9, slope: 2, name: 'Solo' }), + ], + }; + const plan = _sectionSyncPlanPure(a, new Map([[0, true]])); + assert.strictEqual(plan.rows[0].accepted, true, 'selected low-confidence section applies'); + const res = applyPerSectionSync({ + proposals: plan.proposals, + confidenceFloor: plan.confidenceFloor, + domain: plan.domain, + }); + assert.strictEqual(res.applied, true); + const notes = s.arrangements[0].notes; + assert.strictEqual(notes[0].time, 10, 'selected section note baked (2× at 0)'); + assert.strictEqual(notes[1].time, 24, 'selected run maps continuously'); +}); diff --git a/tests/section_sync_ui_dom.test.mjs b/tests/section_sync_ui_dom.test.mjs new file mode 100644 index 0000000..30c4801 --- /dev/null +++ b/tests/section_sync_ui_dom.test.mjs @@ -0,0 +1,201 @@ +/* + * Per-section sync REVIEW UI (#26) — the DOM wiring behind the pure plan. + * + * The pure decision layer is unit-tested in section_sync_ui.test.mjs; this + * drives the real src/section-sync-ui.js against a DOM stub so the interaction + * bugs a pure test can't see are pinned: + * • the default (all-low-confidence) plan disables Apply and still puts focus + * INSIDE the modal (a disabled button refuses focus — the code must fall + * back, or global shortcuts leak through the overlay); + * • toggling a row re-renders the rows container, so focus must be restored + * to the modal (not dropped to ); + * • the summary counts only sections that are actually skipped; + * • Apply bakes the selection and closes the modal; Escape closes and + * restores the opener. + * + * Run: node tests/section_sync_ui_dom.test.mjs + */ +import assert from 'node:assert'; + +// ── DOM stub (the sync_dialog_keyboard.test.mjs pattern) ───────────── +const listeners = new Map(); +let activeEl = null; + +function mkEl(id, opts = {}) { + const children = []; + const classList = { + _s: new Set(), + add(c) { this._s.add(c); }, + remove(c) { this._s.delete(c); }, + toggle(c, on) { on ? this._s.add(c) : this._s.delete(c); }, + contains(c) { return this._s.has(c); }, + }; + return { + id, className: opts.className || '', tagName: (opts.tag || 'DIV').toUpperCase(), + textContent: '', value: '', disabled: false, files: [], + style: {}, classList, children, parentNode: null, + tabIndex: 0, isConnected: true, scrollTop: 0, + _attrs: {}, + setAttribute(k, v) { this._attrs[k] = v; }, + getAttribute(k) { return this._attrs[k]; }, + appendChild(c) { children.push(c); c.parentNode = this; return c; }, + addEventListener(type, fn) { + if (!listeners.has(id)) listeners.set(id, []); + listeners.get(id).push({ type, fn }); + }, + removeEventListener() {}, + focus() { activeEl = this; }, + blur() { activeEl = null; }, + contains(n) { return n === this || children.some((c) => c.contains && c.contains(n)); }, + querySelectorAll(sel) { + const want = []; + if (sel.includes('button')) want.push('BUTTON'); + if (sel.includes('input')) want.push('INPUT'); + if (sel.includes('select')) want.push('SELECT'); + if (sel.includes('textarea')) want.push('TEXTAREA'); + return this._all().filter((e) => want.includes(e.tagName)); + }, + querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }, + get firstElementChild() { return children[0] || null; }, + _all() { + let out = [...children]; + for (const c of children) out = out.concat(c._all ? c._all() : []); + return out; + }, + get innerHTML() { return ''; }, + set innerHTML(_v) { /* rows are HTML strings; the stub drops them */ }, + }; +} + +const registry = new Map(); +function reg(id, el) { registry.set(id, el); return el; } +function byId(id) { + if (!registry.has(id)) registry.set(id, mkEl(id)); + return registry.get(id); +} + +globalThis.document = { + getElementById: byId, + createElement: (t) => mkEl(t), + querySelector: () => null, // row toggles are innerHTML strings + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + body: { appendChild() {} }, + get activeElement() { return activeEl; }, +}; +globalThis.window = globalThis.window || globalThis; +globalThis.localStorage = globalThis.localStorage || { getItem: () => null, setItem: () => {} }; + +// Shipped markup shape: overlay root → panel (firstElementChild) → controls. +const modal = reg('editor-section-sync-modal', mkEl('modal', { className: 'absolute inset-0' })); +const panel = mkEl('panel'); +const rowsEl = reg('editor-section-sync-rows', mkEl('rows')); +const summaryEl = reg('editor-section-sync-summary', mkEl('summary')); +const applyHigh = reg('editor-section-sync-apply-high', mkEl('apply-high', { tag: 'button' })); +const applyBtn = reg('editor-section-sync-apply', mkEl('apply', { tag: 'button' })); +const cancel = mkEl('cancel', { tag: 'button' }); +modal.appendChild(panel); +panel.appendChild(rowsEl); +panel.appendChild(summaryEl); +panel.appendChild(applyHigh); +panel.appendChild(applyBtn); +panel.appendChild(cancel); +reg('editor-status', mkEl('editor-status')); + +const { S } = await import('../src/state.js'); +const { EditHistory } = await import('../src/history.js'); +const { setHostHooks } = await import('../src/host.js'); +setHostHooks({ draw: () => {}, updateStatus: () => {}, ensureArr: () => true }); + +// A single full-domain section reads low-confidence (periodic onsets vs an +// off-grid melody) but is `ok` — exactly the forceable-but-skipped case. +const rms = new Float32Array(60); +for (let i = 0; i < rms.length; i++) rms[i] = i % 2 === 0 ? 1 : 0; +Object.assign(S, { + beats: Array.from({ length: 61 }, (_, k) => ({ time: k * 0.5, measure: k % 4 === 0 ? k / 4 + 1 : -1, den: 4 })), + sections: [], + duration: 30, + waveformPeaks: { bins: 60, rms }, + audioBuffer: null, + audioShift: 0, + activeAudioSourceOffset: 0, + activeAudioSourceId: 'master', + trackSession: { version: 2, tracks: [], removedSourceIds: [], tempoGuideSourceId: '', tempoGuideLocked: false, tempoGuideMode: 'audio' }, + arrangements: [{ + notes: Array.from({ length: 16 }, (_, k) => ({ time: 2 + k * 1.7, sustain: 1, midi: 60 })), + chords: [], anchors: [], anchors_user: [], handshapes: [], phrases: [], + }], + drumTab: null, + history: new EditHistory(), +}); + +const { editorShowSectionSync, editorToggleSectionSync, editorApplySectionSync } = + await import('../src/section-sync-ui.js'); + +function dispatchKey(el, key) { + const evt = { key, target: el, preventDefault() {}, stopPropagation() {} }; + for (const { type, fn } of (listeners.get(el.id) || [])) if (type === 'keydown') fn(evt); +} + +let pass = 0, fail = 0; +function t(name, fn) { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +// ── Tests ─────────────────────────────────────────────────────────── +t('opening shows the modal, disables Apply on an all-skipped plan, and focuses inside', () => { + activeEl = null; + editorShowSectionSync(); + assert.ok(!modal.classList.contains('hidden'), 'modal visible'); + assert.match(summaryEl.textContent, /0\/1 selected/, 'summary counts 0 applied'); + assert.match(summaryEl.textContent, /1 low-confidence skipped/, 'low section reported skipped'); + assert.strictEqual(applyBtn.disabled, true, 'Apply disabled with nothing selected'); + assert.strictEqual(applyHigh.disabled, true, '"Apply all high-confidence" disabled with no trusted section'); + assert.ok(activeEl && activeEl !== document.body, 'focus moved into the modal despite disabled Apply'); + assert.strictEqual(panel.contains(activeEl) || activeEl === modal, true, 'focus is inside the modal'); +}); + +t('toggling the low-confidence section selects it, enables Apply and keeps focus in the modal', () => { + editorToggleSectionSync(0); + assert.match(summaryEl.textContent, /1\/1 selected/, 'summary counts the forced section'); + assert.doesNotMatch(summaryEl.textContent, /skipped/, 'no skipped count once selected'); + assert.strictEqual(applyBtn.disabled, false, 'Apply enabled after selecting'); + // The high-confidence action still has no trusted section (the tier is + // confidence, not the toggle), so its gate stays disabled. + assert.strictEqual(applyHigh.disabled, true, 'high-confidence action still unavailable'); + assert.ok(activeEl && activeEl !== document.body, 'focus restored after the re-render'); + assert.strictEqual(panel.contains(activeEl) || activeEl === modal, true, 'focus stays inside the modal'); +}); + +t('"Apply all high-confidence" on an all-low song does not bake', () => { + activeEl = null; + editorShowSectionSync(); + const before = S.arrangements[0].notes[0].time; + editorApplySectionSync(true); + assert.strictEqual(S.arrangements[0].notes[0].time, before, 'nothing baked'); + assert.ok(!modal.classList.contains('hidden'), 'modal stays open when nothing applies'); +}); + +t('applying the selection bakes the notes and closes the modal', () => { + activeEl = null; + editorShowSectionSync(); + const before = S.arrangements[0].notes[0].time; + editorToggleSectionSync(0); + editorApplySectionSync(false); + assert.notStrictEqual(S.arrangements[0].notes[0].time, before, 'selected section baked'); + assert.ok(modal.classList.contains('hidden'), 'modal closed after apply'); +}); + +t('Escape closes the modal and restores focus to the opener', () => { + const opener = mkEl('opener'); + opener.focus(); + editorShowSectionSync(); + dispatchKey(modal, 'Escape'); + assert.ok(modal.classList.contains('hidden'), 'modal hidden after Escape'); + assert.strictEqual(activeEl, opener, 'focus restored to the opener'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);