Skip to content

Commit

Permalink
🚀 5.1.0, #321
Browse files Browse the repository at this point in the history
  • Loading branch information
RobinTail authored Oct 26, 2024
2 parents 2e90b84 + 7bd4f63 commit 9ddbdf6
Show file tree
Hide file tree
Showing 26 changed files with 134 additions and 83 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Version 5

### 5.1.0

- Fixed compatibility issue with [Physical Button plugin](https://github.com/LuxuSam/PhysicalButton):
- OctoRelay v5 uses a new driver having exclusive pin reservation, so that two plugins could not operate same pin;
- This version releases the reservation immediately, enabling relay operation both using the UI and a physical button.
- Performance improvement for the countdown (remaining time formatting function):
- Attempting to fix user defined locale (`it_IT —> it-IT`) in order to preserve translations;
- Looking up for a suitable locale only once per time unit (memoization);
- This should make the countdown about 47 times more efficient.

### 5.0.3

- Fixed issue with incorrect locale format causing countdown failure and disability to cancel upcoming relay switch:
Expand Down
2 changes: 2 additions & 0 deletions octoprint_octorelay/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ def __init__(self, pin: int, inverted: bool, pin_factory=None):
self.pin = pin # GPIO pin
self.inverted = inverted # marks the relay as normally closed
self.handle = LED(pin, pin_factory=pin_factory, initial_value=inverted)
# release immediately, avoid lock, allow physical buttons to operate same relays:
self.handle.pin_factory.release_pins(self.handle, self.pin)

def __repr__(self) -> str:
return f"{type(self).__name__}(pin={self.pin},inverted={self.inverted},closed={self.is_closed()})"
Expand Down
45 changes: 28 additions & 17 deletions ui/__snapshots__/qa.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,26 @@ var closeIconHTML = '<span class="fa fa-close fa-sm"></span>';
var closeBtnHTML = \`<button id="\${closeBtnId}" type="button" class="close">\${closeIconHTML}</button>\`;
// helpers/countdown.ts
var formatDeadline = (time, locales = [LOCALE, void 0]) => {
var createNumberFormat = _.memoize(
(...[requested, options]) => {
const locales = [requested];
if (typeof requested === "string" && requested.includes("_")) {
locales.push(requested.replaceAll("_", "-"));
}
locales.push(void 0);
for (const locale of locales) {
try {
return new Intl.NumberFormat(locale, options);
} catch (error) {
console.warn(\`Failed to format time using \${locale} locale\`, error);
}
}
const format = (value) => \`\${value} \${options == null ? void 0 : options.unit}\${value === 1 ? "" : "s"}\`;
return { format };
},
(...[locale, options]) => [locale, options == null ? void 0 : options.unit, options == null ? void 0 : options.maximumFractionDigits].join("|")
);
var formatDeadline = (time) => {
let unit = "second";
let timeLeft = (time - Date.now()) / 1e3;
if (timeLeft >= 60) {
Expand All @@ -118,22 +137,14 @@ var formatDeadline = (time, locales = [LOCALE, void 0]) => {
unit = "hour";
}
const isLastMinute = unit === "minute" && timeLeft < 2;
const nonNegTimeLeft = Math.max(0, timeLeft);
for (const locale of locales) {
try {
const formattedTimeLeft = new Intl.NumberFormat(locale, {
style: "unit",
unitDisplay: "long",
minimumFractionDigits: isLastMinute ? 1 : 0,
maximumFractionDigits: isLastMinute ? 1 : 0,
unit
}).format(nonNegTimeLeft);
return \`in \${formattedTimeLeft}\`;
} catch (error) {
console.warn(\`Failed to format time using \${locale} locale\`, error);
}
}
return \`in \${nonNegTimeLeft} \${unit}\${nonNegTimeLeft === 1 ? "" : "s"}\`;
const formattedTimeLeft = createNumberFormat(LOCALE, {
style: "unit",
unitDisplay: "long",
minimumFractionDigits: isLastMinute ? 1 : 0,
maximumFractionDigits: isLastMinute ? 1 : 0,
unit
}).format(Math.max(0, timeLeft));
return \`in \${formattedTimeLeft}\`;
};
var getCountdownDelay = (deadline) => deadline - Date.now() > 12e4 ? 6e4 : 1e3;
var setCountdown = (selector, deadline) => {
Expand Down
1 change: 0 additions & 1 deletion ui/bindings.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { readFileSync } from "node:fs";
import { Window } from "happy-dom";
import { describe, test, expect } from "vitest";

describe("Knockout bindings", () => {
const document = new Window().document;
Expand Down
1 change: 0 additions & 1 deletion ui/helpers/actions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { elementMock, jQueryMock } from "../mocks/jQuery";
import { cancelTask, toggleRelay } from "./actions";
import { vi, describe, afterEach, test, expect } from "vitest";

describe("Actions", () => {
const apiMock = vi.fn();
Expand Down
59 changes: 35 additions & 24 deletions ui/helpers/countdown.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import MockDate from "mockdate";
import assert from "node:assert/strict";
import { elementMock, jQueryMock } from "../mocks/jQuery";
import { formatDeadline, getCountdownDelay, setCountdown } from "./countdown";
import {
describe,
vi,
beforeAll,
afterEach,
afterAll,
expect,
test,
} from "vitest";
import { lodashMock } from "../mocks/lodash";

describe("Countdown helpers", () => {
describe("Countdown helpers", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const setIntervalMock = vi.fn<(handler: () => void, delay: number) => void>(
() => "mockedInterval",
);
Expand All @@ -20,15 +13,24 @@ describe("Countdown helpers", () => {
Object.assign(global, {
LOCALE: "en",
$: jQueryMock,
_: lodashMock,
setInterval: setIntervalMock,
clearInterval: clearIntervalMock,
});

const { formatDeadline, getCountdownDelay, setCountdown } = await import(
"./countdown"
);

beforeAll(() => {
MockDate.set("2023-08-13T22:30:00");
});

afterEach(() => {
Object.assign(global, {
LOCALE: "en",
});
warnSpy.mockClear();
setIntervalMock.mockClear();
clearIntervalMock.mockClear();
elementMock.text.mockClear();
Expand All @@ -44,33 +46,42 @@ describe("Countdown helpers", () => {
"Should format the supplied UNIX timestamp having offset %s seconds",
(offset) => {
expect(formatDeadline(Date.now() + offset * 1000)).toMatchSnapshot();
expect(warnSpy).not.toHaveBeenCalled();
},
);

test.each([
[10000, "10 seconds"],
[60000, "1 minute"],
[-10000, "0 seconds"],
])(`should handle invalid locales %#`, (offset, label) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(
formatDeadline(Date.now() + offset, [
"invalid_locale_id",
"another_invalid_one",
]),
).toBe(`in ${label}`);
test("should handle invalid locales", () => {
Object.assign(global, {
LOCALE: "invalid_locale_id",
});
expect(formatDeadline(Date.now() + 10000)).toBe(`in 10 seconds`);
expect(warnSpy).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls).toEqual([
[
"Failed to format time using invalid_locale_id locale",
expect.any(Error),
],
[
"Failed to format time using another_invalid_one locale",
"Failed to format time using invalid-locale-id locale",
expect.any(Error),
],
]);
});

