Merge PR #103: feat: choose temperature and hours for Ciepła woda teraz
CI / Check (gateway, backend, frontend) (push) Successful in 1m10s
CI / Check (gateway, backend, frontend) (push) Successful in 1m10s
This commit is contained in:
commit
b3fb7f8b40
8 files changed
+290
-56
No files matched your search
+3
-1
@@ -134,7 +134,9 @@ needs `{"until": "2026-11-02T18:00:00+01:00"}` (ISO-8601 with offset, example `s
|
||||
400 when it is missing, invalid, or sent to a preset that has none. Any preset also takes optional `"values"`
|
||||
(example `preset-away-values-request.json`): a whole number per setpoint of that preset, replacing the preset's value;
|
||||
the others keep it. 400 for a setpoint the preset does not change or a value outside the gateway's min, max and step
|
||||
(from `/commands`), checked before anything is sent; a held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
(from `/commands`), checked before anything is sent. A timed preset (built-in "Ciepła woda teraz") also takes optional
|
||||
`"hours"` (example `preset-cwu-now-request.json`): a whole number from 1 to 4 replacing its duration; anything else, or
|
||||
hours for a held preset, is 400 `{"error":"invalid_hours"}`. A held value that is not below the current one is 422. The gateway holds those changes until then without
|
||||
renewals (ADR-012) and rejects an end that is past or over 30 days away, or a change that does not lower the value (422).
|
||||
|
||||
`POST /api/refresh` (same Origin, header and JSON rules; send `{}`) is the "Odśwież" button: it asks the gateway for one
|
||||
|
||||
@@ -57,8 +57,11 @@ public class ApiServer {
|
||||
private record SetpointRequest(String setpoint, Integer value, Double hours) {
|
||||
}
|
||||
|
||||
/** {@code values}: the user's own value per setpoint of the preset, by setpoint name. */
|
||||
private record PresetRequest(String until, Map<String, Integer> values) {
|
||||
/**
|
||||
* {@code hours}: how long a timed preset lasts; {@code values}: the user's own value per setpoint of the
|
||||
* preset, by setpoint name.
|
||||
*/
|
||||
private record PresetRequest(String until, Integer hours, Map<String, Integer> values) {
|
||||
|
||||
PresetRequest {
|
||||
if (values == null) {
|
||||
@@ -316,14 +319,17 @@ public class ApiServer {
|
||||
throws IOException {
|
||||
String id = exchange.getRequestURI().getPath().substring(PRESETS_PATH.length());
|
||||
try {
|
||||
var request = body.length == 0 ? new PresetRequest(null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var request = body.length == 0 ? new PresetRequest(null, null, null) : JSON.readValue(body, PresetRequest.class);
|
||||
var until = request.until() == null ? null : OffsetDateTime.parse(request.until()).toInstant();
|
||||
var result = setpoints.applyPreset(id, until, request.values(), user(exchange));
|
||||
var result = setpoints.applyPreset(id, until, request.hours(), request.values(),
|
||||
user(exchange));
|
||||
if (result.isPresent()) {
|
||||
send(exchange, presetHttpStatus(result.get().status()), result.get());
|
||||
} else {
|
||||
send(exchange, 404, error("no such preset"));
|
||||
}
|
||||
} catch (SetpointService.InvalidHoursException e) {
|
||||
send(exchange, 400, error("invalid_hours"));
|
||||
} catch (JsonProcessingException | DateTimeParseException | IllegalArgumentException e) {
|
||||
send(exchange, 400, error("expected {\"until\": \"2026-11-02T18:00:00+01:00\"} for a preset that needs "
|
||||
+ "an end time, else no body; optional \"values\": {\"cwu_comfort\": 12} within the limits "
|
||||
|
||||
@@ -25,6 +25,16 @@ import java.util.stream.Collectors;
|
||||
/** F3: change setpoints directly or through a preset; the gateway enforces the limits (S1). */
|
||||
public class SetpointService {
|
||||
|
||||
/** Longest timed preset the user may choose; the gateway's own limit is higher. */
|
||||
public static final int MAX_PRESET_HOURS = 4;
|
||||
|
||||
/** The chosen hours are not a whole number from 1 to {@link #MAX_PRESET_HOURS}, or the preset is not timed. */
|
||||
public static class InvalidHoursException extends IllegalArgumentException {
|
||||
InvalidHoursException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
private final GatewayCommands gateway;
|
||||
private final List<Preset> presets;
|
||||
|
||||
@@ -53,12 +63,15 @@ public class SetpointService {
|
||||
* IOException only when no change was attempted; once writing starts, trouble is reported per change.
|
||||
*
|
||||
* @param until the end the user chose; required by a held preset, not allowed for any other
|
||||
* @param hours how long a timed preset keeps its changes, 1 to {@link #MAX_PRESET_HOURS}; null keeps the preset's
|
||||
* @param values the user's own value for some of the preset's setpoints (any preset); the rest keep the
|
||||
* preset's value
|
||||
* @throws InvalidHoursException when {@code hours} is out of range or the preset has no hours to choose
|
||||
* @throws IllegalArgumentException when {@code until} does not match what the preset needs, or a value is
|
||||
* for a setpoint the preset does not change or outside the gateway's limits
|
||||
*/
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Map<String, Integer> values, String user)
|
||||
public Optional<PresetResult> applyPreset(String id, Instant until, Integer hours, Map<String, Integer> values,
|
||||
String user)
|
||||
throws IOException {
|
||||
Optional<Preset> preset = presets.stream().filter(p -> p.id().equals(id)).findFirst();
|
||||
if (preset.isEmpty()) {
|
||||
@@ -68,7 +81,7 @@ public class SetpointService {
|
||||
throw new IllegalArgumentException(preset.get().isHeld() ? "preset " + id + " needs an end time"
|
||||
: "preset " + id + " has no end time to choose");
|
||||
}
|
||||
List<Change> changes = withValues(preset.get().changes(), values);
|
||||
List<Change> changes = withHours(withValues(preset.get().changes(), values), hours);
|
||||
List<Setpoint> limits = gateway.setpoints().setpoints();
|
||||
requireWithinLimits(values, limits);
|
||||
List<ChangeResult> checked = changes.stream().map(c -> check(c, preset.get().isHeld(), limits)).toList();
|
||||
@@ -87,6 +100,19 @@ public class SetpointService {
|
||||
c.hours())).toList();
|
||||
}
|
||||
|
||||
private static List<Change> withHours(List<Change> changes, Integer hours) {
|
||||
if (hours == null) {
|
||||
return changes;
|
||||
}
|
||||
if (hours < 1 || hours > MAX_PRESET_HOURS) {
|
||||
throw new InvalidHoursException("hours must be 1 to " + MAX_PRESET_HOURS);
|
||||
}
|
||||
if (changes.stream().anyMatch(c -> c.hours() == null)) {
|
||||
throw new InvalidHoursException("this preset has no hours to choose");
|
||||
}
|
||||
return changes.stream().map(c -> new Change(c.setpoint(), c.value(), hours.doubleValue())).toList();
|
||||
}
|
||||
|
||||
private static void requireWithinLimits(Map<String, Integer> values, List<Setpoint> limits) {
|
||||
for (Setpoint limit : limits) {
|
||||
Integer value = values.get(limit.name());
|
||||
|
||||
@@ -863,6 +863,25 @@ class ApiServerTest {
|
||||
assertEquals(200, post("/api/presets/cwu-now", "{}").statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void timedPresetTakesOwnHoursAndValue() throws Exception {
|
||||
var response = post("/api/presets/cwu-now", example("preset-cwu-now-request").toString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(3), "phone")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidHoursAre400AndSendNothing() throws Exception {
|
||||
for (String hours : List.of("0", "5", "-1", "2.5", "\"2\"")) {
|
||||
assertEquals(400, post("/api/presets/cwu-now", "{\"hours\":" + hours + "}").statusCode(), hours);
|
||||
}
|
||||
var held = post("/api/presets/away", "{\"until\":\"2026-11-02T18:00:00Z\",\"hours\":2}");
|
||||
assertEquals(400, held.statusCode());
|
||||
assertEquals("invalid_hours", JSON.readTree(held.body()).get("error").asText());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownPresetIs404() throws Exception {
|
||||
assertEquals(404, post("/api/presets/nope", "").statusCode());
|
||||
|
||||
@@ -127,15 +127,38 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void boostPresetSendsOneTimedCommand() throws Exception {
|
||||
var result = service.applyPreset("cwu-now", null, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("cwu-now", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.APPLIED, result.status());
|
||||
assertEquals(List.of(new Command("cwu_economy", 45, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void chosenHoursReplaceTheTimedPresetsOwn() throws Exception {
|
||||
service.applyPreset("cwu-now", null, 4, Map.of("cwu_economy", 47), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 47, Duration.ofHours(4), "anna")), sent);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hoursOutsideOneToFourAreRefusedBeforeAnythingIsSent() {
|
||||
for (int hours : List.of(0, 5, -1, 72)) {
|
||||
assertThrows(SetpointService.InvalidHoursException.class,
|
||||
() -> service.applyPreset("cwu-now", null, hours, Map.of(), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldPresetHasNoHoursToChoose() {
|
||||
assertThrows(SetpointService.InvalidHoursException.class,
|
||||
() -> service.applyPreset("away", RETURN, 2, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void awayPresetSendsBothChangesHeldUntilTheChosenTime() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(2, result.results().size());
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
@@ -144,7 +167,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void ownValuesReplaceTheDefaultsAndTheRestKeepThem() throws Exception {
|
||||
service.applyPreset("away", RETURN, Map.of("co_comfort", 16), "anna");
|
||||
service.applyPreset("away", RETURN, null, Map.of("co_comfort", 16), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_comfort", 10, null, RETURN, "anna"),
|
||||
new Command("co_comfort", 16, null, RETURN, "anna")), sent);
|
||||
@@ -152,7 +175,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void ownValuesAreAllowedForAnOrdinaryPresetToo() throws Exception {
|
||||
service.applyPreset("cwu-now", null, Map.of("cwu_economy", 40), "anna");
|
||||
service.applyPreset("cwu-now", null, null, Map.of("cwu_economy", 40), "anna");
|
||||
|
||||
assertEquals(List.of(new Command("cwu_economy", 40, Duration.ofHours(2), "anna")), sent);
|
||||
}
|
||||
@@ -160,7 +183,7 @@ class SetpointServiceTest {
|
||||
@Test
|
||||
void aValueForASetpointOutsideThePresetIsRefusedBeforeAnythingIsSent() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("cwu_economy", 6), "anna"));
|
||||
() -> service.applyPreset("away", RETURN, null, Map.of("cwu_economy", 6), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -168,14 +191,14 @@ class SetpointServiceTest {
|
||||
void aValueOutsideTheGatewayLimitsIsRefusedBeforeAnythingIsSent() {
|
||||
for (int value : List.of(4, 26, 0)) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.applyPreset("away", RETURN, Map.of("co_comfort", value), "anna"));
|
||||
() -> service.applyPreset("away", RETURN, null, Map.of("co_comfort", value), "anna"));
|
||||
}
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHeldValueThatDoesNotLowerTheCurrentOneWritesNothing() throws Exception {
|
||||
var result = service.applyPreset("away", RETURN, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of("co_comfort", 19), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -184,8 +207,8 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void aHeldPresetNeedsAnEndTimeAndOthersRefuseOne() {
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("away", null, null, Map.of(), "anna"));
|
||||
assertThrows(IllegalArgumentException.class, () -> service.applyPreset("cwu-now", RETURN, null, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -194,7 +217,7 @@ class SetpointServiceTest {
|
||||
var cold = new Preset("cold", "Zimno", "chosen", List.of(new Change("cwu_comfort", 10, null),
|
||||
new Change("co_comfort", 19, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(cold)).applyPreset("cold", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -204,7 +227,7 @@ class SetpointServiceTest {
|
||||
|
||||
@Test
|
||||
void unknownPresetIsEmpty() throws Exception {
|
||||
assertTrue(service.applyPreset("nope", null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(service.applyPreset("nope", null, null, Map.of(), "anna").isEmpty());
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
|
||||
@@ -213,7 +236,7 @@ class SetpointServiceTest {
|
||||
var tooHot = new Preset("hot", "Za gorąco", null, List.of(new Change("co_comfort", 15, null),
|
||||
new Change("cwu_economy", 60, 2.0)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(tooHot)).applyPreset("hot", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.NOT_ATTEMPTED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -225,7 +248,7 @@ class SetpointServiceTest {
|
||||
void presetWithUnknownSetpointWritesNothing() throws Exception {
|
||||
var odd = new Preset("odd", "Dziwny", null, List.of(new Change("garage_heater", 1, null)));
|
||||
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, Map.of(), "anna").orElseThrow();
|
||||
var result = new SetpointService(gateway, List.of(odd)).applyPreset("odd", null, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertTrue(sent.isEmpty());
|
||||
@@ -239,7 +262,7 @@ class SetpointServiceTest {
|
||||
void presetReportsEveryChangeAndStopsAfterTheFirstThatIsNotApplied() throws Exception {
|
||||
script.add(Status.FAILED);
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.FAILED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -250,7 +273,7 @@ class SetpointServiceTest {
|
||||
void secondChangeRejectedLeavesThePresetPartial() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, Status.REJECTED));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.REJECTED), statuses(result));
|
||||
@@ -260,7 +283,7 @@ class SetpointServiceTest {
|
||||
void firstChangeRejectedByTheGatewayIsRejectedAsAWhole() throws Exception {
|
||||
script.add(Status.REJECTED);
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.REJECTED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.REJECTED, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -270,7 +293,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheSecondChangeKeepsTheFirstResult() throws Exception {
|
||||
script.addAll(List.of(Status.APPLIED, new IOException("tunnel dropped")));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.PARTIAL, result.status());
|
||||
assertEquals(List.of(ChangeStatus.APPLIED, ChangeStatus.UNKNOWN), statuses(result));
|
||||
@@ -280,7 +303,7 @@ class SetpointServiceTest {
|
||||
void lostAnswerOnTheFirstChangeIsFailedNotUnreachable() throws Exception {
|
||||
script.add(new IOException("tunnel dropped"));
|
||||
|
||||
var result = service.applyPreset("away", RETURN, Map.of(), "anna").orElseThrow();
|
||||
var result = service.applyPreset("away", RETURN, null, Map.of(), "anna").orElseThrow();
|
||||
|
||||
assertEquals(PresetResult.Status.FAILED, result.status());
|
||||
assertEquals(List.of(ChangeStatus.UNKNOWN, ChangeStatus.NOT_ATTEMPTED), statuses(result));
|
||||
@@ -292,7 +315,7 @@ class SetpointServiceTest {
|
||||
|
||||
assertThrows(IOException.class, service::current);
|
||||
assertThrows(IOException.class, () -> service.apply("cwu_economy", 45, null, "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, Map.of(), "anna"));
|
||||
assertThrows(IOException.class, () -> service.applyPreset("away", RETURN, null, Map.of(), "anna"));
|
||||
assertTrue(sent.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "hours": 3, "values": { "cwu_economy": 47 } }
|
||||
@@ -210,8 +210,10 @@ describe("Settings", () => {
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the plain confirmation for a preset without a chosen end", async () => {
|
||||
const fetch = serve({ "api/setpoints": example("setpoints") }, () => answer(example("setpoints-preset-applied")));
|
||||
it("keeps the plain confirmation for a preset that is neither held nor timed", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.presets[0].changes[0].hours = null;
|
||||
const fetch = serve({ "api/setpoints": data }, () => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
|
||||
@@ -223,6 +225,94 @@ describe("Settings", () => {
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({});
|
||||
});
|
||||
|
||||
describe("form of Ciepła woda teraz", () => {
|
||||
const open = async (data = example<Setpoints>("setpoints"), pump?: HeatPump) => {
|
||||
const fetch = serve({ "api/setpoints": data, ...(pump && { "api/heat-pump": pump }) },
|
||||
() => answer(example("setpoints-preset-applied")));
|
||||
const Settings = await settings();
|
||||
render(<Settings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Ciepła woda teraz" }));
|
||||
return fetch;
|
||||
};
|
||||
const more = (times: number) => Array.from({ length: times }).reduce<Promise<void>>(
|
||||
(done) => done.then(() => userEvent.click(screen.getByLabelText("Więcej: Ciepła woda – oszczędnie"))), Promise.resolve());
|
||||
|
||||
it("starts at the preset's temperature and hours and sends them", async () => {
|
||||
const fetch = await open();
|
||||
|
||||
expect(screen.getByRole("status").textContent).toBe("45 °C");
|
||||
expect(screen.getByRole("button", { name: "2 h" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByText(paragraph("Ciepła woda do 45 °C przez 2 godz., potem wróci 8 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Podgrzej wodę" }));
|
||||
|
||||
expect(posts(fetch)[0][0]).toBe("api/presets/cwu-now");
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ hours: 2, values: { cwu_economy: 45 } });
|
||||
});
|
||||
|
||||
it("sends the temperature and hours the user chose", async () => {
|
||||
const fetch = await open();
|
||||
|
||||
await userEvent.click(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "4 h" }));
|
||||
expect(screen.getByRole("button", { name: "2 h" })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.getByText(paragraph("Ciepła woda do 44 °C przez 4 godz., potem wróci 8 °C."))).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Podgrzej wodę" }));
|
||||
|
||||
expect(JSON.parse(String(posts(fetch)[0][1]!.body))).toEqual({ hours: 4, values: { cwu_economy: 44 } });
|
||||
});
|
||||
|
||||
it("stays between just above the current value and the gateway's maximum", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints.find((s) => s.name === "cwu_economy")!.value = 44;
|
||||
data.presets[0].changes[0].value = 40;
|
||||
await open(data);
|
||||
|
||||
expect(screen.getByRole("status").textContent).toBe("45 °C");
|
||||
expect(screen.getByLabelText("Mniej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
await more(5);
|
||||
expect(screen.getByRole("status").textContent).toBe("48 °C");
|
||||
expect(screen.getByLabelText("Więcej: Ciepła woda – oszczędnie")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("cannot be confirmed when the water is already at the maximum", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.setpoints.find((s) => s.name === "cwu_economy")!.value = 48;
|
||||
await open(data);
|
||||
|
||||
expect(screen.getByText("Już na maksimum (48 °C), nie da się podnieść.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Podgrzej wodę" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("warns above 47 °C that the pump alone does not get there", async () => {
|
||||
await open();
|
||||
const hint = "Pompa sama dochodzi do ok. 47–48 °C (wyjście wyłącza się przy 52 °C)";
|
||||
|
||||
await more(2);
|
||||
expect(screen.queryByText(hint)).toBeNull();
|
||||
await more(1);
|
||||
expect(screen.getByText(hint)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the comfort-period note, for the chosen temperature", async () => {
|
||||
const pump = example<HeatPump>("heat-pump");
|
||||
await open(undefined, { ...pump, setpoints: { ...pump.setpoints, cwuComfort: 46 }, periods: { ...pump.periods, cwu: "comfort" } });
|
||||
|
||||
expect(await screen.findAllByText("Teraz strefa komfortu – woda i tak grzeje się do 46 °C")).toHaveLength(1);
|
||||
await more(2);
|
||||
expect(screen.getByText(/Teraz strefa komfortu \(46 °C\)\. Ustawienie podnosi też temperaturę oszczędną do 47 °C/))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on cancel without writing", async () => {
|
||||
const fetch = await open();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Anuluj" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Ciepła woda teraz" })).toBeInTheDocument();
|
||||
expect(posts(fetch)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a held change with its end date and the value that comes back", async () => {
|
||||
const data = example<Setpoints>("setpoints");
|
||||
data.restores = [{ setpoint: "cwu_comfort", register: 4144, originalValue: 43, appliedValue: 10, due: "2030-11-02T17:00:00Z" }];
|
||||
|
||||
+95
-28
@@ -85,6 +85,46 @@ function ResultBox({ result }: { result: Result }) {
|
||||
return <div role={ok ? "status" : "alert"} className={`mb-4 rounded-xl border-2 px-4 py-3 font-semibold ${tone}`}>{body}</div>;
|
||||
}
|
||||
|
||||
type Limits = Record<string, { min: number; max: number; step: number }>;
|
||||
|
||||
const limitsOf = (data: Setpoints, preset: Preset, range: (s: Setpoint) => { min: number; max: number }): Limits =>
|
||||
Object.fromEntries(preset.changes.map((c) => {
|
||||
const s = find(data, c.setpoint);
|
||||
return [c.setpoint, { ...range(s), step: s.step }];
|
||||
}));
|
||||
|
||||
// The preset's own values, moved into the limits; the user steps them from there.
|
||||
function usePresetValues(preset: Preset, limits: Limits) {
|
||||
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
|
||||
})));
|
||||
const stepValue = (id: string, direction: number) => {
|
||||
const { min, max, step } = limits[id];
|
||||
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
|
||||
};
|
||||
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
|
||||
return { values, stepValue, stuck };
|
||||
}
|
||||
|
||||
function ValueSteppers({ data, preset, limits, values, busy, stuckNote, onStep }: {
|
||||
data: Setpoints; preset: Preset; limits: Limits; values: Record<string, number>; busy: boolean;
|
||||
stuckNote: (current: string) => string; onStep: (id: string, direction: number) => void;
|
||||
}) {
|
||||
return preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
const label = name(c.setpoint);
|
||||
return (
|
||||
<div key={c.setpoint} className="my-3">
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
|
||||
onStep={(direction) => onStep(c.setpoint, direction)} />
|
||||
{max < min && <p className="text-sm text-alarm">{stuckNote(degrees(find(data, c.setpoint).value))}</p>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 3600_000;
|
||||
|
||||
// Away is held by the gateway until this moment, at most 30 days ahead (ADR-012).
|
||||
@@ -95,19 +135,8 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
data: Setpoints; preset: Preset; busy: boolean; onCancel: () => void;
|
||||
onConfirm: (until: string, values: Record<string, number>) => void;
|
||||
}) {
|
||||
const limits = Object.fromEntries(preset.changes.map((c) => {
|
||||
const s = find(data, c.setpoint);
|
||||
return [c.setpoint, { min: s.min, max: lowestAllowed(s), step: s.step }];
|
||||
}));
|
||||
const [values, setValues] = useState(() => Object.fromEntries(preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
return [c.setpoint, Math.max(min, Math.min(max, c.value))];
|
||||
})));
|
||||
const stuck = preset.changes.some((c) => limits[c.setpoint].max < limits[c.setpoint].min);
|
||||
const stepValue = (id: string, direction: number) => {
|
||||
const { min, max, step } = limits[id];
|
||||
setValues({ ...values, [id]: Math.max(min, Math.min(max, values[id] + direction * step)) });
|
||||
};
|
||||
const limits = limitsOf(data, preset, (s) => ({ min: s.min, max: lowestAllowed(s) }));
|
||||
const { values, stepValue, stuck } = usePresetValues(preset, limits);
|
||||
const [now] = useState(() => Date.now());
|
||||
const min = localInput(new Date(now + 3600_000));
|
||||
const max = localInput(new Date(now + 30 * DAY_MS));
|
||||
@@ -125,18 +154,8 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
<input type="datetime-local" value={until} min={min} max={max} onChange={(e) => setUntil(e.target.value)}
|
||||
className="mt-1 block min-h-12 w-full rounded-xl border-2 border-edge bg-panel px-3 text-lg" />
|
||||
</label>
|
||||
{preset.changes.map((c) => {
|
||||
const { min, max } = limits[c.setpoint];
|
||||
const label = name(c.setpoint);
|
||||
return (
|
||||
<div key={c.setpoint} className="my-3">
|
||||
<span className="block font-semibold">{label}</span>
|
||||
<Stepper label={label} value={values[c.setpoint]} min={min} max={max} format={degrees} disabled={busy || max < min}
|
||||
onStep={(direction) => stepValue(c.setpoint, direction)} />
|
||||
{max < min && <p className="text-sm text-alarm">Już na minimum ({degrees(find(data, c.setpoint).value)}), nie da się obniżyć.</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ValueSteppers data={data} preset={preset} limits={limits} values={values} busy={busy} onStep={stepValue}
|
||||
stuckNote={(current) => `Już na minimum (${current}), nie da się obniżyć.`} />
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => onConfirm(withOffset(until), values)} disabled={busy || !valid}>Ustaw wyjazd</Button>
|
||||
@@ -145,14 +164,58 @@ function ReturnForm({ data, preset, busy, onCancel, onConfirm }: {
|
||||
);
|
||||
}
|
||||
|
||||
// What the pump reaches on its own in the tank; above it only the CWU output (off at 52 °C) helps.
|
||||
const PUMP_ALONE_MAX = 47;
|
||||
const PRESET_HOURS = [1, 2, 3, 4];
|
||||
|
||||
// A timed preset raises: from just above the current value up to the gateway's maximum (none left: min > max).
|
||||
const raisable = (s: Setpoint) => ({ min: Math.max(s.min, s.value! + s.step), max: s.max });
|
||||
|
||||
const isCwu = (setpoint: string) => setpoint.startsWith("cwu_");
|
||||
|
||||
function TimedForm({ data, preset, hp, busy, onCancel, onConfirm }: {
|
||||
data: Setpoints; preset: Preset; hp: HeatPump | undefined; busy: boolean; onCancel: () => void;
|
||||
onConfirm: (hours: number, values: Record<string, number>) => void;
|
||||
}) {
|
||||
const limits = limitsOf(data, preset, raisable);
|
||||
const { values, stepValue, stuck } = usePresetValues(preset, limits);
|
||||
const presetHours = preset.changes[0].hours!;
|
||||
const [hours, setHours] = useState(Math.min(4, Math.max(1, Math.round(presetHours))));
|
||||
const chosen = preset.changes.map((c) => ({ ...c, value: values[c.setpoint] }));
|
||||
const above = chosen.some((c) => isCwu(c.setpoint) && c.value > PUMP_ALONE_MAX);
|
||||
return (
|
||||
<>
|
||||
{chosen.map((c) => (
|
||||
<p key={c.setpoint} className="my-2 text-lg font-semibold">
|
||||
{isCwu(c.setpoint) ? "Ciepła woda" : name(c.setpoint)} do <b>{degrees(c.value)}</b> przez {duration(hours)}, potem wróci {degrees(returnValue(data, c.setpoint))}.
|
||||
</p>
|
||||
))}
|
||||
<ValueSteppers data={data} preset={preset} limits={limits} values={values} busy={busy} onStep={stepValue}
|
||||
stuckNote={(current) => `Już na maksimum (${current}), nie da się podnieść.`} />
|
||||
{above && <p className="text-sm text-soft">Pompa sama dochodzi do ok. 47–48 °C (wyjście wyłącza się przy 52 °C)</p>}
|
||||
<ComfortNote changes={chosen} hp={hp} />
|
||||
<p className="mt-4 mb-2 font-semibold">Na jak długo</p>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{PRESET_HOURS.map((h) => (
|
||||
<Button key={h} aria-pressed={h === hours} disabled={busy} onClick={() => setHours(h)}>{h} h</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={onCancel} disabled={busy}>Anuluj</Button>
|
||||
<Button primary onClick={() => onConfirm(hours, values)} disabled={busy || stuck}>Podgrzej wodę</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// What a preset does, in one line under its button; a held preset has no fixed values (the user picks them).
|
||||
const presetHint = (p: Preset) => p.until
|
||||
? "Obniża temperatury na czas nieobecności, do dnia powrotu"
|
||||
: p.changes.map((c) => `${name(c.setpoint)}: ${degrees(c.value)}${c.hours == null ? " do odwołania" : ` na ${duration(c.hours)}`}`).join(", ");
|
||||
|
||||
// The controller works to the comfort value in a comfort period, whatever economy is set to.
|
||||
function ComfortNote({ preset, hp }: { preset: Preset; hp: HeatPump | undefined }) {
|
||||
const raised = preset.changes.find((c) => c.setpoint === "cwu_economy")?.value;
|
||||
function ComfortNote({ changes, hp }: { changes: Change[]; hp: HeatPump | undefined }) {
|
||||
const raised = changes.find((c) => c.setpoint === "cwu_economy")?.value;
|
||||
const comfort = hp?.setpoints.cwuComfort;
|
||||
if (raised == null || comfort == null || hp?.stale || hp?.periods.cwu !== "comfort") return null;
|
||||
return (
|
||||
@@ -244,6 +307,7 @@ export function Settings() {
|
||||
|
||||
const preset = data.presets.find((p) => p.id === presetId);
|
||||
if (preset) {
|
||||
const timed = preset.changes.every((c) => c.hours != null);
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
@@ -252,6 +316,9 @@ export function Settings() {
|
||||
{preset.until ? (
|
||||
<ReturnForm data={data} preset={preset} busy={busy} onCancel={cancel}
|
||||
onConfirm={(until, values) => send(`api/presets/${preset.id}`, { until, values }, preset.changes)} />
|
||||
) : timed ? (
|
||||
<TimedForm data={data} preset={preset} hp={hp} busy={busy} onCancel={cancel}
|
||||
onConfirm={(hours, values) => send(`api/presets/${preset.id}`, { hours, values }, preset.changes)} />
|
||||
) : (
|
||||
<>
|
||||
{preset.changes.map((c) => <p key={c.setpoint} className="my-2 text-lg font-semibold">{changeSentence(data, c, false)}</p>)}
|
||||
@@ -310,7 +377,7 @@ export function Settings() {
|
||||
{p.name}{ready ? "" : " (brak odczytu)"}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-soft">{presetHint(p)}</p>
|
||||
<ComfortNote preset={p} hp={hp} />
|
||||
<ComfortNote changes={p.changes} hp={hp} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user