mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 11:08:32 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27164ec5ba | ||
|
|
e134f5c802 | ||
|
|
f1bae9774c | ||
|
|
751209b80e |
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, move-only).**
|
||||||
|
The library metadata cache — the `MetadataDB` class (4,018 lines) plus the query
|
||||||
|
helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement
|
||||||
|
naming, tag normalisation, the startup DB-restore swap) — now lives in its own flat
|
||||||
|
`lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the
|
||||||
|
`meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before
|
||||||
|
and every route is untouched. The only non-verbatim change is the seam that lets the
|
||||||
|
class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly
|
||||||
|
(`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`,
|
||||||
|
which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging
|
||||||
|
still goes through the `feedBack.server` logger, so existing log filters and `caplog`
|
||||||
|
assertions resolve to the same logger object. `tests/test_settings_export_library_db.py`
|
||||||
|
now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its
|
||||||
|
subject); no other test changed. Every moved block is byte-identical to its
|
||||||
|
`server.py` original.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
||||||
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
||||||
|
|||||||
@@ -54,8 +54,11 @@ without a *signed* exemption" is unenforceable.
|
|||||||
|
|
||||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||||
|
|
||||||
core `static/app.js` (11,821) · `static/highway.js` (4,154, whole file) · `server.py`
|
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||||
(13,948) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
(9,705 — was 14,037; ratcheted by the R3 `MetadataDB` extraction) ·
|
||||||
|
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||||
|
and is a monolith in its own right, to be split per-table once the router train
|
||||||
|
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||||
|
|||||||
+10
-3
@@ -1114,7 +1114,7 @@ def _build_xml(
|
|||||||
ET.SubElement(root, "arrangement").text = arrangement
|
ET.SubElement(root, "arrangement").text = arrangement
|
||||||
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
||||||
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
||||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000"
|
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000"
|
||||||
ET.SubElement(root, "averageTempo").text = str(tempo)
|
ET.SubElement(root, "averageTempo").text = str(tempo)
|
||||||
ET.SubElement(root, "artistName").text = artist
|
ET.SubElement(root, "artistName").text = artist
|
||||||
ET.SubElement(root, "albumName").text = album
|
ET.SubElement(root, "albumName").text = album
|
||||||
@@ -1139,10 +1139,17 @@ def _build_xml(
|
|||||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||||
ET.SubElement(root, "capo").text = "0"
|
ET.SubElement(root, "capo").text = "0"
|
||||||
|
|
||||||
# Ebeats
|
# Ebeats — write beat times at MICROSECOND (6-decimal) precision, not
|
||||||
|
# millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat
|
||||||
|
# spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a
|
||||||
|
# constant-tempo GP (e.g. 140) shows a spurious ±0.05–0.7 BPM per-bar drift
|
||||||
|
# (worse for fast/odd meters) because most bar lengths don't land on a ms
|
||||||
|
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
|
||||||
|
# only loss is this format string — 6 decimals makes the derived tempo match
|
||||||
|
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
|
||||||
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
||||||
for b in beats:
|
for b in beats:
|
||||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
|
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
|
||||||
|
|
||||||
# Sections
|
# Sections
|
||||||
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
||||||
|
|||||||
+4373
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
This module is deliberately kept apart from ``server.py`` so that
|
This module is deliberately kept apart from ``server.py`` so that
|
||||||
``ProcessPoolExecutor`` workers can import and unpickle ``_scan_one``
|
``ProcessPoolExecutor`` workers can import and unpickle ``_scan_one``
|
||||||
without dragging in ``server.py``'s import-time side effects
|
without dragging in ``server.py``'s import-time side effects
|
||||||
(``configure_logging()``, ``meta_db = MetadataDB()`` opening/migrating
|
(``configure_logging()``, ``meta_db = MetadataDB(CONFIG_DIR)`` opening/migrating
|
||||||
SQLite, and ``register_plugin_api(app)`` registering routes).
|
SQLite, and ``register_plugin_api(app)`` registering routes).
|
||||||
|
|
||||||
The background scan spawns its pool with the ``spawn`` start method (see
|
The background scan spawns its pool with the ``spawn`` start method (see
|
||||||
|
|||||||
@@ -98,6 +98,22 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
|
|||||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||||
|
|
||||||
|
|
||||||
|
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
|
||||||
|
"""Return absolute open-string MIDI notes for frequencies at the supplied
|
||||||
|
A4 reference — the inverse of open_midis_to_freqs. None if any entry is
|
||||||
|
non-numeric or non-positive (a provider could hand us anything)."""
|
||||||
|
out: list[int] = []
|
||||||
|
for f in freqs:
|
||||||
|
try:
|
||||||
|
f = float(f)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if f <= 0:
|
||||||
|
return None
|
||||||
|
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||||
"""Return semitone offsets from the instrument's standard open strings."""
|
"""Return semitone offsets from the instrument's standard open strings."""
|
||||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "highway_3d",
|
"id": "highway_3d",
|
||||||
"name": "3D Highway",
|
"name": "3D Highway",
|
||||||
"version": "3.31.4",
|
"version": "3.31.5",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
"script": "screen.js",
|
"script": "screen.js",
|
||||||
|
|||||||
@@ -548,9 +548,20 @@
|
|||||||
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
|
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
|
||||||
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
|
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
|
||||||
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
|
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
|
||||||
|
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
|
||||||
|
// render size and report that SAME size to Butterchurn. Its on-screen
|
||||||
|
// pass viewports to the reported size but never sizes the output canvas
|
||||||
|
// itself — leaving the buffer at the 300x150 default blits the whole
|
||||||
|
// visualizer into a corner that CSS then stretches across the highway.
|
||||||
|
// pixelRatio:1 because DPR is now folded into the reported size, so
|
||||||
|
// buffer == viewport == internal texsize (no double-counting).
|
||||||
|
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||||
|
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
|
||||||
|
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
|
||||||
|
canvas.width = _bcW0; canvas.height = _bcH0;
|
||||||
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
|
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
|
||||||
width: sz.w || 1280, height: sz.h || 720,
|
width: _bcW0, height: _bcH0,
|
||||||
pixelRatio: Math.min(window.devicePixelRatio || 1, 1.5), textureRatio: 1,
|
pixelRatio: 1, textureRatio: 1,
|
||||||
});
|
});
|
||||||
if (_bcIsDesktop()) {
|
if (_bcIsDesktop()) {
|
||||||
try {
|
try {
|
||||||
@@ -584,6 +595,27 @@
|
|||||||
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
|
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
|
||||||
_bcControllers.delete(ctrl);
|
_bcControllers.delete(ctrl);
|
||||||
});
|
});
|
||||||
|
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
|
||||||
|
// device-pixel render size AND report that same size, so buffer ==
|
||||||
|
// on-screen viewport == full fill. Butterchurn never sizes the output
|
||||||
|
// canvas itself; the previous code set only CSS size, leaving the buffer
|
||||||
|
// at the 300x150 default -> the viz showed a stretched lower-left corner
|
||||||
|
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
|
||||||
|
function _bcApplySize(cssW, cssH) {
|
||||||
|
if (!(cssW > 0 && cssH > 0)) return;
|
||||||
|
ctrl.lastW = cssW; ctrl.lastH = cssH;
|
||||||
|
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||||
|
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
|
||||||
|
if (canvas.width !== bw) canvas.width = bw;
|
||||||
|
if (canvas.height !== bh) canvas.height = bh;
|
||||||
|
const wpx = cssW + 'px', hpx = cssH + 'px';
|
||||||
|
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
||||||
|
// backdrop can't bleed over the transport bar above the highway.
|
||||||
|
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
||||||
|
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
||||||
|
});
|
||||||
|
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
applySettings() { ctrl.applySettings(); },
|
applySettings() { ctrl.applySettings(); },
|
||||||
dead() { return ctrl.dead; },
|
dead() { return ctrl.dead; },
|
||||||
@@ -612,18 +644,11 @@
|
|||||||
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
|
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
|
||||||
const sz = sizeProvider && sizeProvider();
|
const sz = sizeProvider && sizeProvider();
|
||||||
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
|
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
|
||||||
ctrl.lastW = sz.w; ctrl.lastH = sz.h;
|
_bcApplySize(sz.w, sz.h);
|
||||||
const wpx = sz.w + 'px', hpx = sz.h + 'px';
|
|
||||||
// Confine ALL layers to exactly the highway-canvas rect so the opaque
|
|
||||||
// backdrop can't bleed over the transport bar above the highway.
|
|
||||||
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
|
|
||||||
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
|
|
||||||
});
|
|
||||||
try { ctrl.viz.setRendererSize(sz.w, sz.h); } catch (e) {}
|
|
||||||
}
|
}
|
||||||
try { ctrl.viz.render(); } catch (e) {}
|
try { ctrl.viz.render(); } catch (e) {}
|
||||||
},
|
},
|
||||||
resize(w, h) { if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(w, h); } catch (e) {} ctrl.lastW = w; ctrl.lastH = h; } },
|
resize(w, h) { _bcApplySize(w, h); },
|
||||||
destroy() {
|
destroy() {
|
||||||
ctrl.dead = true;
|
ctrl.dead = true;
|
||||||
_bcControllers.delete(ctrl);
|
_bcControllers.delete(ctrl);
|
||||||
|
|||||||
+4
-1
@@ -121,7 +121,10 @@ def _converter_ebeats(converter, numerator, denominator, tempo_changes=None):
|
|||||||
def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None):
|
def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None):
|
||||||
ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes)
|
ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes)
|
||||||
|
|
||||||
assert [ebeat.get("time") for ebeat in ebeats] == expected_times
|
# Compare by value, not string: beat times are written at 6-decimal
|
||||||
|
# (microsecond) precision so the derived per-bar tempo matches the authored
|
||||||
|
# GP value, but these tests only care about the spacing, not the format.
|
||||||
|
assert [float(ebeat.get("time")) for ebeat in ebeats] == [float(t) for t in expected_times]
|
||||||
assert [ebeat.get("measure") for ebeat in ebeats] == [
|
assert [ebeat.get("measure") for ebeat in ebeats] == [
|
||||||
"1",
|
"1",
|
||||||
*["-1"] * (len(expected_times) - 1),
|
*["-1"] * (len(expected_times) - 1),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def client(tmp_path, monkeypatch):
|
|||||||
# Point CONFIG_DIR at a per-test temp path BEFORE server's
|
# Point CONFIG_DIR at a per-test temp path BEFORE server's
|
||||||
# import-time side effects run. server.py reads CONFIG_DIR from the
|
# import-time side effects run. server.py reads CONFIG_DIR from the
|
||||||
# environment at module load (line 35) and immediately constructs
|
# environment at module load (line 35) and immediately constructs
|
||||||
# `meta_db = MetadataDB()` at module level, which calls
|
# `meta_db = MetadataDB(CONFIG_DIR)` at module level, which calls
|
||||||
# CONFIG_DIR.mkdir(...) and opens a sqlite file — a plain
|
# CONFIG_DIR.mkdir(...) and opens a sqlite file — a plain
|
||||||
# post-import monkeypatch on server.CONFIG_DIR wouldn't catch those
|
# post-import monkeypatch on server.CONFIG_DIR wouldn't catch those
|
||||||
# side effects, and the real user config dir would get written to.
|
# side effects, and the real user config dir would get written to.
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
# The startup DB swap lives with the DB layer it guards, not with server.py.
|
||||||
|
# metadata_db reads no environment at import, so a plain module import is safe
|
||||||
|
# alongside the env-patched `server_mod` fixture below.
|
||||||
|
from metadata_db import _apply_pending_db_restore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def server_mod(tmp_path, monkeypatch):
|
def server_mod(tmp_path, monkeypatch):
|
||||||
@@ -219,7 +224,7 @@ def test_apply_pending_db_restore_swaps_and_clears_sidecars(server_mod, tmp_path
|
|||||||
(tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM")
|
(tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM")
|
||||||
(tmp_path / "web_library.db.restore").write_bytes(new_db)
|
(tmp_path / "web_library.db.restore").write_bytes(new_db)
|
||||||
|
|
||||||
server_mod._apply_pending_db_restore(tmp_path)
|
_apply_pending_db_restore(tmp_path)
|
||||||
|
|
||||||
assert main.read_bytes() == new_db # swapped in
|
assert main.read_bytes() == new_db # swapped in
|
||||||
assert not (tmp_path / "web_library.db.restore").exists()
|
assert not (tmp_path / "web_library.db.restore").exists()
|
||||||
@@ -234,7 +239,7 @@ def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_pa
|
|||||||
main.write_bytes(b"LIVE-GOOD-DB")
|
main.write_bytes(b"LIVE-GOOD-DB")
|
||||||
(tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64)
|
(tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64)
|
||||||
|
|
||||||
server_mod._apply_pending_db_restore(tmp_path)
|
_apply_pending_db_restore(tmp_path)
|
||||||
|
|
||||||
assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved
|
assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved
|
||||||
assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped
|
assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped
|
||||||
@@ -242,7 +247,7 @@ def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_pa
|
|||||||
|
|
||||||
def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path):
|
def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path):
|
||||||
(tmp_path / "web_library.db").write_bytes(b"LIVE")
|
(tmp_path / "web_library.db").write_bytes(b"LIVE")
|
||||||
server_mod._apply_pending_db_restore(tmp_path) # nothing staged
|
_apply_pending_db_restore(tmp_path) # nothing staged
|
||||||
assert (tmp_path / "web_library.db").read_bytes() == b"LIVE"
|
assert (tmp_path / "web_library.db").read_bytes() == b"LIVE"
|
||||||
|
|
||||||
|
|
||||||
@@ -266,7 +271,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
|
|||||||
# Simulate a restart: close the live conn, apply the staged restore,
|
# Simulate a restart: close the live conn, apply the staged restore,
|
||||||
# reopen — the song is back.
|
# reopen — the song is back.
|
||||||
server_mod.meta_db.conn.close()
|
server_mod.meta_db.conn.close()
|
||||||
server_mod._apply_pending_db_restore(tmp_path)
|
_apply_pending_db_restore(tmp_path)
|
||||||
conn = sqlite3.connect(str(tmp_path / "web_library.db"))
|
conn = sqlite3.connect(str(tmp_path / "web_library.db"))
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
@@ -249,3 +249,30 @@ def test_flat_string_count_patch_resets_incompatible_named_tuning():
|
|||||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
|
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
|
||||||
assert patched["string_count"] == 7
|
assert patched["string_count"] == 7
|
||||||
assert patched["tuning"] == "Standard"
|
assert patched["tuning"] == "Standard"
|
||||||
|
|
||||||
|
|
||||||
|
# ── freqs_to_midis (the /api/tunings tuningMidis inverse) ────────────────────
|
||||||
|
|
||||||
|
def test_freqs_to_midis_round_trips_every_builtin_at_440():
|
||||||
|
from tunings import freqs_to_midis
|
||||||
|
for key, presets in TUNING_PRESET_MIDIS.items():
|
||||||
|
for name, midis in presets.items():
|
||||||
|
assert freqs_to_midis(open_midis_to_freqs(midis)) == midis, f"{key}/{name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_freqs_to_midis_round_trips_at_nonstandard_reference():
|
||||||
|
# The consumer footgun this exists to kill: frequencies served at a 432/450
|
||||||
|
# reference must recover the SAME integer midis when inverted at that
|
||||||
|
# reference (client-side log2-at-440 reconstruction drifts here).
|
||||||
|
from tunings import freqs_to_midis
|
||||||
|
for ref in (430.0, 432.0, 444.0, 450.0):
|
||||||
|
for midis in (TUNING_PRESET_MIDIS["guitar-8"]["Standard"], TUNING_PRESET_MIDIS["bass-5"]["Standard"]):
|
||||||
|
freqs = open_midis_to_freqs(midis, ref)
|
||||||
|
assert freqs_to_midis(freqs, ref) == midis, f"ref={ref}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_freqs_to_midis_rejects_garbage():
|
||||||
|
from tunings import freqs_to_midis
|
||||||
|
assert freqs_to_midis([82.41, 0]) is None # non-positive
|
||||||
|
assert freqs_to_midis([82.41, "x"]) is None # non-numeric
|
||||||
|
assert freqs_to_midis([]) == [] # vacuously fine
|
||||||
|
|||||||
Reference in New Issue
Block a user