describe("FileRenamingDialog", function () { let win; let group; before(async function () { group = await getGroup(); win = await loadWindow("chrome://zotero/content/fileRenamingDialog.xhtml"); }); after(function () { if (win) { win.close(); } }); describe("#handleSettingsChange()", function () { // Drives handleSettingsChange for a given library/template with SyncedSettings.set stubbed, // returning the values the template setting would have been synced with function collectTemplateSyncs(libraryID, template) { let dialog = win.FileRenamingDialog; dialog.libraryPicker.value = String(libraryID); dialog._currentLibraryID = libraryID; dialog.loadSettingsForLibrary(libraryID); dialog.settingsEl.formatTemplate = template; let sandbox = sinon.createSandbox(); let setStub = sandbox.stub(Zotero.SyncedSettings, "set").resolves(); let clearStub = sandbox.stub(Zotero.SyncedSettings, "clear").resolves(); sandbox.stub(Zotero.Prefs, "set"); try { dialog.handleSettingsChange({ detail: { autoRenameEnabled: true, enabledFileTypes: 'application/pdf', renameLinkedEnabled: false, formatTemplate: template } }); return { otherSynced: setStub.getCalls().some(c => c.args[1] !== 'attachmentRenameTemplate'), templateValues: setStub.getCalls() .filter(c => c.args[1] === 'attachmentRenameTemplate') .map(c => c.args[2]), templateCleared: clearStub.getCalls() .some(c => c.args[1] === 'attachmentRenameTemplate') }; } finally { sandbox.restore(); } } it("should not push an invalid template to a group's synced settings", function () { let invalid = collectTemplateSyncs(group.libraryID, '{{ title'); assert.deepEqual(invalid.templateValues, [], "invalid template must not be synced"); assert.isTrue(invalid.otherSynced, "other group settings should still be synced"); let valid = collectTemplateSyncs(group.libraryID, '{{ title }}'); assert.deepEqual(valid.templateValues, ['{{ title }}'], "valid template must be synced"); }); it("should clear a group's template setting when the template is emptied", function () { for (let empty of ['', ' ']) { let result = collectTemplateSyncs(group.libraryID, empty); assert.deepEqual(result.templateValues, [], `empty template ${JSON.stringify(empty)} must not be synced`); assert.isTrue(result.templateCleared, "empty template must be cleared from a group's synced settings"); assert.isTrue(result.otherSynced, "other group settings should still be synced"); } }); it("should not push an invalid template to the user library's synced settings", function () { let invalid = collectTemplateSyncs(Zotero.Libraries.userLibraryID, '{{ title'); assert.deepEqual(invalid.templateValues, [], "invalid template must not be synced"); let valid = collectTemplateSyncs(Zotero.Libraries.userLibraryID, '{{ title }}'); assert.deepEqual(valid.templateValues, ['{{ title }}'], "valid template must be synced"); }); }); describe("invalid template prompt", function () { const INVALID_TEMPLATE = '{{ title'; // Loads the given library and applies a template change through the dialog's change // handler (with persistence stubbed by the caller), leaving the dialog dirty function makeDirty(libraryID, template) { let dialog = win.FileRenamingDialog; dialog.libraryPicker.value = String(libraryID); dialog._currentLibraryID = libraryID; dialog.loadSettingsForLibrary(libraryID); dialog.settingsEl.formatTemplate = template; dialog.handleSettingsChange({ detail: { autoRenameEnabled: dialog.settingsEl.autoRenameEnabled, enabledFileTypes: dialog.settingsEl.enabledFileTypes, renameLinkedEnabled: dialog.settingsEl.renameLinkedEnabled, formatTemplate: template } }); return dialog; } function setupStubs(sandbox, confirmIndex) { return { set: sandbox.stub(Zotero.SyncedSettings, 'set').resolves(), clear: sandbox.stub(Zotero.SyncedSettings, 'clear').resolves(), prefSet: sandbox.stub(Zotero.Prefs, 'set'), confirm: sandbox.stub(Zotero.Prompt, 'confirm').returns(confirmIndex) }; } function resetStubHistory(stubs) { stubs.set.resetHistory(); stubs.clear.resetHistory(); stubs.prefSet.resetHistory(); } function templateWrites(stubs) { return { sets: stubs.set.getCalls().filter(c => c.args[1] === 'attachmentRenameTemplate'), clears: stubs.clear.getCalls().filter(c => c.args[1] === 'attachmentRenameTemplate') }; } function restoreDialog() { let dialog = win.FileRenamingDialog; dialog._forceClose = false; dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID); dialog._currentLibraryID = Zotero.Libraries.userLibraryID; dialog.loadSettingsForLibrary(Zotero.Libraries.userLibraryID); } it("should keep the window open and not touch the template setting on cancel", async function () { let sandbox = sinon.createSandbox(); let stubs = setupStubs(sandbox, 1); let origClose = win.close; let closeSpy = sinon.spy(); win.close = closeSpy; try { let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE); resetStubHistory(stubs); let event = { preventDefault: sinon.spy() }; await dialog.handleWindowClose(event); assert.isTrue(event.preventDefault.called, 'close must be blocked'); assert.isFalse(closeSpy.called, 'window must stay open on cancel'); assert.equal(stubs.confirm.callCount, 1, 'prompt shown once'); assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string'); assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string'); let { sets, clears } = templateWrites(stubs); assert.lengthOf(sets, 0, 'template must not be synced on cancel'); assert.lengthOf(clears, 0, 'template must not be cleared on cancel'); } finally { win.close = origClose; sandbox.restore(); restoreDialog(); } }); it("should clear the group's template setting and close on reset", async function () { let sandbox = sinon.createSandbox(); let stubs = setupStubs(sandbox, 0); let origClose = win.close; let closeSpy = sinon.spy(); win.close = closeSpy; try { let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE); resetStubHistory(stubs); let event = { preventDefault: sinon.spy() }; await dialog.handleWindowClose(event); assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string'); assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string'); assert.equal(stubs.confirm.callCount, 1, 'no rename prompt when the reset lands back on the baseline'); let { sets, clears } = templateWrites(stubs); assert.lengthOf(sets, 0, 'template must not be synced on reset'); assert.lengthOf(clears, 1, 'template setting must be cleared'); assert.equal(clears[0].args[0], group.libraryID); assert.isFalse( stubs.prefSet.calledWith('autoRenameFiles.done', sinon.match.any), 'user-library done pref must not change for a group reset' ); assert.isTrue(closeSpy.called, 'window must close after reset'); } finally { win.close = origClose; sandbox.restore(); restoreDialog(); } }); it("should clear the user library's template setting and recompute the done pref on reset", async function () { let userLibraryID = Zotero.Libraries.userLibraryID; // Make the baseline deterministic: default template stored, no rename pending let origTemplate = Zotero.SyncedSettings.get(userLibraryID, 'attachmentRenameTemplate'); let origDone = Zotero.Prefs.get('autoRenameFiles.done'); await Zotero.SyncedSettings.clear(userLibraryID, 'attachmentRenameTemplate'); Zotero.Prefs.set('autoRenameFiles.done', true); let sandbox = sinon.createSandbox(); let stubs = setupStubs(sandbox, 0); let origClose = win.close; let closeSpy = sinon.spy(); win.close = closeSpy; try { let dialog = makeDirty(userLibraryID, INVALID_TEMPLATE); resetStubHistory(stubs); let event = { preventDefault: sinon.spy() }; await dialog.handleWindowClose(event); assert.isString(stubs.confirm.firstCall.args[0].title, 'prompt title must resolve to a string'); assert.isString(stubs.confirm.firstCall.args[0].text, 'prompt body must resolve to a string'); assert.equal(stubs.confirm.callCount, 1, 'no rename prompt when the reset lands back on the baseline'); let { sets, clears } = templateWrites(stubs); assert.lengthOf(sets, 0, 'template must not be synced on reset'); assert.lengthOf(clears, 1, 'template setting must be cleared'); assert.equal(clears[0].args[0], userLibraryID); // The reset lands back on the baseline, so no rename is pending assert.isTrue( stubs.prefSet.calledWith('autoRenameFiles.done', true), 'done pref must be recomputed against the baseline, not hard-set to false' ); assert.isTrue(closeSpy.called, 'window must close after reset'); } finally { win.close = origClose; sandbox.restore(); if (origTemplate) { await Zotero.SyncedSettings.set(userLibraryID, 'attachmentRenameTemplate', origTemplate); } Zotero.Prefs.set('autoRenameFiles.done', origDone); restoreDialog(); } }); it("should prompt when an invalid template blurs directly to Done", async function () { let sandbox = sinon.createSandbox(); let stubs = setupStubs(sandbox, 1); let origClose = win.close; let closeSpy = sinon.spy(); win.close = closeSpy; try { let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE); resetStubHistory(stubs); dialog.settingsEl.handleTemplateBlur(); await dialog._handleDoneClick(); assert.isFalse(closeSpy.called, 'window must stay open on cancel'); assert.equal(stubs.confirm.callCount, 1, 'only the invalid-template prompt is shown'); let { sets, clears } = templateWrites(stubs); assert.lengthOf(sets, 0); assert.lengthOf(clears, 0); } finally { win.close = origClose; sandbox.restore(); restoreDialog(); } }); it("should stay on the current library when switching libraries and the user cancels", async function () { let sandbox = sinon.createSandbox(); let stubs = setupStubs(sandbox, 1); try { let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE); resetStubHistory(stubs); dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID); await dialog.handleLibraryChange(); assert.equal(dialog.libraryPicker.value, String(group.libraryID), 'picker must revert to the previous library'); assert.equal(dialog._currentLibraryID, group.libraryID, 'current library must not change'); let { sets, clears } = templateWrites(stubs); assert.lengthOf(sets, 0); assert.lengthOf(clears, 0); } finally { sandbox.restore(); restoreDialog(); } }); it("should reset the previous library's template and switch libraries on reset", async function () { let sandbox = sinon.createSandbox(); let stubs = setupStubs(sandbox, 0); try { let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE); resetStubHistory(stubs); dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID); await dialog.handleLibraryChange(); let { clears } = templateWrites(stubs); assert.lengthOf(clears, 1, 'previous library template setting must be cleared'); assert.equal(clears[0].args[0], group.libraryID); assert.equal(dialog._currentLibraryID, Zotero.Libraries.userLibraryID, 'switch must proceed after reset'); assert.equal(dialog.libraryPicker.value, String(Zotero.Libraries.userLibraryID)); } finally { sandbox.restore(); restoreDialog(); } }); it("should prompt to rename files after resetting the template on Done and skip the preview when declined", async function () { await Zotero.SyncedSettings.set(group.libraryID, 'attachmentRenameTemplate', '{{ title }}-baseline'); let sandbox = sinon.createSandbox(); let stubs = setupStubs(sandbox, 0); stubs.confirm.onSecondCall().returns(1); let dialog = win.FileRenamingDialog; let previewStub = sandbox.stub(dialog, '_openRenameFilesPreview').returns(true); let origClose = win.close; let closeSpy = sinon.spy(); win.close = closeSpy; try { makeDirty(group.libraryID, INVALID_TEMPLATE); resetStubHistory(stubs); await dialog._handleDoneClick(); assert.equal(stubs.confirm.callCount, 2, 'reset prompt and rename prompt must both be shown'); let { clears } = templateWrites(stubs); assert.lengthOf(clears, 1, 'template setting must be cleared'); assert.isFalse(previewStub.called, 'declining the rename must not open the preview'); assert.isTrue(closeSpy.called, 'window must close after the prompts'); } finally { win.close = origClose; sandbox.restore(); await Zotero.SyncedSettings.clear(group.libraryID, 'attachmentRenameTemplate'); restoreDialog(); } }); it("should prompt to rename files after resetting the template when switching libraries", async function () { await Zotero.SyncedSettings.set(group.libraryID, 'attachmentRenameTemplate', '{{ title }}-baseline'); let sandbox = sinon.createSandbox(); let stubs = setupStubs(sandbox, 0); let dialog = win.FileRenamingDialog; let previewStub = sandbox.stub(dialog, '_openRenameFilesPreview').returns(true); try { makeDirty(group.libraryID, INVALID_TEMPLATE); resetStubHistory(stubs); dialog.libraryPicker.value = String(Zotero.Libraries.userLibraryID); await dialog.handleLibraryChange(); assert.equal(stubs.confirm.callCount, 2, 'reset prompt and rename prompt must both be shown'); assert.isTrue(previewStub.calledOnceWith(group.libraryID), 'rename preview must open for the previous library'); assert.equal(dialog._currentLibraryID, Zotero.Libraries.userLibraryID, 'switch must proceed after the prompts'); assert.equal(dialog.libraryPicker.value, String(Zotero.Libraries.userLibraryID)); } finally { sandbox.restore(); await Zotero.SyncedSettings.clear(group.libraryID, 'attachmentRenameTemplate'); restoreDialog(); } }); it("should only prompt when settings changed and the template is invalid", function () { let sandbox = sinon.createSandbox(); setupStubs(sandbox, 1); try { let dialog = makeDirty(group.libraryID, INVALID_TEMPLATE); assert.isTrue(dialog._shouldPromptInvalidTemplate(), 'changed + invalid must prompt'); makeDirty(group.libraryID, '{{ title }}{{ year }}'); assert.isFalse(dialog._shouldPromptInvalidTemplate(), 'changed + valid must not prompt'); dialog.loadSettingsForLibrary(group.libraryID); dialog.settingsEl.formatTemplate = INVALID_TEMPLATE; assert.isFalse(dialog._shouldPromptInvalidTemplate(), 'unchanged + invalid must not prompt'); } finally { sandbox.restore(); restoreDialog(); } }); }); describe("template preview", function () { it("should keep the last valid preview while typing and show an error on blur", function () { let dialog = win.FileRenamingDialog; let settingsEl = dialog.settingsEl; let preview = settingsEl.querySelector('#file-renaming-format-preview'); let libraryID = dialog._currentLibraryID; let sandbox = sinon.createSandbox(); sandbox.stub(Zotero.SyncedSettings, 'set').resolves(); sandbox.stub(Zotero.SyncedSettings, 'clear').resolves(); sandbox.stub(Zotero.Prefs, 'set'); try { settingsEl.formatTemplate = '{{ title }}'; settingsEl.updatePreview(); let firstValidPreview = preview.innerText; settingsEl.formatTemplate = '{{ title'; settingsEl.handleTemplateInput(); assert.equal(preview.innerText, firstValidPreview, 'last valid preview retained'); assert.isFalse(settingsEl.previewSection.classList.contains('is-hidden'), 'preview remains visible'); assert.isTrue(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error remains hidden'); settingsEl.formatTemplate = '{{ title }}-updated'; settingsEl.handleTemplateInput(); let secondValidPreview = preview.innerText; assert.notEqual(secondValidPreview, firstValidPreview, 'preview updates when valid again'); settingsEl.formatTemplate = '{{ title'; settingsEl.handleTemplateInput(); assert.equal(preview.innerText, secondValidPreview, 'latest valid preview retained'); settingsEl.handleTemplateBlur(); assert.isTrue(settingsEl.previewSection.classList.contains('is-hidden'), 'preview hidden on blur'); assert.isFalse(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error shown on blur'); } finally { sandbox.restore(); dialog.loadSettingsForLibrary(libraryID); } }); it("should clear a stale syntax-error message when the template is cleared to whitespace", function () { let settingsEl = win.FileRenamingDialog.settingsEl; // Show the invalid state (updatePreview has no change side effects) settingsEl.formatTemplate = '{{ title'; settingsEl.updatePreview(); assert.isFalse(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error shown while invalid'); assert.isTrue(settingsEl.previewSection.classList.contains('is-hidden'), 'preview hidden while invalid'); // Deleting down to whitespace must clear the stale error and restore the preview settingsEl.formatTemplate = ' '; settingsEl.handleTemplateInput(); assert.isTrue(settingsEl.invalidMessage.classList.contains('is-hidden'), 'error cleared for whitespace'); assert.isFalse(settingsEl.previewSection.classList.contains('is-hidden'), 'preview restored for whitespace'); }); it("should validate the template as the engine renders it, ignoring newlines inside a tag", function () { let settingsEl = win.FileRenamingDialog.settingsEl; // The engine strips newlines before validating/rendering, so the dialog must judge the // same normalized string -- a newline splitting `endif` here must not read as invalid settingsEl.formatTemplate = '{{if title}}a{{end\nif}}'; assert.isTrue(settingsEl.templateValid, 'newline inside a tag must not read as invalid'); }); it("should validate the template at most once per keystroke", function () { let settingsEl = win.FileRenamingDialog.settingsEl; settingsEl.formatTemplate = '{{ title }}{{ year }}'; let sandbox = sinon.createSandbox(); // Stub persistence so the change handler runs without touching settings sandbox.stub(Zotero.SyncedSettings, 'set').resolves(); sandbox.stub(Zotero.SyncedSettings, 'clear').resolves(); sandbox.stub(Zotero.Prefs, 'set'); let spy = sandbox.spy(settingsEl, '_validateTemplate'); try { settingsEl.handleTemplateInput(); assert.isAtMost(spy.callCount, 1, 'template should be parsed at most once per keystroke'); } finally { sandbox.restore(); } }); }); });