test.each([
[1000, "1 second"],
[10000, "10 seconds"],
])("should handle complete Intl malfunction", (offset, expected) => {
vi.spyOn(Intl, "NumberFormat").mockImplementation(() =>
assert.fail("Can not do this"),
);
expect(formatDeadline(Date.now() + offset)).toBe(`in ${expected}`);
expect(warnSpy).toHaveBeenCalledTimes(2);
expect(warnSpy.mock.calls).toEqual([
["Failed to format time using en locale", expect.any(Error)],
["Failed to format time using undefined locale", expect.any(Error)],
]);
});
});

describe("getCountdownDelay() helper", () => {
Expand Down
57 changes: 37 additions & 20 deletions ui/helpers/countdown.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
export const formatDeadline = (
time: number,
locales = [LOCALE, undefined],
): string => {
/**
* @desc Creating Intl.NumberFormat is relatively slow, therefore using memoize() per set of arguments
* @since 5.1.0 also iterating over the requested locale, fixed locale and default one, then falling back to custom
* */
const createNumberFormat = _.memoize(
(
...[requested, options]: Parameters<typeof Intl.NumberFormat>
): Pick<Intl.NumberFormat, "format"> => {
const locales = [requested];
if (typeof requested === "string" && requested.includes("_")) {
locales.push(requested.replaceAll("_", "-"));
}
locales.push(undefined);
for (const locale of locales) {
try {
return new Intl.NumberFormat(locale, options);
} catch (error) {
console.warn(`Failed to format time using ${locale} locale`, error);
}
}
const format = (value: number) =>
`${value} ${options?.unit}${value === 1 ? "" : "s"}`;
return { format };
},
(...[locale, options]: Parameters<typeof Intl.NumberFormat>) =>
[locale, options?.unit, options?.maximumFractionDigits].join("|"),
);

export const formatDeadline = (time: number): string => {
let unit: "second" | "minute" | "hour" = "second";
let timeLeft = (time - Date.now()) / 1000;
if (timeLeft >= 60) {
Expand All @@ -13,22 +38,14 @@ export const formatDeadline = (
unit = "hour";
}
const isLastMinute = unit === "minute" && timeLeft < 2;
const nonNegTimeLeft = Math.max(0, timeLeft);
for (const locale of locales) {
try {
const formattedTimeLeft = new Intl.NumberFormat(locale, {
style: "unit",
unitDisplay: "long",
minimumFractionDigits: isLastMinute ? 1 : 0,
maximumFractionDigits: isLastMinute ? 1 : 0,
unit,
}).format(nonNegTimeLeft);
return `in ${formattedTimeLeft}`;
} catch (error) {
console.warn(`Failed to format time using ${locale} locale`, error);
}
}
return `in ${nonNegTimeLeft} ${unit}${nonNegTimeLeft === 1 ? "" : "s"}`;
const formattedTimeLeft = createNumberFormat(LOCALE, {
style: "unit",
unitDisplay: "long",
minimumFractionDigits: isLastMinute ? 1 : 0,
maximumFractionDigits: isLastMinute ? 1 : 0,
unit,
}).format(Math.max(0, timeLeft));
return `in ${formattedTimeLeft}`;
};

export const getCountdownDelay = (deadline: number): number =>
Expand Down
1 change: 0 additions & 1 deletion ui/helpers/hints.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { countdownMock, deadlineMock, disposerMock } from "../mocks/countdown";
import { cancelMock } from "../mocks/actions";
import { elementMock, jQueryMock } from "../mocks/jQuery";
import { addTooltip, clearHints, addPopover, showHints } from "./hints";
import { describe, beforeAll, afterAll, afterEach, test, expect } from "vitest";

describe("Hints helpers", () => {
Object.assign(global, {
Expand Down
1 change: 0 additions & 1 deletion ui/helpers/narrowing.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { hasUpcomingTask } from "./narrowing";
import { describe, test, expect } from "vitest";

describe("Narrowing helpers", () => {
describe("hasUpcomingTask() helper", () => {
Expand Down
2 changes: 0 additions & 2 deletions ui/mocks/OctoRelayModel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { vi } from "vitest";

export const modelMock = vi.fn();

vi.mock("../model/OctoRelayModel", () => ({ OctoRelayViewModel: modelMock }));
2 changes: 0 additions & 2 deletions ui/mocks/actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { vi } from "vitest";

export const cancelMock = vi.fn();
export const toggleMock = vi.fn();

Expand Down
2 changes: 0 additions & 2 deletions ui/mocks/countdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { vi } from "vitest";

export const disposerMock = vi.fn();
export const countdownMock = vi.fn(() => disposerMock);
export const deadlineMock = vi.fn(() => "sample deadline");
Expand Down
2 changes: 0 additions & 2 deletions ui/mocks/hints.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { vi } from "vitest";

export const clearMock = vi.fn();
export const showMock = vi.fn();

Expand Down
2 changes: 1 addition & 1 deletion ui/mocks/jQuery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { vi, type Mock } from "vitest";
import type { Mock } from "vitest";

export const elementMock: Record<
| "toggle"
Expand Down
5 changes: 5 additions & 0 deletions ui/mocks/lodash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const lodashMock = {
memoize: vi.fn(
(fn: () => unknown, resolver: () => unknown) => resolver() && fn,
),
};
2 changes: 0 additions & 2 deletions ui/mocks/messageHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { vi } from "vitest";

export const handlerMock = vi.fn();

vi.mock("../model/messageHandler", () => ({
Expand Down
1 change: 0 additions & 1 deletion ui/model/OctoRelayModel.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { handlerMock } from "../mocks/messageHandler";
import type { OwnModel, OwnProperties } from "../types/OwnModel";
import { OctoRelayViewModel } from "./OctoRelayModel";
import { describe, test, vi, expect } from "vitest";

describe("OctoRelayViewModel", () => {
test("should set certain props of itself", () => {
Expand Down
1 change: 0 additions & 1 deletion ui/model/initOctoRelayModel.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { modelMock } from "../mocks/OctoRelayModel";
import { initOctoRelayModel } from "./initOctoRelayModel";
import { describe, test, expect } from "vitest";

describe("initOctorelayModel()", () => {
const registryMock: ViewModel[] = [];
Expand Down
2 changes: 1 addition & 1 deletion ui/model/messageHandler.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Mock } from "vitest";
import { elementMock, jQueryMock } from "../mocks/jQuery";
import type { OwnModel, OwnProperties } from "../types/OwnModel";
import { clearMock, showMock } from "../mocks/hints";
import { toggleMock } from "../mocks/actions";
import { makeMessageHandler } from "./messageHandler";
import { describe, vi, type Mock, afterEach, test, expect } from "vitest";

describe("makeMessageHandler()", () => {
Object.assign(global, {
Expand Down
5 changes: 3 additions & 2 deletions ui/octorelay.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { initOctoRelayModel } from "./model/initOctoRelayModel";
import { describe, vi, test, expect } from "vitest";
import { lodashMock } from "./mocks/lodash";

describe("Entrypoint", () => {
const jQueryMock = vi.fn();
Object.assign(global, {
$: jQueryMock,
_: lodashMock,
});
test("Should set the document onLoad handler", async () => {
const { initOctoRelayModel } = await import("./model/initOctoRelayModel");
await import("./octorelay");
expect(jQueryMock).toHaveBeenCalledWith(initOctoRelayModel);
});
Expand Down
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"devDependencies": {
"@tsconfig/node18": "^18.2.4",
"@types/jquery": "^3.5.30",
"@types/lodash": "^3.10.9",
"@types/node": "^22.5.2",
"@vitest/coverage-istanbul": "^2.0.5",
"eslint": "^9.12.0",
Expand Down
1 change: 0 additions & 1 deletion ui/qa.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { readFile } from "node:fs/promises";
import { describe, test, expect } from "vitest";

describe("QA", () => {
test.each(["css/octorelay.css", "js/octorelay.js"])(
Expand Down
4 changes: 3 additions & 1 deletion ui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"target": "ES6",
"module": "ES2022",
"moduleResolution": "Bundler",
"removeComments": true
"removeComments": true,
"allowUmdGlobalAccess": true,
"types": ["vitest/globals"]
},
"exclude": [
"node_modules"
Expand Down
Loading

0 comments on commit 9ddbdf6

Please sign in to comment.