diff --git a/index.html b/index.html index 52e7eabdc2..c47e9d9de0 100644 --- a/index.html +++ b/index.html @@ -440,6 +440,13 @@ accept=".json" tabindex="-1" /> + { + let mockBrowser; + let mockChrome; + + beforeEach(() => { + // Mock objects + mockBrowser = { + browserAction: { + onClicked: { addListener: jest.fn() }, + }, + tabs: { create: jest.fn() }, + runtime: { + onInstalled: { addListener: jest.fn() }, + }, + }; + + mockChrome = { + browserAction: { + onClicked: { addListener: jest.fn() }, + }, + runtime: { + onInstalled: { addListener: jest.fn() }, + getURL: jest.fn((path) => `chrome-extension://fake-id/${path}`), + }, + tabs: { create: jest.fn() }, + }; + + global.browser = mockBrowser; + global.chrome = mockChrome; + + Object.defineProperty(global.navigator, "userAgent", { + writable: true, + value: "", + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + delete global.browser; + delete global.chrome; + }); + + it("should set up Firefox-specific listeners when user agent is Firefox", () => { + navigator.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0"; + + jest.resetModules(); // Clear the module cache + const { isFirefox, browserAction } = require("../background.js"); + + expect(isFirefox).toBe(true); + expect(browserAction.onClicked.addListener).toHaveBeenCalledTimes(1); + expect(mockBrowser.runtime.onInstalled.addListener).toHaveBeenCalledTimes(1); + }); + + it("should set up Chrome-specific listeners when user agent is not Firefox", () => { + navigator.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"; + + jest.resetModules(); // Clear the module cache + const { isFirefox, browserAction } = require("../background.js"); + + expect(isFirefox).toBe(false); + expect(browserAction.onClicked.addListener).toHaveBeenCalledTimes(1); + expect(mockChrome.runtime.onInstalled.addListener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/js/__tests__/mxml.test.js b/js/__tests__/mxml.test.js new file mode 100644 index 0000000000..523d27e71a --- /dev/null +++ b/js/__tests__/mxml.test.js @@ -0,0 +1,151 @@ +const saveMxmlOutput = require("../mxml"); + +describe("saveMxmlOutput", () => { + it("should return a valid XML string for a basic input", () => { + const logo = { + notation: { + notationStaging: { + "0": [ + [["C"], 4, 0], + ], + "1": [] + } + } + }; + + const output = saveMxmlOutput(logo); + + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain(""); + }); + + it("should handle multiple voices", () => { + const logo = { + notation: { + notationStaging: { + "0": [ + [["C"], 4, 0], + [["D"], 4, 0] + ], + "1": [ + [["E"], 4, 0], + [["F"], 4, 0] + ] + } + } + }; + + const output = saveMxmlOutput(logo); + + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain(""); + expect(output).toContain("C"); + expect(output).toContain("E"); + }); + + it("should ignore specified elements", () => { + const logo = { + notation: { + notationStaging: { + "0": [ + "voice one", + [["C"], 4, 0], + "voice two" + ] + } + } + }; + + const output = saveMxmlOutput(logo); + + expect(output).not.toContain("voice one"); + expect(output).not.toContain("voice two"); + expect(output).toContain("C"); + }); + + it("should handle tempo changes", () => { + const logo = { + notation: { + notationStaging: { + "0": [ + "tempo", 120, 4, + [["C"], 4, 0] + ] + } + } + }; + + const output = saveMxmlOutput(logo); + + expect(output).toContain(""); + expect(output).toContain("C"); + }); + + it("should handle meter changes", () => { + const logo = { + notation: { + notationStaging: { + "0": [ + "meter", 3, 4, + [["C"], 4, 0] + ] + } + } + }; + + const output = saveMxmlOutput(logo); + + expect(output).toContain(""); + expect(output).toContain("4"); + expect(output).toContain("C"); + }); + + it("should handle crescendo and decrescendo markings", () => { + const logo = { + notation: { + notationStaging: { + "0": [ + "begin crescendo", + [["C"], 4, 0], + "end crescendo", + "begin decrescendo", + [["D"], 4, 0], + "end decrescendo" + ] + } + } + }; + + const output = saveMxmlOutput(logo); + + expect(output).toContain(''); + expect(output).toContain(''); + expect(output).toContain(''); + expect(output).toContain("C"); + expect(output).toContain("D"); + }); + + it("should handle tied notes", () => { + const logo = { + notation: { + notationStaging: { + "0": [ + [["C"], 4, 0], + "tie", + [["C"], 4, 0] + ] + } + } + }; + + const output = saveMxmlOutput(logo); + + expect(output).toContain(''); + expect(output).toContain(''); + }); +}); diff --git a/js/__tests__/notation.test.js b/js/__tests__/notation.test.js new file mode 100644 index 0000000000..4f5beac967 --- /dev/null +++ b/js/__tests__/notation.test.js @@ -0,0 +1,131 @@ +const Notation = require('../notation'); +const { durationToNoteValue, convertFactor } = require('../utils/musicutils'); +global.convertFactor = convertFactor; +global.durationToNoteValue = durationToNoteValue; + +jest.mock('../utils/musicutils.js', function() { + return { + durationToNoteValue: jest.fn().mockReturnValue([1, 1, 1, 1]), + convertFactor: jest.fn().mockReturnValue(4), + }; +}); + +describe('Notation Class', () => { + let notation; + + beforeEach(() => { + const mockActivity = { + turtles: { + ithTurtle: jest.fn().mockReturnValue({ + singer: { + staccato: [] + } + }) + }, + logo: { + updateNotation: jest.fn() + } + }; + notation = new Notation(mockActivity); + notation._notationStaging = [[]]; + notation._notationStaging['turtle1'] = []; + notation._notationDrumStaging['turtle1'] = []; + notation._pickupPOW2['turtle1'] = false; + notation._pickupPoint['turtle1'] = null; + }); + + describe('Setters and Getters', () => { + it('should correctly set and get notationStaging', () => { + const turtle = 'turtle1'; + const staging = { 'turtle1': ['note1', 'note2'] }; + notation.notationStaging = staging; + expect(notation.notationStaging).toEqual(staging); + }); + + it('should correctly set and get notationDrumStaging', () => { + const turtle = 'turtle1'; + const drumStaging = { 'turtle1': ['drum1', 'drum2'] }; + notation.notationDrumStaging = drumStaging; + expect(notation.notationDrumStaging).toEqual(drumStaging); + }); + + it('should correctly set and get notationMarkup', () => { + const markup = { 'turtle1': ['markup1', 'markup2'] }; + notation.notationMarkup = markup; + expect(notation.notationMarkup).toEqual(markup); + }); + + it('should correctly return pickupPOW2 and pickupPoint', () => { + expect(notation.pickupPOW2).toEqual({ turtle1: false }); + expect(notation.pickupPoint).toEqual({ turtle1: null }); + }); + }); + + describe('Notation Utility Methods', () => { + it('should update notation correctly with doUpdateNotation', () => { + const note = 'C4'; + const duration = 4; + const turtle = 'turtle1'; + const insideChord = false; + const drum = []; + notation.doUpdateNotation(note, duration, turtle, insideChord, drum); + expect(notation._notationStaging[turtle]).toContainEqual(expect.arrayContaining([note, expect.any(Number), expect.any(Number)])); + }); + + it('should add notation markup using _notationMarkup', () => { + const turtle = 'turtle1'; + const markup = 'staccato'; + const below = true; + notation._notationMarkup(turtle, markup, below); + expect(notation._notationStaging[turtle]).toEqual(["markdown", "staccato"]); + }); + + it('should add a pickup using notationPickup', () => { + const turtle = 'turtle1'; + const factor = 2; + + notation.notationPickup(turtle, factor); + expect(convertFactor).toHaveBeenCalledWith(factor); + expect(notation._notationStaging[turtle]).toEqual(['pickup', 4]); + }); + + it('should add a voice using notationVoices', () => { + const turtle = 'turtle1'; + const voiceNumber = 2; + notation.notationVoices(turtle, voiceNumber); + expect(notation._notationStaging[turtle]).toContain('voice two'); + }); + + it('should set meter using notationMeter', () => { + const turtle = 'turtle1'; + const count = 4; + const value = 4; + notation.notationMeter(turtle, count, value); + expect(notation._notationStaging[turtle]).toEqual(['meter', count, value]); + }); + + + it('should handle notationSwing', () => { + const turtle = 'turtle1'; + notation.notationSwing(turtle); + expect(notation._notationStaging[turtle]).toContain('swing'); + }); + + it('should handle notationTempo', () => { + const turtle = 'turtle1'; + const bpm = 120; + const beatValue = 4; + notation.notationTempo(turtle, bpm, beatValue); + expect(notation._notationStaging[turtle]).toEqual(['tempo', bpm, 4]); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty or invalid input for notationPickup', () => { + const turtle = 'turtle1'; + const factor = 0; + notation.notationPickup(turtle, factor); + expect(notation._notationStaging[turtle].length).toBe(0); + }); + }); +}); diff --git a/js/__tests__/planetInterface.test.js b/js/__tests__/planetInterface.test.js new file mode 100644 index 0000000000..ad7f75442f --- /dev/null +++ b/js/__tests__/planetInterface.test.js @@ -0,0 +1,117 @@ +const PlanetInterface = require('../planetInterface'); +global.platformColor = { + header: '#8bc34a' +}; + +const mockActivity = { + hideSearchWidget: jest.fn(), + prepSearchWidget: jest.fn(), + sendAllToTrash: jest.fn(), + refreshCanvas: jest.fn(), + _loadStart: jest.fn(), + doLoadAnimation: jest.fn(), + textMsg: jest.fn(), + stage: { enableDOMEvents: jest.fn() }, + blocks: { loadNewBlocks: jest.fn(), palettes: { _hideMenus: jest.fn() }, trashStacks: [] }, + logo: { doStopTurtles: jest.fn() }, + canvas: {}, + turtles: {}, + loading: false, + prepareExport: jest.fn(), + _allClear: jest.fn() +}; + +document.body.innerHTML = ` + + + + + + + + + + + +`; + +const docById = jest.fn((id) => document.getElementById(id)); +global.docById = docById; + +beforeAll(() => { + mockCanvas = { + click: jest.fn() + }; + window.widgetWindows = { + hideAllWindows: jest.fn(), + showWindows: jest.fn(), + }; + window.scroll = jest.fn(); +}); + +describe('PlanetInterface', () => { + let planetInterface; + + beforeEach(() => { + planetInterface = new PlanetInterface(mockActivity); + }); + + test('hideMusicBlocks hides relevant elements and disables DOM events', () => { + planetInterface.hideMusicBlocks(); + + expect(mockActivity.hideSearchWidget).toHaveBeenCalled(); + expect(mockActivity.logo.doStopTurtles).toHaveBeenCalled(); + expect(docById('helpElem').style.visibility).toBe('hidden'); + expect(document.querySelector('.canvasHolder').classList.contains('hide')).toBe(true); + expect(document.querySelector('#canvas').style.display).toBe('none'); + expect(document.querySelector('#theme-color').content).toBe('#8bc34a'); + }); + + test('showMusicBlocks shows relevant elements and enables DOM events', () => { + mockActivity.planet = { getCurrentProjectName: jest.fn(() => 'Test Project') }; + + planetInterface.showMusicBlocks(); + + expect(document.title).toBe('Test Project'); + expect(docById('toolbars').style.display).toBe('block'); + expect(docById('palette').style.display).toBe('block'); + expect(mockActivity.prepSearchWidget).toHaveBeenCalled(); + expect(document.querySelector('.canvasHolder').classList.contains('hide')).toBe(false); + expect(document.querySelector('#canvas').style.display).toBe(''); + }); + + test('hidePlanet hides the planet interface', () => { + planetInterface.iframe = document.querySelector('#planet-iframe'); + planetInterface.hidePlanet(); + expect(planetInterface.iframe.style.display).toBe('none'); + }); + + test('openPlanet calls saveLocally, hideMusicBlocks, and showPlanet', () => { + jest.spyOn(planetInterface, 'saveLocally').mockImplementation(() => {}); + jest.spyOn(planetInterface, 'hideMusicBlocks').mockImplementation(() => {}); + jest.spyOn(planetInterface, 'showPlanet').mockImplementation(() => {}); + planetInterface.openPlanet(); + expect(planetInterface.saveLocally).toHaveBeenCalled(); + expect(planetInterface.hideMusicBlocks).toHaveBeenCalled(); + expect(planetInterface.showPlanet).toHaveBeenCalled(); + }); + + test('closePlanet calls hidePlanet and showMusicBlocks', () => { + jest.spyOn(planetInterface, 'hidePlanet').mockImplementation(() => {}); + jest.spyOn(planetInterface, 'showMusicBlocks').mockImplementation(() => {}); + planetInterface.closePlanet(); + expect(planetInterface.hidePlanet).toHaveBeenCalled(); + expect(planetInterface.showMusicBlocks).toHaveBeenCalled(); + }); + + test('newProject calls closePlanet, initialiseNewProject, _loadStart, and saveLocally', () => { + jest.spyOn(planetInterface, 'closePlanet').mockImplementation(() => {}); + jest.spyOn(planetInterface, 'initialiseNewProject').mockImplementation(() => {}); + jest.spyOn(planetInterface, 'saveLocally').mockImplementation(() => {}); + planetInterface.newProject(); + expect(planetInterface.closePlanet).toHaveBeenCalled(); + expect(planetInterface.initialiseNewProject).toHaveBeenCalled(); + expect(mockActivity._loadStart).toHaveBeenCalled(); + expect(planetInterface.saveLocally).toHaveBeenCalled(); + }); +}); diff --git a/js/background.js b/js/background.js index 5e00403dd6..2fe00d3e55 100644 --- a/js/background.js +++ b/js/background.js @@ -34,3 +34,9 @@ if (navigator.userAgent.search("Firefox") !== -1) { window.open(chrome.runtime.getURL("index.html")); }); } +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + isFirefox: navigator.userAgent.search("Firefox") !== -1, + browserAction: navigator.userAgent.search("Firefox") !== -1 ? browser.browserAction : chrome.browserAction, + }; +} diff --git a/js/block.js b/js/block.js index 04bc791d06..f75cf8b515 100644 --- a/js/block.js +++ b/js/block.js @@ -2085,8 +2085,8 @@ class Block { * @param {number} thisBlock - Index of the current block. */ _doOpenMedia(thisBlock) { - const fileChooser = docById("myOpenAll"); const that = this; + const fileChooser = that.name=="media" ? docById("myMedia") : docById("audio"); const __readerAction = () => { window.scroll(0, 0); diff --git a/js/js-export/interface.js b/js/js-export/interface.js index 782c2e4612..52d20ab2b1 100644 --- a/js/js-export/interface.js +++ b/js/js-export/interface.js @@ -523,6 +523,7 @@ class JSInterface { "cello", "bass", "double bass", + "sitar", "guitar", "acoustic guitar", "flute", diff --git a/js/mxml.js b/js/mxml.js index b1a51f80da..4f6630a299 100644 --- a/js/mxml.js +++ b/js/mxml.js @@ -247,3 +247,6 @@ saveMxmlOutput = (logo) => { return res; }; +if (typeof module !== "undefined" && module.exports) { + module.exports = saveMxmlOutput; +} diff --git a/js/notation.js b/js/notation.js index e652b2ccda..6efd447aab 100644 --- a/js/notation.js +++ b/js/notation.js @@ -502,3 +502,6 @@ class Notation { this._pickupPoint[turtle] = null; } } +if (typeof module !== "undefined" && module.exports) { + module.exports = Notation; +} diff --git a/js/planetInterface.js b/js/planetInterface.js index 09e273ac32..7c97cd285d 100644 --- a/js/planetInterface.js +++ b/js/planetInterface.js @@ -331,3 +331,6 @@ class PlanetInterface { }; } } +if (typeof module !== "undefined" && module.exports) { + module.exports = PlanetInterface; +} diff --git a/js/toolbar.js b/js/toolbar.js index 72c1643b8a..a870720eb5 100644 --- a/js/toolbar.js +++ b/js/toolbar.js @@ -463,7 +463,12 @@ class Toolbar { const darkModeIcon = docById("darkModeIcon"); darkModeIcon.onclick = () => { - onclick(); + this.activity.textMsg(` ${_("Refresh your browser to change your theme.")} `); + + const themeLink = docById("theme-link"); + themeLink.addEventListener( "click", () => { + onclick(); + }) } } diff --git a/js/utils/musicutils.js b/js/utils/musicutils.js index 689497b225..3e243f830c 100644 --- a/js/utils/musicutils.js +++ b/js/utils/musicutils.js @@ -1109,6 +1109,7 @@ const SELECTORSTRINGS = [ _("bass"), _("double bass"), _("guitar"), + _("sitar"), _("acoustic guitar"), _("flute"), _("clarinet"), diff --git a/js/utils/synthutils.js b/js/utils/synthutils.js index e44f774220..99b8caaa9f 100644 --- a/js/utils/synthutils.js +++ b/js/utils/synthutils.js @@ -80,6 +80,8 @@ const VOICENAMES = [ [_("bass"), "bass", "images/voices.svg", "string"], //.TRANS: viola musical instrument [_("double bass"), "double bass", "images/voices.svg", "string"], + //.TRANS: sitar musical instrument + [_("sitar"), "sitar", "images/synth.svg", "string"], //.TRANS: musical instrument [_("guitar"), "guitar", "images/voices.svg", "string"], //.TRANS: musical instrument @@ -264,7 +266,8 @@ const SOUNDSAMPLESDEFINES = [ "samples/viola", "samples/oboe", "samples/trombone", - "samples/doublebass" // "samples/japanese_bell", + "samples/doublebass", + "samples/sitar" ]; // Some samples have a default volume other than 50 (See #1697) @@ -300,7 +303,8 @@ const DEFAULTSYNTHVOLUME = { "slap": 60, "vibraphone": 100, "xylophone": 100, - "japanese drum": 90 + "japanese drum": 90, + "sitar": 100 }; /** @@ -327,12 +331,13 @@ const SAMPLECENTERNO = { "koto": ["C5", 51], // pitchToNumber('C', 5, 'C Major')], "dulcimer": ["C4", 39], // pitchToNumber('C', 4, 'C Major')], "electric guitar": ["C3", 27], // pitchToNumber('C', 3, 'C Major')], - "bassoon": ["D4", 41], // pitchToNumber('C', 5, 'C Major')], + "bassoon": ["D4", 41], // pitchToNumber('D', 4, 'D Major')], "celeste": ["C3", 27], // pitchToNumber('C', 3, 'C Major')], - "vibraphone": ["C5", 51], - "xylophone": ["C4", 39], - "viola": ["D4", 53], - "double bass": ["C4", 39] + "vibraphone": ["C5", 51], // pitchToNumber('C', 5, 'C Major')], + "xylophone": ["C4", 39], // pitchToNumber('C', 4, 'C Major')], + "viola": ["D4", 53], // pitchToNumber('D', 4, 'D Major')], + "double bass": ["C4", 39], // pitchToNumber('C', 4, 'C Major')], + "sitar": ["C4", 39] // pitchToNumber('C', 4, 'C Major')] }; @@ -355,7 +360,7 @@ const percussionInstruments = ["koto", "banjo", "dulcimer", "xylophone", "celest * @constant * @type {Array} */ -const stringInstruments = ["piano", "guitar", "acoustic guitar", "electric guitar"]; +const stringInstruments = ["piano","sitar", "guitar", "acoustic guitar", "electric guitar"]; /** * Validates and sets parameters for an instrument. @@ -828,7 +833,8 @@ function Synth() { { name: "bassoon", data: BASSOON_SAMPLE }, { name: "celeste", data: CELESTE_SAMPLE }, { name: "vibraphone", data: VIBRAPHONE_SAMPLE }, - { name: "xylophone", data: XYLOPHONE_SAMPLE } + { name: "xylophone", data: XYLOPHONE_SAMPLE }, + { name: "sitar", data: SITAR_SAMPLE } ], drum: [ { name: "bottle", data: BOTTLE_SAMPLE }, diff --git a/js/widgets/musickeyboard.js b/js/widgets/musickeyboard.js index c28ce4882b..8d523f0f05 100644 --- a/js/widgets/musickeyboard.js +++ b/js/widgets/musickeyboard.js @@ -872,9 +872,14 @@ function MusicKeyboard(activity) { } this._stopOrCloseClicked = false; - this._playChord(notes, selectedNotes[0].duration, selectedNotes[0].voice); - const maxWidth = Math.max.apply(Math, selectedNotes[0].duration); - this.playOne(1, maxWidth, playButtonCell); + + // Convert durations to seconds based on BPM + const durationInSeconds = selectedNotes[0].duration.map( + (beatDuration) => (beatDuration * 60 * 4) / (this.bpm) + ); + this._playChord(notes, durationInSeconds, selectedNotes[0].voice); + const maxDuration = Math.max(...durationInSeconds); + this.playOne(1, maxDuration, playButtonCell); } else { if (!this.keyboardShown) { this._createTable(); @@ -898,14 +903,14 @@ function MusicKeyboard(activity) { /** * Plays a sequence of musical notes recursively with a specified time delay. - * + * * @param {number} counter - The index of the current note in the sequence. * @param {number} time - The time duration of the current note. * @param {HTMLElement} playButtonCell - The HTML element representing the play button. */ this.playOne = function (counter, time, playButtonCell) { setTimeout(() => { - let cell, eleid, ele, notes, zx, res, maxWidth; + let cell, eleid, ele, notes, zx, res, maxDuration; if (counter < selectedNotes.length) { if (this._stopOrCloseClicked) { return; @@ -952,15 +957,24 @@ function MusicKeyboard(activity) { } if (this.playingNow) { + const durationInSeconds = selectedNotes[counter].duration.map( + (beatDuration) => (beatDuration * 60 * 4) / this.bpm + ); + this._playChord( notes, - selectedNotes[counter].duration, + durationInSeconds, selectedNotes[counter].voice ); } - maxWidth = Math.max.apply(Math, selectedNotes[counter].duration); - this.playOne(counter + 1, maxWidth, playButtonCell); + maxDuration = Math.max( + ...selectedNotes[counter].duration.map( + (beatDuration) => (beatDuration * 60 * 4) / this.bpm + ) + ); + + this.playOne(counter + 1, maxDuration, playButtonCell); } else { playButtonCell.innerHTML = `