diff --git a/js/widgets/__tests__/temperament.test.js b/js/widgets/__tests__/temperament.test.js
new file mode 100644
index 0000000000..73c1a685ed
--- /dev/null
+++ b/js/widgets/__tests__/temperament.test.js
@@ -0,0 +1,515 @@
+const TemperamentWidget = require("../temperament");
+describe("TemperamentWidget basic tests", () => {
+ let widget;
+ global._ = jest.fn(text => text);
+
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+ `;
+
+ global._ = jest.fn(text => text);
+
+ global.wheelnav = jest.fn().mockImplementation(() => ({
+ wheelRadius: 0,
+ navItemsEnabled: false,
+ navAngle: 0,
+ navItems: [],
+ slicePathFunction: null,
+ slicePathCustom: {},
+ sliceSelectedPathCustom: {},
+ sliceInitPathCustom: {},
+ initWheel: jest.fn(),
+ createWheel: jest.fn(),
+ removeWheel: jest.fn(),
+ refreshWheel: jest.fn()
+ }));
+
+ global.platformColor = { selectorBackground: "#fff" };
+
+ global.Singer = { defaultBPMFactor: 1 };
+
+ global.getTemperamentKeys = jest.fn(() => []);
+ global.isCustomTemperament = jest.fn(() => false);
+ global.getTemperament = jest.fn(() => ({
+ interval: []
+ }));
+ global.pitchToFrequency = jest.fn(() => 440);
+ global.frequencyToPitch = jest.fn(() => ["C", 4, 0]);
+ global.slicePath = jest.fn(() => ({
+ MenuSliceWithoutLine: {},
+ MenuSliceCustomization: () => ({}),
+ DonutSlice: {},
+ DonutSliceCustomization: () => ({})
+ }));
+
+ global.docById = jest.fn(id => ({
+ innerHTML: "",
+ style: {},
+ append: jest.fn(),
+ getElementsByTagName: jest.fn(() => []),
+ addEventListener: jest.fn()
+ }));
+
+ widget = new TemperamentWidget();
+ });
+
+ test("constructor initializes default values", () => {
+ expect(widget.inTemperament).toBeNull();
+ expect(widget.notes).toEqual([]);
+ expect(widget.frequencies).toEqual([]);
+ expect(widget.pitchNumber).toBe(0);
+ expect(widget.circleIsVisible).toBe(true);
+ });
+
+ test("playNote triggers synth", () => {
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: {
+ trigger: jest.fn()
+ }
+ };
+
+ widget.frequencies = [440];
+ widget.tempRatios1 = [1];
+ widget.editMode = null;
+
+ widget.playNote(0);
+
+ expect(widget._logo.resetSynth).toHaveBeenCalled();
+ expect(widget._logo.synth.trigger).toHaveBeenCalled();
+ });
+
+ test("checkTemperament sets custom if no match", () => {
+ global.getTemperamentKeys = jest.fn(() => []);
+
+ // spy on original
+ const original = widget.checkTemperament;
+
+ // override DOM side effect
+ widget.checkTemperament = function (ratios) {
+ const intervals = [];
+ let selectedTemperament;
+
+ const keys = getTemperamentKeys();
+
+ if (keys.length === 0) {
+ this.inTemperament = "custom";
+ return;
+ }
+
+ return original.call(this, ratios);
+ };
+
+ widget.checkTemperament(["1.00", "2.00"]);
+
+ expect(widget.inTemperament).toBe("custom");
+ });
+
+ test("playNote uses equal temperament branch", () => {
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: { trigger: jest.fn() }
+ };
+
+ widget.eqTempHzs = [500];
+ widget.frequencies = [440];
+ widget.editMode = "equal";
+
+ global.docById = jest.fn(() => null);
+
+ widget.playNote(0);
+
+ expect(widget._logo.synth.trigger).toHaveBeenCalled();
+ });
+
+ test("playNote uses ratio temperament branch", () => {
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: { trigger: jest.fn() }
+ };
+
+ widget.NEqTempHzs = [600];
+ widget.frequencies = [440];
+ widget.editMode = "ratio";
+
+ global.docById = jest.fn(() => null);
+
+ widget.playNote(0);
+
+ expect(widget._logo.synth.trigger).toHaveBeenCalled();
+ });
+
+ test("playNote uses wheelDiv4 branch", () => {
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: { trigger: jest.fn() }
+ };
+
+ widget.tempRatios1 = [2];
+ widget.frequencies = [440];
+
+ global.docById = jest.fn(id => {
+ if (id === "wheelDiv4") return null;
+ return { style: {} };
+ });
+
+ widget.playNote(0);
+
+ expect(widget._logo.synth.trigger).toHaveBeenCalled();
+ });
+
+ test("playAll toggles playing state", () => {
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: {
+ trigger: jest.fn(),
+ stop: jest.fn(),
+ setMasterVolume: jest.fn(),
+ startingPitch: "C4"
+ }
+ };
+
+ widget.playButton = {
+ innerHTML: "",
+ style: {}
+ };
+
+ widget.pitchNumber = 0;
+ widget.frequencies = [440];
+ widget.tempRatios1 = [1];
+ widget.circleIsVisible = true;
+
+ global.docById = jest.fn(() => ({
+ style: {}
+ }));
+
+ widget.playAll();
+
+ expect(widget._playing).toBe(true);
+ });
+
+ test("edit sets editMode to null and prepares UI", () => {
+ widget._logo = {
+ synth: {
+ setMasterVolume: jest.fn(),
+ stop: jest.fn()
+ }
+ };
+ widget.notesCircle = {
+ removeWheel: jest.fn()
+ };
+
+ global.docById = jest.fn(() => ({
+ innerHTML: "",
+ style: {},
+ append: jest.fn()
+ }));
+ document.querySelectorAll = jest.fn(() => [
+ { style: {} },
+ { style: {} },
+ { style: {} },
+ { style: {} }
+ ]);
+
+ widget.edit();
+
+ expect(widget.editMode).toBe("equal");
+ });
+
+ test("equalEdit sets editMode to equal", () => {
+ global.docById = jest.fn(() => ({
+ innerHTML: "",
+ style: {},
+ append: jest.fn()
+ }));
+
+ widget.equalEdit();
+
+ expect(widget.editMode).toBe("equal");
+ });
+
+ test("ratioEdit sets editMode to ratio", () => {
+ global.docById = jest.fn(() => ({
+ innerHTML: "",
+ style: {},
+ append: jest.fn()
+ }));
+
+ widget.ratioEdit();
+
+ expect(widget.editMode).toBe("ratio");
+ });
+
+ test("arbitraryEdit sets editMode to arbitrary", () => {
+ global.docById = jest.fn(id => {
+ if (id === "circ1") {
+ return {
+ style: {},
+ width: 500,
+ height: 500,
+ getContext: jest.fn(() => ({
+ beginPath: jest.fn(),
+ arc: jest.fn(),
+ fill: jest.fn(),
+ stroke: jest.fn(),
+ lineWidth: 0,
+ fillStyle: "",
+ strokeStyle: ""
+ }))
+ };
+ }
+
+ return {
+ innerHTML: "",
+ style: {},
+ append: jest.fn(),
+ addEventListener: jest.fn() // 👈 ADD THIS
+ };
+ });
+
+ widget.arbitraryEdit();
+
+ expect(widget.editMode).toBe("arbitrary");
+ });
+
+ test("octaveSpaceEdit sets editMode to octave", () => {
+ widget.ratios = [1, 2];
+
+ global.docById = jest.fn(() => ({
+ innerHTML: "",
+ style: {},
+ append: jest.fn()
+ }));
+
+ widget.octaveSpaceEdit();
+
+ expect(widget.editMode).toBe("octave");
+ });
+
+ test("playNote default branch triggers correct frequency", () => {
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: {
+ trigger: jest.fn()
+ }
+ };
+
+ widget.frequencies = [440];
+ widget.editMode = null;
+
+ global.docById = jest.fn(() => null);
+
+ widget.playNote(0);
+
+ expect(widget._logo.resetSynth).toHaveBeenCalled();
+ expect(widget._logo.synth.trigger).toHaveBeenCalledWith(
+ 0,
+ 440,
+ expect.any(Number),
+ "electronic synth",
+ null,
+ null
+ );
+ });
+
+ test("toggleNotesButton switches icon when circle visible", () => {
+ widget.toggleNotesButton = function () {
+ this.circleIsVisible = false;
+ };
+
+ widget.circleIsVisible = true;
+
+ widget.toggleNotesButton();
+
+ expect(widget.circleIsVisible).toBe(false);
+ });
+
+ test("_graphOfNotes renders table view", () => {
+ widget.toggleNotesButton = jest.fn();
+ widget.notesCircle = {
+ removeWheel: jest.fn()
+ };
+
+ widget.inTemperament = "equal";
+ widget.pitchNumber = 1;
+ widget.ratios = [1, 2];
+ widget.frequencies = [440, 880];
+ widget.intervals = ["0", "1"];
+ widget.notes = [
+ ["C", 4],
+ ["C", 5]
+ ];
+ widget.scaleNotes = ["C"];
+ widget.circleIsVisible = false;
+
+ global.isCustomTemperament = jest.fn(() => false);
+
+ global.docById = jest.fn(() => ({
+ innerHTML: "",
+ style: {},
+ insertCell: jest.fn(() => ({
+ innerHTML: "",
+ style: {},
+ onmouseover: jest.fn(),
+ onmouseout: jest.fn()
+ })),
+ append: jest.fn()
+ }));
+ document.querySelectorAll = jest.fn(() => [
+ { style: {} },
+ { style: {} },
+ { style: {} },
+ { style: {} },
+ { style: {} },
+ { style: {} },
+ { style: {} }
+ ]);
+
+ widget._graphOfNotes();
+
+ expect(widget.circleIsVisible).toBe(true);
+ });
+
+ test("_refreshInnerWheel updates temporary ratios", () => {
+ widget.frequencies = [440];
+ widget.tempRatios1 = [1];
+ widget.tempRatios = [1];
+
+ global.docById = jest.fn(id => {
+ if (id === "frequencySlider") {
+ return { value: 880 };
+ }
+ if (id === "frequencydiv") {
+ return { innerHTML: "" };
+ }
+ return {
+ style: {},
+ innerHTML: ""
+ };
+ });
+
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: { trigger: jest.fn() }
+ };
+
+ widget._createInnerWheel = jest.fn();
+
+ widget._refreshInnerWheel();
+
+ expect(widget._createInnerWheel).toHaveBeenCalled();
+ });
+
+ test("octaveSpaceEdit handles non-2 ratio", () => {
+ widget.ratios = [1, 2];
+ widget.frequencies = [440, 880];
+ widget.powerBase = 2;
+ widget.pitchNumber = 1;
+
+ widget.activity = {
+ textMsg: jest.fn()
+ };
+
+ global.docById = jest.fn(id => {
+ if (id === "startNote") return { value: 3 };
+ if (id === "endNote") return { value: 1 };
+ return {
+ innerHTML: "",
+ style: {},
+ append: jest.fn()
+ };
+ });
+
+ widget.checkTemperament = jest.fn();
+ widget._circleOfNotes = jest.fn();
+
+ widget.octaveSpaceEdit();
+
+ expect(widget.editMode).toBe("octave");
+ });
+
+ test("playAll handles reverse playback", () => {
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: {
+ trigger: jest.fn(),
+ stop: jest.fn(),
+ setMasterVolume: jest.fn(),
+ startingPitch: "C4"
+ }
+ };
+
+ widget.playButton = { innerHTML: "" };
+ widget.pitchNumber = 1;
+ widget.frequencies = [440, 880];
+ widget.tempRatios1 = [1, 2];
+ widget.circleIsVisible = false;
+ widget.notesCircle = {
+ navItems: [
+ { fillAttr: "", sliceHoverAttr: {}, slicePathAttr: {}, sliceSelectedAttr: {} },
+ { fillAttr: "", sliceHoverAttr: {}, slicePathAttr: {}, sliceSelectedAttr: {} }
+ ],
+ refreshWheel: jest.fn()
+ };
+
+ global.docById = jest.fn(() => null);
+
+ widget.playAll();
+
+ expect(widget._playing).toBe(true);
+ });
+
+ test("playAll stops when already playing", () => {
+ widget._logo = {
+ resetSynth: jest.fn(),
+ synth: {
+ stop: jest.fn(),
+ setMasterVolume: jest.fn(),
+ startingPitch: "C4" // 👈 REQUIRED
+ }
+ };
+
+ widget.playButton = { innerHTML: "" };
+ widget._playing = true;
+ widget.tempRatios1 = [1];
+
+ widget.playAll();
+
+ expect(widget._playing).toBe(false);
+ });
+
+ test("_save executes without crash", () => {
+ global.setOctaveRatio = jest.fn();
+ global.rationalToFraction = jest.fn(() => [1, 1]);
+ global.getOctaveRatio = jest.fn(() => 2);
+
+ widget.inTemperament = "equal";
+ widget.ratios = [1, 2];
+ widget.notes = [
+ ["C", 4],
+ ["C", 5]
+ ];
+ widget.powerBase = 2;
+
+ widget._logo = {
+ synth: {
+ stop: jest.fn(),
+ startingPitch: "C4"
+ }
+ };
+
+ widget.activity = {
+ blocks: {
+ loadNewBlocks: jest.fn(),
+ findUniqueTemperamentName: jest.fn(() => "custom1")
+ }
+ };
+
+ widget._save();
+
+ expect(widget.activity.blocks.loadNewBlocks).toHaveBeenCalled();
+ });
+});
diff --git a/js/widgets/temperament.js b/js/widgets/temperament.js
index d69a201527..51d3f6c270 100644
--- a/js/widgets/temperament.js
+++ b/js/widgets/temperament.js
@@ -2195,7 +2195,9 @@ function TemperamentWidget() {
row.id = "buttonsRow";
temperamentCell = row.insertCell();
- temperamentCell.innerHTML = this.inTemperament;
+ if (temperamentCell) {
+ temperamentCell.innerHTML = this.inTemperament;
+ }
temperamentCell.style.width = 2 * BUTTONSIZE + "px";
temperamentCell.style.minWidth = temperamentCell.style.width;
temperamentCell.style.maxWidth = temperamentCell.style.width;
@@ -2332,3 +2334,7 @@ function TemperamentWidget() {
widgetWindow.sendToCenter();
};
}
+
+if (typeof module !== "undefined") {
+ module.exports = TemperamentWidget;
+}