Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🚀 5.1.0 #321

Merged
merged 6 commits into from
Oct 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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