Config UI: fix duration template param handling (#32572)
This commit is contained in:
parent
6f8a6b8c36
commit
afac7ab56d
14 changed files with 346 additions and 13 deletions
|
|
@ -8,6 +8,7 @@
|
||||||
:id="formId('modbusTimeout')"
|
:id="formId('modbusTimeout')"
|
||||||
property="timeout"
|
property="timeout"
|
||||||
type="Duration"
|
type="Duration"
|
||||||
|
:label="$t('config.modbus.timeout')"
|
||||||
class="me-2"
|
class="me-2"
|
||||||
:model-value="timeout"
|
:model-value="timeout"
|
||||||
@update:model-value="(v) => $emit('update:timeout', v)"
|
@update:model-value="(v) => $emit('update:timeout', v)"
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
:id="formId('modbusDelay')"
|
:id="formId('modbusDelay')"
|
||||||
property="delay"
|
property="delay"
|
||||||
type="Duration"
|
type="Duration"
|
||||||
|
:label="$t('config.modbus.delay')"
|
||||||
class="me-2"
|
class="me-2"
|
||||||
:model-value="delay"
|
:model-value="delay"
|
||||||
@update:model-value="(v) => $emit('update:delay', v)"
|
@update:model-value="(v) => $emit('update:delay', v)"
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@
|
||||||
id="loadpointEnableDelay"
|
id="loadpointEnableDelay"
|
||||||
v-model="values.thresholds.enable.delay"
|
v-model="values.thresholds.enable.delay"
|
||||||
type="Duration"
|
type="Duration"
|
||||||
|
legacy-duration
|
||||||
unit="minute"
|
unit="minute"
|
||||||
size="w-25 w-min-200"
|
size="w-25 w-min-200"
|
||||||
required
|
required
|
||||||
|
|
@ -255,6 +256,7 @@
|
||||||
id="loadpointDisableDelay"
|
id="loadpointDisableDelay"
|
||||||
v-model="values.thresholds.disable.delay"
|
v-model="values.thresholds.disable.delay"
|
||||||
type="Duration"
|
type="Duration"
|
||||||
|
legacy-duration
|
||||||
unit="minute"
|
unit="minute"
|
||||||
size="w-25 w-min-200"
|
size="w-25 w-min-200"
|
||||||
required
|
required
|
||||||
|
|
@ -519,6 +521,7 @@
|
||||||
id="loadpointPollInterval"
|
id="loadpointPollInterval"
|
||||||
v-model="values.soc.poll.interval"
|
v-model="values.soc.poll.interval"
|
||||||
type="Duration"
|
type="Duration"
|
||||||
|
legacy-duration
|
||||||
unit="minute"
|
unit="minute"
|
||||||
size="w-25 w-min-200"
|
size="w-25 w-min-200"
|
||||||
class="me-2"
|
class="me-2"
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,13 @@
|
||||||
/* oxlint-disable vue/prop-name-casing */
|
/* oxlint-disable vue/prop-name-casing */
|
||||||
import FormRow from "./FormRow.vue";
|
import FormRow from "./FormRow.vue";
|
||||||
import PropertyField from "./PropertyField.vue";
|
import PropertyField from "./PropertyField.vue";
|
||||||
|
import formatter from "@/mixins/formatter";
|
||||||
|
import { goDurationToUnit, goDurationUnit } from "@/utils/goDuration";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "PropertyEntry",
|
name: "PropertyEntry",
|
||||||
components: { FormRow, PropertyField },
|
components: { FormRow, PropertyField },
|
||||||
|
mixins: [formatter],
|
||||||
props: {
|
props: {
|
||||||
id: String,
|
id: String,
|
||||||
Name: String,
|
Name: String,
|
||||||
|
|
@ -66,8 +69,11 @@ export default {
|
||||||
return this.Description === this.Help ? undefined : this.Help;
|
return this.Description === this.Help ? undefined : this.Help;
|
||||||
},
|
},
|
||||||
example() {
|
example() {
|
||||||
// hide example text since config ui doesnt use go duration format (e.g. 5m)
|
// show duration example in its own unit, field unit is user-changeable
|
||||||
return this.Type === "Duration" ? undefined : this.Example;
|
const unit = this.Type === "Duration" ? goDurationUnit(this.Example) : null;
|
||||||
|
if (!unit) return this.Example;
|
||||||
|
const value = goDurationToUnit(this.Example, unit);
|
||||||
|
return `${value} ${this.fmtDurationUnit(value, unit)}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -122,8 +122,23 @@
|
||||||
</option>
|
</option>
|
||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
|
<CustomSelect
|
||||||
|
v-if="unitSelectable"
|
||||||
|
:id="id + '_unit_select'"
|
||||||
|
:options="unitOptions"
|
||||||
|
:selected="durationUnit"
|
||||||
|
:aria-label="$t('config.form.durationUnit', { label })"
|
||||||
|
@change="onUnitChange"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:id="id + '_unit'"
|
||||||
|
class="input-group-text h-100"
|
||||||
|
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
|
||||||
|
>{{ unitValue }}</span
|
||||||
|
>
|
||||||
|
</CustomSelect>
|
||||||
<span
|
<span
|
||||||
v-if="unitValue"
|
v-else-if="unitValue"
|
||||||
:id="id + '_unit'"
|
:id="id + '_unit'"
|
||||||
class="input-group-text"
|
class="input-group-text"
|
||||||
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
|
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
|
||||||
|
|
@ -136,14 +151,21 @@
|
||||||
import "@h2d2/shopicons/es/regular/minus";
|
import "@h2d2/shopicons/es/regular/minus";
|
||||||
import VehicleIcon from "../VehicleIcon";
|
import VehicleIcon from "../VehicleIcon";
|
||||||
import SelectGroup from "../Helper/SelectGroup.vue";
|
import SelectGroup from "../Helper/SelectGroup.vue";
|
||||||
|
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||||
import PropertyZonesField from "./PropertyZonesField.vue";
|
import PropertyZonesField from "./PropertyZonesField.vue";
|
||||||
import formatter from "@/mixins/formatter";
|
import formatter from "@/mixins/formatter";
|
||||||
|
import parseGoDuration, {
|
||||||
|
displayFactors,
|
||||||
|
durationUnits,
|
||||||
|
goDurationUnit,
|
||||||
|
toGoDuration,
|
||||||
|
} from "@/utils/goDuration";
|
||||||
|
|
||||||
const NS_PER_SECOND = 1000000000;
|
const NS_PER_SECOND = 1000000000;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "PropertyField",
|
name: "PropertyField",
|
||||||
components: { VehicleIcon, SelectGroup, PropertyZonesField },
|
components: { VehicleIcon, SelectGroup, CustomSelect, PropertyZonesField },
|
||||||
mixins: [formatter],
|
mixins: [formatter],
|
||||||
props: {
|
props: {
|
||||||
id: String,
|
id: String,
|
||||||
|
|
@ -152,6 +174,8 @@ export default {
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
type: String,
|
type: String,
|
||||||
unit: String,
|
unit: String,
|
||||||
|
// transitional: emit ns numbers, remove once all callers accept duration strings (loadpoint follow-up)
|
||||||
|
legacyDuration: Boolean,
|
||||||
size: String,
|
size: String,
|
||||||
scale: Number,
|
scale: Number,
|
||||||
required: Boolean,
|
required: Boolean,
|
||||||
|
|
@ -167,7 +191,7 @@ export default {
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue"],
|
emits: ["update:modelValue"],
|
||||||
data: () => {
|
data: () => {
|
||||||
return { selectMode: false };
|
return { selectMode: false, unitOverride: null };
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
patternRegex() {
|
patternRegex() {
|
||||||
|
|
@ -240,7 +264,7 @@ export default {
|
||||||
},
|
},
|
||||||
unitValue() {
|
unitValue() {
|
||||||
if (this.type === "Duration") {
|
if (this.type === "Duration") {
|
||||||
return this.fmtDurationUnit(this.value, this.unit);
|
return this.fmtDurationUnit(this.value, this.durationUnit);
|
||||||
}
|
}
|
||||||
if (this.pricePerKWh) {
|
if (this.pricePerKWh) {
|
||||||
return this.pricePerKWhUnit(this.currency);
|
return this.pricePerKWhUnit(this.currency);
|
||||||
|
|
@ -297,7 +321,19 @@ export default {
|
||||||
return this.choice.length > 0 || this.chargeModes;
|
return this.choice.length > 0 || this.chargeModes;
|
||||||
},
|
},
|
||||||
durationFactor() {
|
durationFactor() {
|
||||||
return this.unit === "minute" ? 60 : 1;
|
return displayFactors[this.durationUnit] ?? 1;
|
||||||
|
},
|
||||||
|
durationUnit() {
|
||||||
|
return this.unitOverride ?? goDurationUnit(this.modelValue) ?? this.unit ?? "second";
|
||||||
|
},
|
||||||
|
unitSelectable() {
|
||||||
|
return this.type === "Duration" && !this.legacyDuration && !this.disabled;
|
||||||
|
},
|
||||||
|
unitOptions() {
|
||||||
|
return durationUnits.map((value) => ({
|
||||||
|
value,
|
||||||
|
name: this.fmtDurationUnit(2, value),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
selectOptions() {
|
selectOptions() {
|
||||||
if (this.chargeModes) {
|
if (this.chargeModes) {
|
||||||
|
|
@ -343,8 +379,15 @@ export default {
|
||||||
return Array.isArray(this.modelValue) ? this.modelValue.join("\n") : "";
|
return Array.isArray(this.modelValue) ? this.modelValue.join("\n") : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.type === "Duration" && typeof this.modelValue === "number") {
|
if (this.type === "Duration") {
|
||||||
return this.modelValue / this.durationFactor / NS_PER_SECOND;
|
const ns =
|
||||||
|
typeof this.modelValue === "string"
|
||||||
|
? parseGoDuration(this.modelValue)
|
||||||
|
: this.modelValue;
|
||||||
|
if (typeof ns === "number") {
|
||||||
|
return ns / this.durationFactor / NS_PER_SECOND;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.pricePerKWh) {
|
if (this.pricePerKWh) {
|
||||||
|
|
@ -367,7 +410,9 @@ export default {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.type === "Duration" && typeof newValue === "number") {
|
if (this.type === "Duration" && typeof newValue === "number") {
|
||||||
newValue = newValue * this.durationFactor * NS_PER_SECOND;
|
newValue = this.legacyDuration
|
||||||
|
? newValue * this.durationFactor * NS_PER_SECOND
|
||||||
|
: toGoDuration(newValue, this.durationUnit);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.pricePerKWh) {
|
if (this.pricePerKWh) {
|
||||||
|
|
@ -385,6 +430,14 @@ export default {
|
||||||
}
|
}
|
||||||
return val;
|
return val;
|
||||||
},
|
},
|
||||||
|
onUnitChange(e) {
|
||||||
|
// read display value before override changes the getter's unit
|
||||||
|
const num = this.value;
|
||||||
|
this.unitOverride = e.target.value;
|
||||||
|
if (typeof num === "number") {
|
||||||
|
this.$emit("update:modelValue", toGoDuration(num, this.unitOverride));
|
||||||
|
}
|
||||||
|
},
|
||||||
onFieldChange(e) {
|
onFieldChange(e) {
|
||||||
// unparsable input (e.g. locale decimal separator mismatch)
|
// unparsable input (e.g. locale decimal separator mismatch)
|
||||||
if (e.target.validity?.badInput) return;
|
if (e.target.validity?.badInput) return;
|
||||||
|
|
|
||||||
76
assets/js/utils/goDuration.test.ts
Normal file
76
assets/js/utils/goDuration.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import parseGoDuration, { goDurationToUnit, goDurationUnit, toGoDuration } from "./goDuration";
|
||||||
|
|
||||||
|
describe("parseGoDuration", () => {
|
||||||
|
test("parses single unit", () => {
|
||||||
|
expect(parseGoDuration("24h")).toBe(24 * 3.6e12);
|
||||||
|
expect(parseGoDuration("30m")).toBe(30 * 6e10);
|
||||||
|
expect(parseGoDuration("45s")).toBe(45e9);
|
||||||
|
expect(parseGoDuration("500ms")).toBe(5e8);
|
||||||
|
expect(parseGoDuration("10us")).toBe(1e4);
|
||||||
|
expect(parseGoDuration("10µs")).toBe(1e4);
|
||||||
|
expect(parseGoDuration("100ns")).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses compound durations", () => {
|
||||||
|
expect(parseGoDuration("1h30m")).toBe(3.6e12 + 30 * 6e10);
|
||||||
|
expect(parseGoDuration("1m30s")).toBe(6e10 + 30e9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses decimals", () => {
|
||||||
|
expect(parseGoDuration("0.1s")).toBe(1e8);
|
||||||
|
expect(parseGoDuration("1.5h")).toBe(1.5 * 3.6e12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid input", () => {
|
||||||
|
expect(parseGoDuration("")).toBeNull();
|
||||||
|
expect(parseGoDuration("6")).toBeNull();
|
||||||
|
expect(parseGoDuration("1h30")).toBeNull();
|
||||||
|
expect(parseGoDuration("abc")).toBeNull();
|
||||||
|
expect(parseGoDuration("6 h")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("goDurationToUnit", () => {
|
||||||
|
test("converts to display unit", () => {
|
||||||
|
expect(goDurationToUnit("10s")).toBe(10);
|
||||||
|
expect(goDurationToUnit("5m")).toBe(300);
|
||||||
|
expect(goDurationToUnit("90s", "minute")).toBe(1.5);
|
||||||
|
expect(goDurationToUnit("3h", "hour")).toBe(3);
|
||||||
|
expect(goDurationToUnit("6", "hour")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toGoDuration", () => {
|
||||||
|
test("formats value with unit suffix", () => {
|
||||||
|
expect(toGoDuration(6, "hour")).toBe("6h");
|
||||||
|
expect(toGoDuration(90, "second")).toBe("90s");
|
||||||
|
expect(toGoDuration(30, "minute")).toBe("30m");
|
||||||
|
expect(toGoDuration(1.5, "hour")).toBe("1.5h");
|
||||||
|
expect(toGoDuration(0.1, "second")).toBe("0.1s");
|
||||||
|
expect(toGoDuration(0)).toBe("0s");
|
||||||
|
expect(toGoDuration(5, "fortnight")).toBe("5s");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("roundtrips through parseGoDuration", () => {
|
||||||
|
expect(parseGoDuration(toGoDuration(6, "hour"))).toBe(6 * 3.6e12);
|
||||||
|
expect(parseGoDuration(toGoDuration(1.5, "minute"))).toBe(90e9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("goDurationUnit", () => {
|
||||||
|
test("detects single-unit strings", () => {
|
||||||
|
expect(goDurationUnit("12h")).toBe("hour");
|
||||||
|
expect(goDurationUnit("90s")).toBe("second");
|
||||||
|
expect(goDurationUnit("30m")).toBe("minute");
|
||||||
|
expect(goDurationUnit("1.5h")).toBe("hour");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("null for composite, numbers, empty", () => {
|
||||||
|
expect(goDurationUnit("1h30m")).toBeNull();
|
||||||
|
expect(goDurationUnit("")).toBeNull();
|
||||||
|
expect(goDurationUnit(15000000000)).toBeNull();
|
||||||
|
expect(goDurationUnit("6")).toBeNull();
|
||||||
|
expect(goDurationUnit("500ms")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
48
assets/js/utils/goDuration.ts
Normal file
48
assets/js/utils/goDuration.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
const factors: Record<string, number> = {
|
||||||
|
ns: 1,
|
||||||
|
us: 1e3,
|
||||||
|
µs: 1e3,
|
||||||
|
ms: 1e6,
|
||||||
|
s: 1e9,
|
||||||
|
m: 6e10,
|
||||||
|
h: 3.6e12,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayFactors: Record<string, number> = { minute: 60, hour: 3600 };
|
||||||
|
|
||||||
|
export type DurationUnit = "second" | "minute" | "hour";
|
||||||
|
type DurationSuffix = "s" | "m" | "h";
|
||||||
|
|
||||||
|
const unitSuffixes: Record<DurationUnit, DurationSuffix> = { second: "s", minute: "m", hour: "h" };
|
||||||
|
const suffixUnits: Record<DurationSuffix, DurationUnit> = { s: "second", m: "minute", h: "hour" };
|
||||||
|
|
||||||
|
export const durationUnits = Object.keys(unitSuffixes) as DurationUnit[];
|
||||||
|
|
||||||
|
// display value in given Intl unit to go duration string ("6h", "90s", "1.5h")
|
||||||
|
export function toGoDuration(value: number, unit: string = "second"): string {
|
||||||
|
return `${value}${unitSuffixes[unit as DurationUnit] ?? "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intl unit of a single-unit go duration string ("12h" > "hour"), null for composite/number/empty
|
||||||
|
export function goDurationUnit(value: unknown): DurationUnit | null {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const match = value.match(/^\d+(?:\.\d+)?(s|m|h)$/);
|
||||||
|
return match ? suffixUnits[match[1] as DurationSuffix] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// go duration string to display value in given Intl unit (default second), null if invalid
|
||||||
|
export function goDurationToUnit(value: string, unit?: string): number | null {
|
||||||
|
const ns = parseGoDuration(value);
|
||||||
|
return ns === null ? null : ns / (displayFactors[unit ?? ""] ?? 1) / 1e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// go duration string ("24h", "1h30m", "0.1s") to nanoseconds, null if invalid
|
||||||
|
export default function parseGoDuration(value: string): number | null {
|
||||||
|
let total = 0;
|
||||||
|
let matched = "";
|
||||||
|
for (const [all, num, unit] of value.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) {
|
||||||
|
total += parseFloat(num!) * factors[unit!]!;
|
||||||
|
matched += all;
|
||||||
|
}
|
||||||
|
return matched && matched === value ? total : null;
|
||||||
|
}
|
||||||
|
|
@ -306,6 +306,7 @@
|
||||||
"form": {
|
"form": {
|
||||||
"danger": "Achtung",
|
"danger": "Achtung",
|
||||||
"deprecated": "veraltet",
|
"deprecated": "veraltet",
|
||||||
|
"durationUnit": "Zeiteinheit für {label}",
|
||||||
"example": "Beispiel",
|
"example": "Beispiel",
|
||||||
"optional": "optional"
|
"optional": "optional"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,7 @@
|
||||||
"form": {
|
"form": {
|
||||||
"danger": "Danger",
|
"danger": "Danger",
|
||||||
"deprecated": "deprecated",
|
"deprecated": "deprecated",
|
||||||
|
"durationUnit": "Time unit for {label}",
|
||||||
"example": "Example",
|
"example": "Example",
|
||||||
"optional": "optional"
|
"optional": "optional"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ params:
|
||||||
choice: ["grid"]
|
choice: ["grid"]
|
||||||
- name: host
|
- name: host
|
||||||
- name: cache
|
- name: cache
|
||||||
default: 10
|
default: 10s
|
||||||
render: |
|
render: |
|
||||||
type: custom
|
type: custom
|
||||||
power:
|
power:
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,12 @@ test("decimal input with german separator", async ({ page }) => {
|
||||||
await modal.getByRole("button", { name: "Show advanced settings" }).click();
|
await modal.getByRole("button", { name: "Show advanced settings" }).click();
|
||||||
|
|
||||||
// comma accepted as decimal separator
|
// comma accepted as decimal separator
|
||||||
const timeout = modal.getByLabel("Timeout");
|
const timeout = modal.getByRole("spinbutton", { name: "Timeout" });
|
||||||
await timeout.pressSequentially("0,5");
|
await timeout.pressSequentially("0,5");
|
||||||
await expect(timeout).toHaveValue("0.5");
|
await expect(timeout).toHaveValue("0.5");
|
||||||
|
|
||||||
// intermediate zeros survive typing
|
// intermediate zeros survive typing
|
||||||
const delay = modal.getByLabel("Delay");
|
const delay = modal.getByRole("spinbutton", { name: "Delay" });
|
||||||
await delay.pressSequentially("0.004");
|
await delay.pressSequentially("0.004");
|
||||||
await expect(delay).toHaveValue("0.004");
|
await expect(delay).toHaveValue("0.004");
|
||||||
await delay.blur();
|
await delay.blur();
|
||||||
|
|
|
||||||
25
tests/config-duration-demo.tpl.yaml
Normal file
25
tests/config-duration-demo.tpl.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
template: duration-demo
|
||||||
|
group: generic
|
||||||
|
products:
|
||||||
|
- description:
|
||||||
|
generic: Duration Demo Meter
|
||||||
|
params:
|
||||||
|
- name: usage
|
||||||
|
choice: ["grid"]
|
||||||
|
- name: shortduration
|
||||||
|
type: duration
|
||||||
|
default: 30s
|
||||||
|
example: 10s
|
||||||
|
description:
|
||||||
|
generic: Short duration
|
||||||
|
- name: longduration
|
||||||
|
type: duration
|
||||||
|
unit: hour
|
||||||
|
default: 3h
|
||||||
|
description:
|
||||||
|
generic: Long duration
|
||||||
|
render: |
|
||||||
|
type: custom
|
||||||
|
power:
|
||||||
|
source: const
|
||||||
|
value: 0
|
||||||
23
tests/config-duration-values.sql
Normal file
23
tests/config-duration-values.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE `configs` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT
|
||||||
|
, `class` integer
|
||||||
|
, `type` text
|
||||||
|
, `title` text
|
||||||
|
, `icon` text
|
||||||
|
, `product` text
|
||||||
|
, `value` text
|
||||||
|
);
|
||||||
|
CREATE TABLE `settings` (
|
||||||
|
`key` text
|
||||||
|
, `value` text
|
||||||
|
, PRIMARY KEY(`key`)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- longduration as duration string (UI-written format), shortduration as legacy nanosecond number
|
||||||
|
INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(1, 2, 'template', 'Duration Test', '', 'Duration Demo Meter', '{"template":"duration-demo","usage":"grid","longduration":"12h","shortduration":15000000000}');
|
||||||
|
|
||||||
|
INSERT INTO settings("key", value) VALUES('gridMeter', 'db:1');
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
94
tests/config-duration.spec.ts
Normal file
94
tests/config-duration.spec.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import { test, expect, type Page, type Locator } from "@playwright/test";
|
||||||
|
import { start, stop, restart, baseUrl } from "./evcc";
|
||||||
|
import { expectModalVisible, expectModalHidden } from "./utils";
|
||||||
|
|
||||||
|
const templateFlags = [
|
||||||
|
"--disable-auth",
|
||||||
|
"--template-type",
|
||||||
|
"meter",
|
||||||
|
"--template",
|
||||||
|
"tests/config-duration-demo.tpl.yaml",
|
||||||
|
];
|
||||||
|
|
||||||
|
test.use({ baseURL: baseUrl() });
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openGridMeterModal(page: Page): Promise<Locator> {
|
||||||
|
await page.goto("/#/config");
|
||||||
|
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
|
||||||
|
const modal = page.getByTestId("meter-modal");
|
||||||
|
await expectModalVisible(modal);
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("duration fields", async () => {
|
||||||
|
test("template defaults and roundtrip", async ({ page }) => {
|
||||||
|
await start(undefined, undefined, templateFlags);
|
||||||
|
await page.goto("/#/config");
|
||||||
|
await page.getByRole("button", { name: "Add grid meter" }).click();
|
||||||
|
const modal = page.getByTestId("meter-modal");
|
||||||
|
await expectModalVisible(modal);
|
||||||
|
await modal.getByLabel("Manufacturer").selectOption("Duration Demo Meter");
|
||||||
|
|
||||||
|
await expect(modal.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("30");
|
||||||
|
await expect(modal.getByText("Example: 10 seconds", { exact: true })).toBeVisible();
|
||||||
|
await expect(modal.getByLabel("Time unit for Short duration")).toHaveValue("second");
|
||||||
|
await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("3");
|
||||||
|
await expect(modal.getByLabel("Time unit for Long duration")).toHaveValue("hour");
|
||||||
|
|
||||||
|
await modal.getByRole("spinbutton", { name: "Long duration" }).fill("6");
|
||||||
|
await modal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(modal);
|
||||||
|
|
||||||
|
await restart(undefined, templateFlags);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// edited value and untouched default are both stored as duration strings
|
||||||
|
const reopened = await openGridMeterModal(page);
|
||||||
|
await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("6");
|
||||||
|
await expect(reopened.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("30");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("existing values as string and nanoseconds", async ({ page }) => {
|
||||||
|
await start(undefined, "config-duration-values.sql", templateFlags);
|
||||||
|
|
||||||
|
const modal = await openGridMeterModal(page);
|
||||||
|
await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12");
|
||||||
|
await expect(modal.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("15");
|
||||||
|
|
||||||
|
await modal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(modal);
|
||||||
|
|
||||||
|
await restart(undefined, templateFlags);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const reopened = await openGridMeterModal(page);
|
||||||
|
await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12");
|
||||||
|
await expect(reopened.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("15");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unit switching keeps number", async ({ page }) => {
|
||||||
|
await start(undefined, "config-duration-values.sql", templateFlags);
|
||||||
|
|
||||||
|
// stored "12h" drives the unit, ns number falls back to template default
|
||||||
|
const modal = await openGridMeterModal(page);
|
||||||
|
await expect(modal.getByLabel("Time unit for Long duration")).toHaveValue("hour");
|
||||||
|
await expect(modal.getByLabel("Time unit for Short duration")).toHaveValue("second");
|
||||||
|
|
||||||
|
await modal.getByLabel("Time unit for Long duration").selectOption("minutes");
|
||||||
|
await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12");
|
||||||
|
await modal.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expectModalHidden(modal);
|
||||||
|
|
||||||
|
await restart(undefined, templateFlags);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// stored "12m" restores the selected unit
|
||||||
|
const reopened = await openGridMeterModal(page);
|
||||||
|
await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12");
|
||||||
|
await expect(reopened.getByLabel("Time unit for Long duration")).toHaveValue("minute");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -489,6 +489,7 @@ params:
|
||||||
en: Battery storage unit index
|
en: Battery storage unit index
|
||||||
de: Nummer des Batteriespeichers
|
de: Nummer des Batteriespeichers
|
||||||
- name: delay
|
- name: delay
|
||||||
|
type: duration
|
||||||
description:
|
description:
|
||||||
en: Delay
|
en: Delay
|
||||||
de: Verzögerung
|
de: Verzögerung
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue