({});
const closeRef = useRef(onClose);
closeRef.current = onClose;
@@ -58,7 +82,7 @@ export default function ModalFrame({
window.removeEventListener('resize', update);
};
}, [open]);
- if (!isAndroid)
+ if (!isAndroid && !managedFocus)
return open
? createPortal(
!value && onClose()}>
-
+
e.preventDefault()}
+ onOpenAutoFocus={(e) => {
+ if (isAndroid) e.preventDefault();
+ else if (e.target instanceof HTMLElement) {
+ const initial = e.target.querySelector('[data-initial-focus]');
+ if (initial) {
+ e.preventDefault();
+ initial.focus();
+ }
+ }
+ }}
+ onCloseAutoFocus={(e) => {
+ if (!isAndroid) {
+ e.preventDefault();
+ if (returnFocus.current?.isConnected) returnFocus.current.focus();
+ }
+ }}
>
{label || tr('选项')}
{children}
diff --git a/src/styles/app.css b/src/styles/app.css
index 9f43947..4cba400 100644
--- a/src/styles/app.css
+++ b/src/styles/app.css
@@ -8,3 +8,4 @@
@import './rehearsal-reset.css' layer(component-reset);
@import './rehearsal-design.css' layer(design);
@import './rehearsal.css' layer(integration);
+@import './settings.css' layer(integration);
diff --git a/src/styles/settings.css b/src/styles/settings.css
new file mode 100644
index 0000000..342d6a3
--- /dev/null
+++ b/src/styles/settings.css
@@ -0,0 +1,818 @@
+/* Settings use the existing rehearsal theme; this file owns no replacement palette. */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--line-strong) var(--surface-1);
+}
+/* Native drag regions can intercept clicks even through a modal, especially after zoom. */
+html[data-desktop-modal='open'] :is(.nr-ph.player-header, .library-header, .nr-nav-brand),
+.managed-dialog,
+.desktop-dialog-overlay,
+.managed-dialog :is(button, input, select, summary, a) {
+ -webkit-app-region: no-drag;
+ app-region: no-drag;
+}
+.desktop-dialog-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ background: var(--scrim);
+}
+.managed-dialog {
+ position: fixed;
+ z-index: 101;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+.modal.settings-workspace-dialog {
+ inset: 0;
+ width: 100%;
+ height: 100dvh;
+ max-width: none;
+ max-height: none;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ transform: none;
+ overflow: hidden;
+ background: var(--ground);
+ color: var(--text);
+ box-shadow: none;
+}
+.settings-workspace {
+ height: 100%;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ font: 14px/1.55 var(--font-ui);
+}
+.settings-workspace button,
+.settings-workspace .nr-btn,
+.settings-confirm button,
+.onboarding-guide button {
+ background: var(--surface-2);
+ color: var(--text);
+ border-color: var(--line-strong);
+}
+.settings-workspace button.primary,
+.settings-confirm button.primary,
+.onboarding-guide button.primary {
+ background: var(--accent);
+ color: var(--accent-ink);
+ border-color: var(--accent);
+}
+.settings-workspace button:not(:disabled):hover,
+.settings-confirm button:not(:disabled):hover,
+.onboarding-guide button:not(:disabled):hover {
+ background: var(--surface-3);
+}
+.settings-workspace button.primary:not(:disabled):hover,
+.settings-confirm button.primary:not(:disabled):hover,
+.onboarding-guide button.primary:not(:disabled):hover {
+ background: var(--accent-strong);
+}
+.settings-header {
+ user-select: none;
+ display: grid;
+ grid-template-columns: 170px minmax(0, 1fr);
+ gap: 24px;
+ align-items: center;
+ padding: 22px 150px 20px 24px;
+ border-bottom: 1px solid var(--line);
+ background: var(--surface-1);
+}
+.settings-header > div {
+ -webkit-app-region: drag;
+ app-region: drag;
+}
+.settings-header h1 {
+ margin: 0 0 3px;
+ font: 600 26px/1.25 var(--font-display);
+}
+.settings-header p {
+ margin: 0;
+ font-size: 13px;
+ color: var(--text-2);
+}
+.settings-header button {
+ width: fit-content;
+}
+.settings-layout {
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr);
+ min-height: 0;
+}
+.settings-navigation {
+ padding: 24px 14px;
+ border-right: 1px solid var(--line);
+ background: var(--surface-1);
+ overflow-y: auto;
+}
+.settings-search {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-s);
+ padding: 0 9px;
+ margin-bottom: 20px;
+ background: var(--surface-2);
+ color: var(--text-2);
+}
+.settings-search input {
+ width: 100%;
+ min-width: 0;
+ padding: 11px 0;
+ border: 0;
+ background: none;
+ color: var(--text);
+ outline: none;
+ font-size: 13px;
+}
+.settings-search:focus-within {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+.settings-search input::-webkit-search-cancel-button {
+ display: none;
+}
+.settings-search button {
+ width: 25px;
+ height: 28px;
+ padding: 0;
+ flex: none;
+ border: 0;
+ background: none;
+}
+.settings-navigation nav {
+ display: grid;
+ gap: 5px;
+}
+.settings-navigation nav button {
+ justify-content: flex-start;
+ padding: 12px;
+ border: 1px solid transparent;
+ background: transparent;
+ color: var(--text-2);
+ text-align: left;
+}
+.settings-navigation nav button:hover {
+ background: var(--surface-2);
+ color: var(--text);
+}
+.settings-navigation nav button[aria-current] {
+ color: var(--accent);
+ background: var(--accent-soft);
+ border-color: var(--accent-line);
+}
+.settings-autosave {
+ display: block;
+ margin: 28px 12px 0;
+ color: var(--text-2);
+ font-size: 12px;
+ line-height: 1.7;
+}
+.settings-mobile-category {
+ display: none;
+}
+.settings-content {
+ min-width: 0;
+ overflow-y: auto;
+ padding: 28px clamp(20px, 4vw, 64px) 48px;
+ scrollbar-gutter: stable;
+}
+.settings-panel {
+ max-width: 1000px;
+ margin: 0 auto 32px;
+}
+.settings-workspace [hidden],
+.settings-panel[hidden] {
+ display: none;
+}
+.settings-panel-heading {
+ margin: 0 0 23px;
+}
+.settings-panel-heading h2 {
+ font: 600 21px/1.4 var(--font-ui);
+ margin: 0 0 6px;
+}
+.settings-panel-heading p {
+ color: var(--text-2);
+ margin: 0;
+ font-size: 14px;
+}
+.settings-stack {
+ display: grid;
+ gap: 20px;
+}
+.settings-card,
+.settings-workspace .appearance-settings {
+ border: 1px solid var(--line);
+ border-radius: var(--r-l);
+ background: var(--surface-1);
+ padding: 22px;
+ min-width: 0;
+}
+.settings-card h3,
+.settings-workspace .appearance-settings h3 {
+ font-size: 16px;
+ margin-bottom: 16px;
+}
+.settings-card p {
+ font-size: 14px;
+ line-height: 1.7;
+}
+.settings-card .muted,
+.settings-workspace .muted {
+ color: var(--text-2);
+}
+.settings-card-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 14px;
+}
+.settings-card-heading h3 {
+ margin: 0;
+}
+.settings-workspace .appearance-settings > div:first-of-type {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+}
+.settings-workspace .appearance-settings label {
+ min-width: 0;
+ color: var(--text-2);
+}
+.settings-workspace .appearance-settings select {
+ width: 100%;
+ min-width: 0;
+ margin-top: 8px;
+}
+.settings-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(170px, 310px);
+ gap: 24px;
+ align-items: center;
+ padding: 16px 0;
+ border-top: 1px solid var(--line);
+}
+.settings-row strong {
+ font-weight: 500;
+}
+.settings-row span > small,
+.settings-cleanup-row span > small {
+ display: block;
+ font-size: 12px;
+ color: var(--text-2);
+ line-height: 1.7;
+ margin-top: 4px;
+}
+.settings-row input[type='checkbox'] {
+ justify-self: end;
+ width: 19px;
+ height: 19px;
+}
+.settings-row :is(select, input) {
+ min-width: 0;
+ max-width: 100%;
+}
+.settings-row button {
+ justify-self: end;
+}
+.settings-fieldset {
+ border: 0;
+ padding: 0;
+ margin: 0;
+ min-width: 0;
+}
+.settings-fieldset > button {
+ margin-top: 12px;
+}
+.settings-section-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 15px;
+ flex-wrap: wrap;
+}
+.settings-section-footer small,
+.settings-card > small {
+ color: var(--text-2);
+ font-size: 12px;
+}
+.settings-error {
+ color: var(--danger);
+ overflow-wrap: anywhere;
+}
+.settings-result {
+ padding: 14px 18px;
+ border: 1px solid var(--line);
+ border-radius: var(--r-s);
+ background: var(--surface-1);
+}
+.settings-readout {
+ padding: 10px 0 0;
+ color: var(--text-2);
+}
+.settings-project-defaults {
+ border-top: 1px solid var(--line);
+ margin-top: 16px;
+ padding-top: 16px;
+}
+.settings-runtime-check {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.settings-runtime-check small {
+ color: var(--text-2);
+}
+.settings-maintenance {
+ border-top: 1px solid var(--line);
+ margin-top: 20px;
+ padding-top: 4px;
+}
+.settings-maintenance .download-settings {
+ max-width: 100%;
+}
+.settings-runtime-details {
+ margin-top: 18px;
+ border-top: 1px solid var(--line);
+ padding-top: 12px;
+}
+.settings-runtime-details summary {
+ cursor: pointer;
+ color: var(--text-2);
+ font-size: 13px;
+}
+.settings-install-log {
+ max-height: 160px;
+ overflow: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font: 12px/1.7 var(--font-mono);
+ color: var(--text-2);
+ padding: 14px;
+ background: var(--surface-2);
+ border-radius: var(--r-s);
+}
+.settings-status {
+ padding: 4px 10px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-s);
+ font-size: 12px;
+ color: var(--warn);
+}
+.settings-status.is-ready {
+ color: var(--ok);
+}
+.settings-storage-list {
+ min-height: 100px;
+}
+.settings-storage-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 92px 40px;
+ align-items: center;
+ gap: 15px;
+ padding: 16px 0;
+ border-top: 1px solid var(--line);
+}
+.settings-storage-row strong {
+ font-weight: 500;
+}
+.settings-path {
+ display: block;
+ margin-top: 5px;
+ font: 12px/1.7 var(--font-mono);
+ color: var(--text-2);
+ overflow-wrap: anywhere;
+ user-select: text;
+}
+.settings-byte-count {
+ font: 13px/1.5 var(--font-mono);
+ text-align: right;
+ white-space: nowrap;
+ color: var(--text-2);
+}
+.settings-folder-button {
+ padding: 0;
+ width: 38px;
+ height: 38px;
+}
+.settings-cleanup-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 20px;
+ padding: 18px 0;
+ border-top: 1px solid var(--line);
+}
+.settings-cleanup-row button {
+ flex-shrink: 0;
+}
+.settings-confirm {
+ width: min(540px, calc(100vw - 28px));
+ max-height: calc(100dvh - 28px);
+ overflow-y: auto;
+}
+.settings-confirm .modal-actions {
+ position: sticky;
+ bottom: -24px;
+ background: var(--panel);
+ padding-top: 14px;
+}
+.settings-action-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin: 18px 0;
+}
+.settings-version {
+ font: 20px/1.5 var(--font-mono);
+}
+.settings-workspace .model-source-details {
+ margin: 10px 0;
+ color: var(--text-2);
+}
+.model-source-details summary {
+ cursor: pointer;
+ font-size: 12px;
+ padding: 6px 0;
+}
+.settings-workspace .model-source-links {
+ overflow-wrap: anywhere;
+}
+.settings-search-count {
+ max-width: 1000px;
+ margin: 0 auto 20px;
+ color: var(--text-2);
+}
+.settings-empty {
+ max-width: 600px;
+ margin: 80px auto;
+ text-align: center;
+ color: var(--text-2);
+}
+.settings-empty h2 {
+ color: var(--text);
+ margin-top: 20px;
+}
+.help-tooltip {
+ position: fixed;
+ z-index: 10020;
+ pointer-events: none;
+ width: max-content;
+ max-width: min(320px, calc(100vw - 24px));
+ padding: 10px 13px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-s);
+ background: var(--surface-3);
+ color: var(--text);
+ box-shadow: var(--shadow-pop);
+ font: 13px/1.6 var(--font-ui);
+ overflow-wrap: anywhere;
+}
+.modal.onboarding-dialog {
+ width: min(960px, calc(100vw - 48px));
+ height: min(700px, calc(100dvh - 48px));
+ max-width: none;
+ max-height: none;
+ padding: 0;
+ overflow: hidden;
+ background: var(--surface-1);
+}
+.onboarding-guide {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ height: 100%;
+}
+.onboarding-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 22px 28px;
+ border-bottom: 1px solid var(--line);
+}
+.onboarding-heading .eyebrow {
+ font-size: 10px;
+}
+.onboarding-heading h2 {
+ font: 600 24px/1.4 var(--font-ui);
+ margin: 3px 0 0;
+}
+.onboarding-counter {
+ color: var(--text-3);
+ font: 13px var(--font-mono);
+}
+.onboarding-body {
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+}
+.onboarding-layout {
+ display: grid;
+ grid-template-columns: 205px minmax(0, 1fr);
+ flex: 1 0 auto;
+ min-height: 0;
+}
+.onboarding-steps {
+ list-style: none;
+ padding: 24px 12px;
+ margin: 0;
+ border-right: 1px solid var(--line);
+ background: var(--surface-2);
+}
+.onboarding-steps li + li {
+ margin-top: 6px;
+}
+.onboarding-steps button {
+ width: 100%;
+ min-height: 52px;
+ display: flex;
+ justify-content: flex-start;
+ text-align: left;
+ gap: 10px;
+ font-size: 13px;
+ padding: 12px;
+ background: transparent;
+ border: 1px solid transparent;
+}
+.onboarding-steps button span {
+ font: 11px var(--font-mono);
+ color: var(--text-3);
+}
+.onboarding-steps button svg {
+ flex: none;
+}
+.onboarding-steps button[aria-current] {
+ border-color: var(--accent-line);
+ color: var(--accent);
+ background: var(--accent-soft);
+}
+.onboarding-content {
+ padding: 24px 30px 28px;
+ min-width: 0;
+}
+.onboarding-current-label {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--text-2);
+ font-size: 12px;
+ margin-top: 22px;
+}
+.onboarding-content h3 {
+ font: 600 23px/1.5 var(--font-ui);
+ outline: none;
+ margin: 7px 0 14px;
+}
+.onboarding-content p {
+ font-size: 14px;
+ line-height: 1.8;
+ margin: 0;
+ color: var(--text-2);
+}
+.onboarding-content .notice {
+ margin-top: 17px;
+ border: 0;
+ border-left: 2px solid var(--accent);
+ border-radius: 0 var(--r-s) var(--r-s) 0;
+ padding: 10px 14px;
+ background: var(--surface-2);
+ color: var(--text-2);
+ font-size: 13px;
+}
+.onboarding-content > button {
+ margin-top: 16px;
+}
+.onboarding-visual {
+ position: relative;
+ margin: 0;
+ border: 1px solid var(--line);
+ border-radius: var(--r-s);
+ background: var(--surface-2);
+ overflow: hidden;
+}
+.onboarding-visual svg {
+ display: block;
+ width: 100%;
+ height: 158px;
+}
+.onboarding-visual figcaption {
+ position: absolute;
+ right: 12px;
+ bottom: 6px;
+ color: var(--text-3);
+ font-size: 10px;
+}
+.onboarding-visual text {
+ fill: var(--text-2);
+ font: 13px var(--font-ui);
+}
+.guide-sheet {
+ fill: var(--surface-1);
+ stroke: var(--line-strong);
+ stroke-width: 1.5;
+}
+.guide-panel {
+ fill: var(--surface-3);
+ stroke: var(--line);
+}
+.guide-line {
+ fill: none;
+ stroke: var(--text-3);
+ stroke-width: 2;
+ stroke-linecap: round;
+}
+.guide-accent {
+ fill: none;
+ stroke: var(--accent);
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.guide-loop {
+ fill: var(--accent-soft);
+ stroke: var(--accent-line);
+}
+.guide-record {
+ fill: var(--accent);
+}
+.onboarding-actions {
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+ align-items: center;
+ padding: 18px 28px;
+ border-top: 1px solid var(--line);
+ background: var(--surface-1);
+}
+.onboarding-actions > div {
+ display: flex;
+ gap: 8px;
+}
+.onboarding-actions > button {
+ background: transparent;
+}
+.onboarding-reference {
+ flex: none;
+ border-top: 1px solid var(--line);
+ padding: 16px 28px;
+}
+.onboarding-reference summary {
+ cursor: pointer;
+ color: var(--text-2);
+ font-size: 12px;
+}
+.onboarding-reference .usage-help {
+ margin-top: 18px;
+}
+@media (max-width: 760px) {
+ .modal.onboarding-dialog {
+ width: calc(100vw - 24px);
+ height: calc(100dvh - 24px);
+ }
+ .onboarding-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .onboarding-steps {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 4px;
+ padding: 12px 18px;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+ .onboarding-steps li + li {
+ margin-top: 0;
+ }
+ .onboarding-steps button {
+ min-height: 42px;
+ padding: 9px;
+ }
+ .onboarding-heading {
+ padding: 16px 20px;
+ }
+ .onboarding-content {
+ padding: 20px;
+ }
+ .onboarding-visual svg {
+ height: 130px;
+ }
+ .onboarding-actions {
+ padding: 14px 20px;
+ }
+}
+:is(.settings-workspace, .settings-confirm, .onboarding-guide)
+ :is(button, input, select, summary):focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+.toast {
+ z-index: 102;
+}
+@media (max-width: 800px) {
+ .settings-header {
+ grid-template-columns: auto minmax(0, 1fr);
+ padding: 38px 18px 16px;
+ gap: 16px;
+ }
+ .settings-header h1 {
+ font-size: 23px;
+ }
+ .settings-header p {
+ display: none;
+ }
+ .settings-layout {
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+ .settings-navigation {
+ padding: 12px 18px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(140px, 1fr);
+ gap: 12px;
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ overflow: visible;
+ }
+ .settings-navigation nav,
+ .settings-autosave {
+ display: none;
+ }
+ .settings-search {
+ margin: 0;
+ }
+ .settings-mobile-category {
+ display: grid;
+ gap: 3px;
+ font-size: 11px;
+ color: var(--text-2);
+ }
+ .settings-mobile-category select {
+ padding: 8px;
+ width: 100%;
+ min-width: 0;
+ }
+ .settings-content {
+ padding: 22px 18px 32px;
+ }
+ .settings-row {
+ gap: 18px;
+ }
+}
+@media (max-width: 520px) {
+ .settings-navigation {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ }
+ .settings-mobile-category {
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 16px;
+ }
+ .settings-card,
+ .settings-workspace .appearance-settings {
+ padding: 16px;
+ }
+ .settings-workspace .appearance-settings > div:first-of-type {
+ grid-template-columns: 1fr;
+ }
+ .settings-row {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+ .settings-row select {
+ width: 100%;
+ }
+ .settings-row input[type='checkbox'] {
+ justify-self: start;
+ }
+ .settings-cleanup-row {
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+ .settings-storage-row {
+ grid-template-columns: minmax(0, 1fr) 75px 36px;
+ gap: 8px;
+ }
+ .onboarding-steps {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .onboarding-actions {
+ flex-wrap: wrap;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .settings-workspace *,
+ .help-tooltip,
+ .onboarding-guide * {
+ transition: none !important;
+ animation: none !important;
+ }
+}
diff --git a/tests/audio-engine.test.cjs b/tests/audio-engine.test.cjs
new file mode 100644
index 0000000..b68ddfa
--- /dev/null
+++ b/tests/audio-engine.test.cjs
@@ -0,0 +1,150 @@
+const test = require('node:test'),
+ assert = require('node:assert/strict');
+const fs = require('node:fs'),
+ path = require('node:path'),
+ Module = require('node:module'),
+ vm = require('node:vm'),
+ ts = require('typescript');
+function modules(t, initial = {}) {
+ const previous = global.localStorage,
+ values = new Map(Object.entries(initial));
+ let failing = '';
+ global.localStorage = {
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => {
+ if (key === failing) throw new Error('disk full');
+ values.set(key, String(value));
+ },
+ removeItem: (key) => values.delete(key),
+ };
+ t.after(() => {
+ if (previous === undefined) delete global.localStorage;
+ else global.localStorage = previous;
+ });
+ const cache = new Map();
+ const load = (file) => {
+ const filename = path.resolve(file);
+ if (cache.has(filename)) return cache.get(filename).exports;
+ const mod = new Module(filename, module);
+ mod.filename = filename;
+ mod.paths = Module._nodeModulePaths(path.dirname(filename));
+ cache.set(filename, mod);
+ const original = mod.require.bind(mod);
+ mod.require = (name) =>
+ name === './device-preferences' ? load('src/audio/device-preferences.ts') : original(name);
+ mod._compile(
+ ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
+ }).outputText,
+ filename,
+ );
+ return mod.exports;
+ };
+ return {
+ engine: load('src/audio/engine-preferences.ts'),
+ audio: load('src/audio/device-preferences.ts'),
+ values,
+ fail: (key) => {
+ failing = key;
+ },
+ };
+}
+test('engine preferences validate output systems, supported rates and ASIO driver selection', (t) => {
+ const { engine } = modules(t, { 'immerse.audio.engine': '{"backend":"invalid","sampleRate":0}' });
+ assert.deepEqual(engine.getEnginePreferences(), engine.DEFAULT_ENGINE);
+ for (const sampleRate of [44100, 48000, 88200, 96000])
+ assert.equal(
+ engine.validateEngine({ backend: 'web-audio', deviceId: 'default', sampleRate }).sampleRate,
+ sampleRate,
+ );
+ for (const value of [
+ null,
+ { backend: 'asio', deviceId: 'default', sampleRate: 48000 },
+ { backend: 'web-audio', deviceId: 'default', sampleRate: 12345 },
+ ])
+ assert.throws(() => engine.validateEngine(value), /无效/);
+});
+test('calibration is separated by engine/device/rate and explicit project corrections survive', (t) => {
+ const base = 'immerse.recording.latency.default|default';
+ const { engine, audio, values } = modules(t, {
+ [base]: '23',
+ [base + '|asio:Realtek ASIO:48000']: '64',
+ });
+ assert.equal(audio.effectiveRecordingSettings({ kind: 'creation' }).latencyMs, 23);
+ engine.setEnginePreferences({ backend: 'asio', deviceId: 'Realtek ASIO', sampleRate: 48000 });
+ assert.equal(audio.getAudioDefaults().latencyMs, 64);
+ assert.equal(
+ audio.effectiveRecordingSettings({ kind: 'creation', recordingSettings: { latencyMs: 12 } })
+ .latencyMs,
+ 12,
+ );
+ audio.setAudioDefaults({ latencyMs: 81 });
+ assert.equal(values.get(base), '23');
+ assert.equal(values.get(base + '|asio:Realtek ASIO:48000'), '81');
+ engine.setEnginePreferences({ ...engine.DEFAULT_ENGINE, sampleRate: 44100 });
+ assert.equal(audio.getAudioDefaults().latencyMs, 0);
+ engine.setEnginePreferences({ ...engine.DEFAULT_ENGINE });
+ assert.equal(audio.getAudioDefaults().latencyMs, 23);
+});
+test('failed engine persistence keeps the selected system and calibration route unchanged', (t) => {
+ const { engine, audio, fail } = modules(t);
+ const key = audio.calibrationKey('input', 'output');
+ fail('immerse.audio.engine');
+ assert.throws(
+ () =>
+ engine.setEnginePreferences({
+ backend: 'wasapi-exclusive',
+ deviceId: 'default',
+ sampleRate: 96000,
+ }),
+ /disk full/,
+ );
+ assert.deepEqual(engine.getEnginePreferences(), engine.DEFAULT_ENGINE);
+ assert.equal(audio.calibrationKey('input', 'output'), key);
+});
+test('PCM bridge emits interleaved bounded stereo, silences browser output and discards suspended partial blocks', () => {
+ let Processor;
+ const packets = [];
+ class Base {
+ constructor() {
+ this.port = {
+ postMessage: (data, transfer) => {
+ assert.equal(transfer[0], data);
+ packets.push(data);
+ },
+ onmessage: null,
+ };
+ }
+ }
+ vm.runInNewContext(fs.readFileSync('src/audio/native-output-processor.js', 'utf8'), {
+ AudioWorkletProcessor: Base,
+ registerProcessor: (name, type) => {
+ assert.equal(name, 'immerse-native-output');
+ Processor = type;
+ },
+ });
+ const processor = new Processor();
+ const left = new Float32Array(128).fill(0.25),
+ right = new Float32Array(128).fill(-0.5);
+ const output = new Float32Array(128).fill(1);
+ processor.process([[left, right]], [[output]]);
+ assert.ok(output.every((v) => v === 0));
+ assert.equal(packets.length, 0);
+ processor.port.onmessage({ data: { enabled: true } });
+ left[1] = NaN;
+ right[1] = 2;
+ for (let i = 0; i < 4; i++) processor.process([[left, right]], [[output]]);
+ const pcm = new Float32Array(packets[0]);
+ assert.equal(pcm.length, 1024);
+ assert.equal(pcm[0], 0.25);
+ assert.equal(pcm[1], -0.5);
+ assert.equal(pcm[2], 0);
+ assert.ok(Math.abs(pcm[3] - 0.98) < 1e-6);
+ processor.process([[left, right]], [[output]]);
+ processor.port.onmessage({ data: { enabled: false } });
+ processor.port.onmessage({ data: { enabled: true } });
+ for (let i = 0; i < 3; i++) processor.process([[left, right]], [[output]]);
+ assert.equal(packets.length, 1);
+ processor.process([[left, right]], [[output]]);
+ assert.equal(packets.length, 2);
+});
diff --git a/tests/audio-preferences.test.cjs b/tests/audio-preferences.test.cjs
new file mode 100644
index 0000000..f08d79f
--- /dev/null
+++ b/tests/audio-preferences.test.cjs
@@ -0,0 +1,110 @@
+const test = require('node:test'),
+ assert = require('node:assert/strict');
+const fs = require('node:fs'),
+ path = require('node:path'),
+ Module = require('node:module'),
+ ts = require('typescript');
+function load(t, initial = {}) {
+ const previous = global.localStorage,
+ values = new Map(Object.entries(initial));
+ let fail = false,
+ failingKey = '';
+ global.localStorage = {
+ getItem: (k) => values.get(k) ?? null,
+ setItem: (k, v) => {
+ if (fail || k === failingKey) throw new Error('disk full');
+ values.set(k, String(v));
+ },
+ removeItem: (k) => values.delete(k),
+ };
+ t.after(() => {
+ if (previous === undefined) delete global.localStorage;
+ else global.localStorage = previous;
+ });
+ const filename = path.resolve('src/audio/device-preferences.ts'),
+ mod = new Module(filename, module);
+ mod.filename = filename;
+ mod.paths = Module._nodeModulePaths(path.dirname(filename));
+ mod._compile(
+ ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
+ }).outputText,
+ filename,
+ );
+ return {
+ api: mod.exports,
+ values,
+ fail: () => {
+ fail = true;
+ },
+ failKey: (key) => {
+ failingKey = key;
+ },
+ };
+}
+test('global audio defaults seed new projects and preserve explicit project choices', (t) => {
+ const { api } = load(t, { 'immerse.recording.latency.piano|speaker': '88' });
+ api.setAudioDefaults({
+ inputDeviceId: 'usb',
+ outputDeviceId: 'phones',
+ channel: 'left',
+ latencyMs: 35,
+ });
+ const inherited = api.effectiveRecordingSettings({ kind: 'creation' });
+ assert.equal(inherited.inputDeviceId, 'usb');
+ assert.equal(inherited.outputDeviceId, 'phones');
+ const explicit = api.effectiveRecordingSettings({
+ kind: 'practice',
+ practiceMode: 'mixed',
+ recordingSettings: {
+ mode: 'instrument',
+ inputDeviceId: 'piano',
+ outputDeviceId: 'speaker',
+ channel: 'stereo',
+ latencyMs: 12,
+ },
+ });
+ assert.equal(explicit.inputDeviceId, 'piano');
+ assert.equal(explicit.outputDeviceId, 'speaker');
+ assert.equal(explicit.mode, 'mixed');
+ assert.equal(explicit.latencyMs, 12);
+ api.rememberCalibration(explicit, 160);
+ assert.equal(
+ api.effectiveRecordingSettings({ kind: 'practice', recordingSettings: explicit }).latencyMs,
+ 12,
+ );
+});
+test('calibration uses the existing per-device-pair storage key', (t) => {
+ const { api } = load(t, { 'immerse.recording.latency.usb|phones': '42' });
+ api.setAudioDefaults({ inputDeviceId: 'usb', outputDeviceId: 'phones' });
+ assert.equal(api.getAudioDefaults().latencyMs, 42);
+ api.setAudioDefaults({ outputDeviceId: 'speaker' });
+ assert.equal(api.getAudioDefaults().latencyMs, 0);
+ api.rememberCalibration(
+ { inputDeviceId: 'usb', outputDeviceId: 'speaker', channel: 'left', latencyMs: 0 },
+ -20,
+ );
+ assert.equal(api.effectiveRecordingSettings({ kind: 'creation' }).latencyMs, -20);
+});
+test('failed default persistence restores the previous device-pair calibration', (t) => {
+ const key = 'immerse.recording.latency.default|default';
+ const { api, values, failKey } = load(t, { [key]: '12' });
+ failKey('immerse.audio.defaults');
+ assert.throws(() => api.setAudioDefaults({ latencyMs: 220 }), /disk full/);
+ assert.equal(api.getAudioDefaults().latencyMs, 0);
+ assert.equal(values.get(key), '12');
+ values.delete(key);
+ assert.throws(() => api.setAudioDefaults({ latencyMs: 220 }), /disk full/);
+ assert.equal(values.has(key), false);
+});
+test('invalid defaults and failed persistence leave the committed audio settings unchanged', (t) => {
+ const { api, fail } = load(t, {
+ 'immerse.audio.defaults': '{"channel":"wrong","latencyMs":99999}',
+ });
+ assert.deepEqual(api.getAudioDefaults(), api.DEFAULT_AUDIO);
+ assert.throws(() => api.setAudioDefaults({ latencyMs: NaN }), /录音设置无效/);
+ assert.throws(() => api.setAudioDefaults({ latencyMs: 2001 }), /录音设置无效/);
+ fail();
+ assert.throws(() => api.setAudioDefaults({ outputDeviceId: 'headphones' }), /disk full/);
+ assert.equal(api.getAudioDefaults().outputDeviceId, 'default');
+});
diff --git a/tests/installation-audit.test.cjs b/tests/installation-audit.test.cjs
new file mode 100644
index 0000000..25764da
--- /dev/null
+++ b/tests/installation-audit.test.cjs
@@ -0,0 +1,175 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const http = require('node:http');
+const { localFailure, errorMessage } = require('../desktop/install-errors.cjs');
+const { checkSpace, requiredSpace } = require('../desktop/install-space.cjs');
+const { installPackages } = require('../desktop/package-index.cjs');
+const { createGpuProbe } = require('../desktop/gpu-health.cjs');
+const { validateRoot } = require('../desktop/locations.cjs');
+function area(t) {
+ const parent = path.resolve('.runtime/test-workspaces');
+ fs.mkdirSync(parent, { recursive: true });
+ const folder = fs.mkdtempSync(path.join(parent, 'installer-audit-'));
+ t.after(() => fs.rmSync(folder, { recursive: true, force: true }));
+ return folder;
+}
+test('disk and permission failures retain their cause and are not retried on another package index', async () => {
+ for (const input of [
+ new Error('failed to copy torch.dll: disk full (os error 112)'),
+ Object.assign(new Error('write denied'), { code: 'EACCES' }),
+ ]) {
+ let attempts = 0;
+ await assert.rejects(
+ installPackages(
+ 'uv',
+ [],
+ {},
+ () => {},
+ async () => {
+ attempts++;
+ throw input;
+ },
+ async () => 'https://first.invalid/',
+ ),
+ (error) => {
+ assert.ok(['ENOSPC', 'EACCES'].includes(error.code));
+ assert.equal(error.cause, input);
+ assert.ok(!/网络|proxy/.test(error.message));
+ assert.ok(!/[\u4e00-\u9fff]/.test(errorMessage(error, true)));
+ return true;
+ },
+ );
+ assert.equal(attempts, 1);
+ }
+ assert.equal(localFailure(new Error('HTTP 403 access denied')), null);
+ assert.equal(
+ localFailure(
+ new Error('wrapped', { cause: Object.assign(new Error('full'), { code: 'ENOSPC' }) }),
+ ).code,
+ 'ENOSPC',
+ );
+});
+test('space budgets account for unpacked environments and allow an already-ready matching environment', () => {
+ const io = {
+ existsSync: () => true,
+ readFileSync: () => JSON.stringify({ recipe: 3, mode: 'cpu', flavor: 'cpu' }),
+ statfsSync: () => ({ bsize: 1, bavail: 512 * 1024 ** 2 }),
+ };
+ assert.ok(checkSpace(path.resolve('fixture'), 'cpu', [], io).available > 0);
+ assert.throws(
+ () => checkSpace(path.resolve('fixture'), 'cuda', [], io),
+ (error) => error.code === 'ENOSPC' && /GiB/.test(error.message),
+ );
+ assert.ok(requiredSpace(path.resolve('fixture'), 'cuda', [], io) > 1024 ** 3);
+ const missing = { ...io, existsSync: (value) => !String(value).endsWith('verified.json') };
+ assert.equal(
+ requiredSpace(path.resolve('fixture'), 'cpu', ['model'], missing),
+ requiredSpace(path.resolve('fixture'), 'cpu', [], missing) + 1024 ** 3,
+ );
+});
+test('space failure occurs before runtime download or committing new installation configuration', async (t) => {
+ const folder = area(t),
+ app = path.join(folder, 'program'),
+ data = path.join(folder, 'data');
+ fs.mkdirSync(app);
+ const configuration = path.join(app, 'installation.json');
+ fs.writeFileSync(configuration, JSON.stringify({ schemaVersion: 1, dataRoot: data }));
+ const before = fs.readFileSync(configuration);
+ let downloads = 0;
+ await assert.rejects(
+ require('../desktop/installation.cjs').prepare(
+ {
+ installRoot: app,
+ dataRoot: data,
+ mode: 'cpu',
+ models: [],
+ allowSystemDrive: true,
+ },
+ {
+ preflight: () => {
+ throw Object.assign(new Error('no space'), { code: 'ENOSPC' });
+ },
+ runtime: async () => {
+ downloads++;
+ },
+ },
+ ),
+ (error) => error.code === 'ENOSPC',
+ );
+ assert.equal(downloads, 0);
+ assert.deepEqual(fs.readFileSync(configuration), before);
+});
+test(
+ 'Windows non-system paths need no consent, while system-drive data is rejected without consent',
+ { skip: process.platform !== 'win32' },
+ () => {
+ const system = process.env.SystemDrive || 'C:';
+ const other = system.toUpperCase() === 'Z:' ? 'Y:' : 'Z:';
+ const app = other + '\\AuditProgram';
+ assert.equal(validateRoot(other + '\\AuditData', app, false), other + '\\AuditData');
+ assert.throws(() => validateRoot(system + '\\AuditData', app, false), /系统盘/);
+ assert.equal(validateRoot(system + '\\AuditData', app, true), system + '\\AuditData');
+ assert.throws(
+ () => validateRoot(other + '\\Parent', other + '\\Parent\\Program', false),
+ /独立/,
+ );
+ },
+);
+test('GPU readiness requires a real kernel, not only a visible device', async () => {
+ const good = createGpuProbe((_exe, args, options, done) => {
+ assert.match(args[1], /device='cuda'/);
+ assert.match(args[1], /x @ x/);
+ assert.equal(options.windowsHide, true);
+ done(null, 'IMMERSE_GPU_READY\n', '');
+ });
+ assert.equal((await good('python')).ready, true);
+ const unsupported = createGpuProbe((_exe, _args, _options, done) =>
+ done(new Error('failed'), '', 'no kernel image is available'),
+ );
+ const result = await unsupported('python');
+ assert.equal(result.ready, false);
+ assert.match(result.detail, /no kernel image/);
+});
+test('a model write failure stops mirror fallback and preserves the real storage reason', async (t) => {
+ const folder = area(t),
+ destination = path.join(folder, 'model.bin');
+ const server = http.createServer((_request, response) => {
+ response.writeHead(200, { 'Content-Length': 128 });
+ response.end(Buffer.alloc(128, 1));
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ t.after(() => {
+ server.closeAllConnections();
+ server.close();
+ });
+ const originalOpen = fs.openSync,
+ originalWrite = fs.writeSync;
+ let downloadFd;
+ fs.openSync = function (file, ...args) {
+ const fd = originalOpen.call(fs, file, ...args);
+ if (String(file).startsWith(destination)) downloadFd = fd;
+ return fd;
+ };
+ fs.writeSync = function (fd, ...args) {
+ if (fd === downloadFd) throw Object.assign(new Error('disk full'), { code: 'ENOSPC' });
+ return originalWrite.call(fs, fd, ...args);
+ };
+ t.after(() => {
+ fs.openSync = originalOpen;
+ fs.writeSync = originalWrite;
+ });
+ const requests = [];
+ server.on('request', (request) => requests.push(request.url));
+ const origin = 'http://127.0.0.1:' + server.address().port;
+ await assert.rejects(
+ require('../desktop/runtime.cjs').downloadSources(
+ [origin + '/first', origin + '/second'],
+ destination,
+ ),
+ (error) => error.code === 'ENOSPC',
+ );
+ assert.ok(requests.length > 0);
+ assert.ok(!requests.includes('/second'));
+});
diff --git a/tests/native-audio.test.cjs b/tests/native-audio.test.cjs
new file mode 100644
index 0000000..c0ea4a8
--- /dev/null
+++ b/tests/native-audio.test.cjs
@@ -0,0 +1,182 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { EventEmitter } = require('node:events');
+const { PassThrough } = require('node:stream');
+const fs = require('node:fs'),
+ path = require('node:path');
+const { NativeAudio, validate, hostCommand } = require('../desktop/native-audio.cjs');
+const config = { backend: 'wasapi', deviceId: 'default', sampleRate: 48000 };
+function harness(options = {}) {
+ const children = [],
+ events = [];
+ const spawnProcess = (executable, args, settings) => {
+ const child = new EventEmitter();
+ Object.assign(child, {
+ stdout: new PassThrough(),
+ stderr: new PassThrough(),
+ exitCode: null,
+ writes: [],
+ killed: false,
+ ended: false,
+ });
+ const input = new EventEmitter();
+ Object.assign(input, {
+ writableLength: 0,
+ write: (bytes) => {
+ child.writes.push(Buffer.from(bytes));
+ return true;
+ },
+ end: () => {
+ child.ended = true;
+ child.exitCode = 0;
+ setImmediate(() => child.emit('close', 0));
+ },
+ });
+ child.stdin = input;
+ child.kill = () => {
+ child.killed = true;
+ child.exitCode = -1;
+ setImmediate(() => child.emit('close', -1));
+ };
+ children.push({ child, executable, args, settings });
+ setImmediate(() => {
+ if (args.includes('--list')) {
+ child.stdout.write(JSON.stringify({ devices: [], available: true }));
+ child.exitCode = 0;
+ child.emit('close', 0);
+ } else if (options.failure)
+ child.stdout.write(JSON.stringify({ type: 'error', message: options.failure }) + '\n');
+ else
+ child.stdout.write(
+ JSON.stringify({
+ type: 'ready',
+ ...config,
+ deviceSampleRate: 48000,
+ deviceBufferFrames: 480,
+ }) + '\n',
+ );
+ });
+ return child;
+ };
+ const service = new NativeAudio({
+ executable: 'test/dotnet.exe',
+ assembly: 'test/host.dll',
+ spawnProcess,
+ exists: () => true,
+ platform: 'win32',
+ emit: (e) => events.push(e),
+ });
+ return { service, children, events };
+}
+test('native output accepts only supported systems/rates and bounded device identifiers', () => {
+ assert.deepEqual(validate(config), config);
+ for (const input of [
+ null,
+ {},
+ { ...config, backend: 'shell' },
+ { ...config, sampleRate: NaN },
+ { ...config, sampleRate: 96001 },
+ { ...config, deviceId: '' },
+ { ...config, deviceId: '\0' },
+ { ...config, deviceId: 'a'.repeat(2049) },
+ ])
+ assert.throws(() => validate(input), /无效/);
+});
+test('native host paths keep malformed version pointers inside the application cache', (t) => {
+ const parent = path.resolve('.runtime/test-workspaces');
+ fs.mkdirSync(parent, { recursive: true });
+ const root = fs.mkdtempSync(path.join(parent, 'native-audio-path-'));
+ t.after(() => {
+ if (path.dirname(root) !== parent || !path.basename(root).startsWith('native-audio-path-'))
+ throw new Error('Invalid fixture path');
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+ const manifest = path.join(root, '.runtime/native-audio/current.json');
+ fs.mkdirSync(path.dirname(manifest), { recursive: true });
+ fs.writeFileSync(manifest, JSON.stringify({ revision: '../../other' }));
+ assert.equal(
+ hostCommand(root, false).executable,
+ path.join(root, '.runtime/native-audio/versions/unbuilt/dotnet.exe'),
+ );
+ fs.writeFileSync(manifest, JSON.stringify({ revision: 'a'.repeat(20) }));
+ assert.equal(
+ hostCommand(root, false).assembly,
+ path.join(root, '.runtime/native-audio/versions', 'a'.repeat(20), 'ImmerseAudioHost.dll'),
+ );
+ assert.equal(
+ hostCommand(root, true, path.join(root, 'resources')).assembly,
+ path.join(root, 'resources/native-audio/ImmerseAudioHost.dll'),
+ );
+});
+test('native output uses argument-based invocation, ordered PCM and stream-scoped stop', async () => {
+ const { service, children, events } = harness();
+ const device = 'Device "quoted" & unchanged';
+ const first = await service.start({ ...config, deviceId: device });
+ const launched = children[0];
+ assert.equal(launched.settings.windowsHide, true);
+ assert.equal(launched.args[0], 'test/host.dll');
+ assert.equal(JSON.parse(Buffer.from(launched.args[2], 'base64')).deviceId, device);
+ const pcm = new Float32Array([0.25, -0.5, 0, 0]);
+ assert.deepEqual(await service.write({ streamId: first.streamId, samples: pcm.buffer }), {
+ accepted: true,
+ });
+ assert.equal(launched.child.writes[0].readFloatLE(0), 0.25);
+ assert.equal(launched.child.writes[0].readFloatLE(4), -0.5);
+ assert.deepEqual(await service.write({ streamId: 'stale', samples: pcm.buffer }), {
+ accepted: false,
+ stale: true,
+ });
+ await service.stop('stale');
+ assert.equal(launched.child.ended, false);
+ const second = await service.start(config);
+ assert.equal(launched.child.ended, true);
+ await service.stop(first.streamId);
+ assert.equal(children[1].child.ended, false);
+ await service.stop(second.streamId);
+ assert.equal(children[1].child.ended, true);
+ assert.deepEqual(events, []);
+});
+test('native PCM rejects invalid samples, frame boundaries and unbounded buffering', async () => {
+ const { service, children } = harness();
+ const ready = await service.start(config);
+ for (const samples of [
+ new ArrayBuffer(7),
+ new ArrayBuffer(32776),
+ new Float32Array([NaN, 0]).buffer,
+ new Float32Array([Infinity, 0]).buffer,
+ new Float32Array([1.5, 0]).buffer,
+ 'not PCM',
+ ])
+ await assert.rejects(service.write({ streamId: ready.streamId, samples }), /无效/);
+ assert.equal(children[0].child.writes.length, 0);
+ children[0].child.stdin.writableLength = 48000 * 8;
+ await assert.rejects(
+ service.write({ streamId: ready.streamId, samples: new Float32Array(2).buffer }),
+ /积压/,
+ );
+ await service.stop();
+});
+test('driver startup failure closes the owned child and leaves no writable stream', async () => {
+ const { service, children, events } = harness({ failure: 'Driver unavailable' });
+ await assert.rejects(
+ service.start({ ...config, backend: 'asio', deviceId: 'Missing ASIO' }),
+ /Driver unavailable/,
+ );
+ assert.equal(service.session, null);
+ assert.equal(children[0].child.ended, true);
+ assert.ok(events.some((e) => e.scope === 'native-audio' && e.message === 'Driver unavailable'));
+});
+test('unsupported hosts expose compatibility fallback without launching a process', async () => {
+ let launched = false;
+ const service = new NativeAudio({
+ executable: 'none',
+ platform: 'linux',
+ exists: () => true,
+ spawnProcess: () => {
+ launched = true;
+ },
+ });
+ assert.equal((await service.info()).available, false);
+ await assert.rejects(service.start(config), /不支持/);
+ assert.equal(launched, false);
+});
diff --git a/tests/settings-ipc.test.cjs b/tests/settings-ipc.test.cjs
new file mode 100644
index 0000000..924f06b
--- /dev/null
+++ b/tests/settings-ipc.test.cjs
@@ -0,0 +1,79 @@
+const test = require('node:test'),
+ assert = require('node:assert/strict'),
+ fs = require('node:fs'),
+ path = require('node:path');
+const register = require('../desktop/settings-ipc.cjs');
+function setup(t) {
+ const workspace = path.resolve('.runtime/test-workspaces');
+ fs.mkdirSync(workspace, { recursive: true });
+ const root = fs.mkdtempSync(path.join(workspace, 'settings-ipc-'));
+ t.after(() => {
+ assert.equal(path.dirname(root), workspace);
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+ const roots = Object.fromEntries(
+ ['library', 'runtime', 'profile', 'logs'].map((id) => [id, path.join(root, id)]),
+ );
+ Object.values(roots).forEach((p) => fs.mkdirSync(p, { recursive: true }));
+ let scale = 1,
+ copied = '',
+ opened = [];
+ const handlers = new Map();
+ register({
+ handle: (id, fn) => handlers.set(id, fn),
+ app: { getVersion: () => '0.1.12' },
+ shell: {
+ openPath: async (p) => {
+ opened.push(p);
+ return '';
+ },
+ },
+ clipboard: {
+ writeText: (value) => {
+ copied = value;
+ },
+ },
+ getWindow: () => ({
+ webContents: {
+ getZoomFactor: () => scale,
+ setZoomFactor: (value) => {
+ scale = value;
+ },
+ },
+ }),
+ roots,
+ isBusy: () => false,
+ getState: () => ({
+ ready: true,
+ runtimeMode: 'cpu',
+ installing: false,
+ processing: false,
+ modelDownloads: 0,
+ }),
+ });
+ return { handlers, roots, root, scale: () => scale, copied: () => copied, opened };
+}
+test('diagnostic copying contains only local status and known paths, with allowlisted folder opening', async (t) => {
+ const f = setup(t);
+ assert.equal(f.handlers.get('settings-copy-diagnostics')(), true);
+ const summary = JSON.parse(f.copied());
+ assert.equal(summary.version, '0.1.12');
+ assert.equal(summary.ready, true);
+ assert.equal(summary.locations.library, f.roots.library);
+ assert.equal(summary.locations.installLog, path.join(f.root, 'installation.log'));
+ assert.equal(Object.hasOwn(summary, 'projects'), false);
+ await assert.rejects(
+ () => f.handlers.get('settings-open-location')({ id: '../../outside' }),
+ /无效存储/,
+ );
+ await f.handlers.get('settings-open-location')({ id: 'library' });
+ assert.deepEqual(f.opened, [f.roots.library]);
+});
+test('native scale accepts only bounded finite numbers', (t) => {
+ const f = setup(t),
+ change = f.handlers.get('ui-scale');
+ assert.equal(change({ scale: 1.25 }).scale, 1.25);
+ for (const scale of [0, 2, NaN, Infinity, '1.25'])
+ assert.throws(() => change({ scale }), /界面缩放/);
+ assert.equal(f.scale(), 1.25);
+});
diff --git a/tests/settings-storage.test.cjs b/tests/settings-storage.test.cjs
new file mode 100644
index 0000000..860ebfc
--- /dev/null
+++ b/tests/settings-storage.test.cjs
@@ -0,0 +1,170 @@
+const test = require('node:test'),
+ assert = require('node:assert/strict');
+const fs = require('node:fs'),
+ path = require('node:path'),
+ crypto = require('node:crypto');
+const { SettingsStorage } = require('../desktop/settings-storage.cjs');
+const DAY = 86400000;
+function fixture(t, isBusy = () => false) {
+ const workspace = path.resolve('.runtime/test-workspaces');
+ fs.mkdirSync(workspace, { recursive: true });
+ const root = fs.mkdtempSync(path.join(workspace, 'settings-'));
+ t.after(() => {
+ assert.equal(path.dirname(path.resolve(root)), workspace);
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+ const roots = Object.fromEntries(
+ ['library', 'runtime', 'profile', 'logs'].map((n) => [n, path.join(root, n)]),
+ );
+ for (const p of Object.values(roots)) fs.mkdirSync(p, { recursive: true });
+ let now = Date.now();
+ const write = (relative, content = 'data', days = 30) => {
+ const p = path.join(root, relative);
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, content);
+ const old = new Date(now - days * DAY);
+ fs.utimesSync(p, old, old);
+ let parent = path.dirname(p);
+ while (parent.startsWith(root + path.sep)) {
+ fs.utimesSync(parent, old, old);
+ parent = path.dirname(parent);
+ }
+ return p;
+ };
+ return {
+ root,
+ roots,
+ write,
+ advance: (ms) => (now += ms),
+ service: new SettingsStorage({ ...roots, isBusy, now: () => now }),
+ };
+}
+test('cleanup deletes only previewed expired temp content and preserves resumable data and music', async (t) => {
+ const f = fixture(t);
+ const expired = f.write('runtime/temp/old/decode.bin', 'expired');
+ const partial = f.write('runtime/temp/resume/model.part', 'resume');
+ const recent = f.write('runtime/temp/recent.wav', 'recent', 0);
+ const model = f.write('runtime/models/vocals/model.bin', 'weights');
+ const music = f.write('library/projects/track.wav', 'music');
+ const wheel = f.write('runtime/cache/wheels/archive.bin', 'cached');
+ const preview = await f.service.preview('temp');
+ assert.equal(preview.files, 1);
+ assert.equal(preview.bytes, 7);
+ const result = await f.service.clear(preview.token);
+ assert.equal(result.deletedFiles, 1);
+ assert.equal(result.freedBytes, 7);
+ assert.equal(fs.existsSync(expired), false);
+ for (const p of [partial, recent, model, music, wheel]) assert.ok(fs.existsSync(p), p);
+ await assert.rejects(() => f.service.clear(preview.token), /预览已过期/);
+});
+test('old job cleanup accepts only known files in UUID task directories', async (t) => {
+ const f = fixture(t),
+ old = crypto.randomUUID(),
+ unknown = crypto.randomUUID(),
+ young = crypto.randomUUID();
+ const request = JSON.stringify({ operation: 'analyze' });
+ const allowed = f.write('runtime/jobs/' + old + '/request.json', request);
+ f.write('runtime/jobs/' + old + '/worker.log', 'log');
+ const asset = f.write('runtime/jobs/' + unknown + '/source.wav', 'do not delete');
+ f.write('runtime/jobs/' + unknown + '/request.json', request);
+ const recent = f.write('runtime/jobs/' + young + '/request.json', request, 1);
+ const preview = await f.service.preview('jobs');
+ assert.equal(preview.files, 2);
+ const result = await f.service.clear(preview.token);
+ assert.equal(result.deletedFiles, 2);
+ assert.equal(fs.existsSync(allowed), false);
+ assert.ok(fs.existsSync(asset));
+ assert.ok(fs.existsSync(recent));
+});
+test('modified candidates and newly added files survive a stale preview', async (t) => {
+ const f = fixture(t),
+ file = f.write('runtime/temp/item/old.bin', 'old');
+ const preview = await f.service.preview('temp');
+ fs.writeFileSync(file, 'changed after preview');
+ const added = f.write('runtime/temp/item/new.part', 'new', 0);
+ const result = await f.service.clear(preview.token);
+ assert.equal(result.deletedFiles, 0);
+ assert.equal(result.skippedFiles, 1);
+ assert.equal(fs.readFileSync(file, 'utf8'), 'changed after preview');
+ assert.ok(fs.existsSync(added));
+});
+test('busy state and expired previews prevent cleanup without consuming valid data', async (t) => {
+ let busy = false;
+ const f = fixture(t, () => busy);
+ const file = f.write('runtime/temp/cache.bin');
+ const preview = await f.service.preview('temp');
+ busy = true;
+ await assert.rejects(() => f.service.clear(preview.token), /任务结束/);
+ await assert.rejects(() => f.service.preview('temp'), /任务结束/);
+ busy = false;
+ f.advance(6 * 60000);
+ await assert.rejects(() => f.service.clear(preview.token), /预览已过期/);
+ assert.ok(fs.existsSync(file));
+ await assert.rejects(() => f.service.preview('../models'), /不支持清理/);
+ await assert.rejects(() => f.service.location('../library'), /无效存储/);
+});
+test('junctions and linked roots are never traversed or removed', async (t) => {
+ const f = fixture(t),
+ outside = f.write('outside/private.wav', 'private');
+ const temp = path.join(f.roots.runtime, 'temp');
+ fs.mkdirSync(temp);
+ fs.symlinkSync(path.dirname(outside), path.join(temp, 'linked'), 'junction');
+ const preview = await f.service.preview('temp');
+ assert.equal(preview.files, 0);
+ await f.service.clear(preview.token);
+ assert.equal(fs.readFileSync(outside, 'utf8'), 'private');
+ fs.unlinkSync(path.join(temp, 'linked'));
+ fs.rmdirSync(temp);
+ fs.symlinkSync(path.dirname(outside), temp, 'junction');
+ assert.equal((await f.service.preview('temp')).files, 0);
+ assert.equal(fs.readFileSync(outside, 'utf8'), 'private');
+});
+test('statistics classify retained downloads separately from media and models', async (t) => {
+ const f = fixture(t);
+ f.write('library/audio.wav', 'music');
+ f.write('runtime/models/vocals/weights.bin', 'weights');
+ f.write('runtime/cache/installer.part', 'resume');
+ const info = await f.service.info(),
+ rows = Object.fromEntries(info.locations.map((r) => [r.id, r]));
+ assert.equal(rows.library.bytes, 5);
+ assert.equal(rows.models.bytes, 7);
+ assert.equal(rows.cache.bytes, 6);
+ assert.equal(rows.library.path, f.roots.library);
+ assert.ok(info.freeBytes === null || info.freeBytes >= 0);
+});
+test('linked regular files are measured but never offered for cleanup', async (t) => {
+ const f = fixture(t),
+ original = f.write('library/shared.wav', 'music');
+ for (const relative of ['temp/shared.bin', 'venv/shared.bin']) {
+ const target = path.join(f.roots.runtime, relative);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.linkSync(original, target);
+ }
+ const info = await f.service.info();
+ const runtime = info.locations.find((row) => row.id === 'runtime');
+ assert.equal(runtime.bytes, 5);
+ assert.equal(runtime.complete, true);
+ assert.equal((await f.service.preview('temp')).files, 0);
+ assert.equal(fs.readFileSync(original, 'utf8'), 'music');
+});
+test('partial cleanup reports failed files and leaves them available for retry', async (t) => {
+ const f = fixture(t),
+ blocked = f.write('runtime/temp/blocked.bin', 'blocked'),
+ removed = f.write('runtime/temp/old.bin', 'old');
+ const preview = await f.service.preview('temp'),
+ unlink = fs.promises.unlink;
+ fs.promises.unlink = async (file) => {
+ if (path.resolve(file) === blocked)
+ throw Object.assign(new Error('synthetic access denied'), { code: 'EPERM' });
+ return unlink(file);
+ };
+ t.after(() => {
+ fs.promises.unlink = unlink;
+ });
+ const result = await f.service.clear(preview.token);
+ assert.equal(result.deletedFiles, 1);
+ assert.equal(result.failedFiles, 1);
+ assert.equal(result.freedBytes, 3);
+ assert.ok(fs.existsSync(blocked));
+ assert.equal(fs.existsSync(removed), false);
+});
diff --git a/tests/test_decode_audit.py b/tests/test_decode_audit.py
index 632b3d2..b399e23 100644
--- a/tests/test_decode_audit.py
+++ b/tests/test_decode_audit.py
@@ -43,6 +43,18 @@ def oversized(args, **_kwargs):
self.assertEqual(self.target.read_bytes(), self.before)
self.assertEqual(sorted(p.name for p in self.root.iterdir()), ['playback.wav', 'source.wav'])
+ def test_invalid_decoded_duration_preserves_previous_playback(self):
+ def invalid(args, **_kwargs):
+ Path(args[-1]).write_bytes(b'empty decode fixture')
+
+ with patch.dict(os.environ, {'IMMERSE_FFMPEG': 'fixture-decoder'}), patch('analysis.subprocess.run', side_effect=invalid):
+ for duration in (0, -1, float('nan'), float('inf')):
+ with self.subTest(duration=duration), patch('analysis.sf.info', return_value=SimpleNamespace(duration=duration)):
+ with self.assertRaisesRegex(ValueError, '没有可读取的采样'):
+ decode(self.source, self.target)
+ self.assertEqual(self.target.read_bytes(), self.before)
+ self.assertEqual(sorted(p.name for p in self.root.iterdir()), ['playback.wav', 'source.wav'])
+
def test_successful_decode_atomically_replaces_the_previous_file(self):
decoder = shutil.which('ffmpeg')
if not decoder: