forked from retoor/devplacepy
Code Farm: per-plot auto-replant pause and crop-lock check; community sync and node work
Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -89,3 +89,70 @@ test("hello limits advertise the effective concurrency", () => {
|
||||
const limits = new Limits({ local: { maxConcurrent: 1 } });
|
||||
assert.deepEqual(limits.helloLimits(), { max_concurrent: 1 });
|
||||
});
|
||||
|
||||
function serverLimits(window, clock, local = {}) {
|
||||
const limits = new Limits({ clock, local });
|
||||
limits.applyServerSettings({ active_from: window.from, active_to: window.to, timezone: window.zone });
|
||||
return limits;
|
||||
}
|
||||
|
||||
test("the server window is evaluated in the server timezone", () => {
|
||||
const moment = () => new Date(Date.UTC(2026, 0, 15, 12, 0));
|
||||
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "UTC" }, moment).inActiveHours(), true);
|
||||
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), false);
|
||||
assert.equal(serverLimits({ from: "20:00", to: "23:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
|
||||
assert.equal(serverLimits({ from: "22:00", to: "06:00", zone: "America/New_York" }, moment).inActiveHours(), false);
|
||||
assert.equal(serverLimits({ from: "21:00", to: "06:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
|
||||
});
|
||||
|
||||
test("an invalid server timezone falls back to UTC and empty or equal bounds mean always", () => {
|
||||
const moment = () => new Date(Date.UTC(2026, 0, 15, 12, 0));
|
||||
assert.equal(serverLimits({ from: "09:00", to: "17:00", zone: "Not/AZone" }, moment).inActiveHours(), true);
|
||||
assert.equal(serverLimits({ from: "13:00", to: "17:00", zone: "Not/AZone" }, moment).inActiveHours(), false);
|
||||
assert.equal(serverLimits({ from: "", to: "", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
|
||||
assert.equal(serverLimits({ from: "10:00", to: "10:00", zone: "Asia/Tokyo" }, moment).inActiveHours(), true);
|
||||
const bare = new Limits({ clock: moment });
|
||||
bare.applyServerSettings({});
|
||||
assert.equal(bare.inActiveHours(), true);
|
||||
});
|
||||
|
||||
test("both the donor window and the server window must be open", () => {
|
||||
const noon = at(12);
|
||||
const open = { from: "00:00", to: "23:59", zone: "UTC" };
|
||||
const shut = { from: "00:00", to: "00:01", zone: "UTC" };
|
||||
assert.equal(serverLimits(open, noon, { activeFrom: "09:00", activeTo: "17:00" }).admit(0), null);
|
||||
assert.equal(serverLimits(shut, noon, { activeFrom: "09:00", activeTo: "17:00" }).admit(0), REFUSALS.OUTSIDE_HOURS);
|
||||
assert.equal(serverLimits(open, noon, { activeFrom: "13:00", activeTo: "17:00" }).admit(0), REFUSALS.OUTSIDE_HOURS);
|
||||
});
|
||||
|
||||
test("a settings_update replaces the server window", () => {
|
||||
const limits = serverLimits({ from: "13:00", to: "17:00", zone: "UTC" }, () => new Date(Date.UTC(2026, 0, 15, 12, 0)));
|
||||
assert.equal(limits.inActiveHours(), false);
|
||||
limits.applyServerSettings({ active_from: "", active_to: "", timezone: "UTC" });
|
||||
assert.equal(limits.inActiveHours(), true);
|
||||
});
|
||||
|
||||
const BASE = 1767225600;
|
||||
const STEP = 40009;
|
||||
const SAMPLES = 200;
|
||||
const PARITY = [
|
||||
{ from: "09:00", to: "17:00", zone: "Europe/Amsterdam", bits: "01010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000101010101000000101010100000010101010000001010101010000001010101010000" },
|
||||
{ from: "22:00", to: "06:00", zone: "America/New_York", bits: "00010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000010101010100000010101010000001010101000000101010101000000101010101000010101010100000010101010000001010101" },
|
||||
{ from: "08:30", to: "12:15", zone: "Asia/Kolkata", bits: "00000001010000000000101000000000010101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101000000000000101000000000010100000000001010000000000101010000000000101" },
|
||||
{ from: "00:00", to: "12:00", zone: "Pacific/Auckland", bits: "01101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101001010101010101101010101010110101010101010010101010101011010101010101101010101010100101010101010010101010" },
|
||||
{ from: "10:00", to: "10:00", zone: "UTC", bits: "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" },
|
||||
{ from: "09:00", to: "17:00", zone: "Not/AZone", bits: "01010000101010101000000101010100000010101010000001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010101010100001010101010000001010101000000101010100000010" },
|
||||
{ from: "23:00", to: "01:00", zone: "Australia/Lord_Howe", bits: "00000000000010000000000001010000000000001000000000000100000000000010000000000001010000000000001000000000000100000000000010000000000000010000000000001000000000000100000000000010000000000000010000000000" },
|
||||
{ from: "", to: "", zone: "Europe/Amsterdam", bits: "11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" },
|
||||
];
|
||||
|
||||
test("server window parity with the Python scheduler over 200 sampled instants", () => {
|
||||
for (const item of PARITY) {
|
||||
assert.equal(item.bits.length, SAMPLES);
|
||||
for (let index = 0; index < SAMPLES; index += 1) {
|
||||
const moment = new Date((BASE + index * STEP) * 1000);
|
||||
const limits = serverLimits({ from: item.from, to: item.to, zone: item.zone }, () => moment);
|
||||
assert.equal(limits.inActiveHours(), item.bits[index] === "1", `${item.from}-${item.to} ${item.zone} #${index}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = (path) => fileURLToPath(new URL(`../${path}`, import.meta.url));
|
||||
const manifest = JSON.parse(readFileSync(root("manifest.json"), "utf8"));
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
test("the manifest declares icons and the action icon, and every file is a PNG of its declared size", () => {
|
||||
assert.deepEqual(Object.keys(manifest.icons), ["16", "32", "48", "128"]);
|
||||
for (const [size, path] of Object.entries(manifest.icons)) {
|
||||
assert.ok(existsSync(root(path)), path);
|
||||
const data = readFileSync(root(path));
|
||||
assert.equal(data.subarray(0, 8).equals(PNG_SIGNATURE), true, path);
|
||||
assert.equal(data.subarray(12, 16).toString(), "IHDR", path);
|
||||
assert.equal(data.readUInt32BE(16), Number(size), `${path} width`);
|
||||
assert.equal(data.readUInt32BE(20), Number(size), `${path} height`);
|
||||
}
|
||||
for (const [size, path] of Object.entries(manifest.action.default_icon)) {
|
||||
assert.equal(manifest.icons[size], path);
|
||||
}
|
||||
});
|
||||
|
||||
test("build.sh ships the icons", () => {
|
||||
const script = readFileSync(root("build.sh"), "utf8");
|
||||
assert.match(script, /manifest\.json src ui icons README\.md/);
|
||||
assert.match(script, /\("src", "ui", "icons"\)/);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { describeState, displayLabel } from "../ui/popup.js";
|
||||
|
||||
test("a suspended state shows the end time in DD/MM/YYYY and says it retries", () => {
|
||||
const text = describeState({ state: "suspended", suspendedUntil: "2026-03-05T10:07:00Z", detail: "x" });
|
||||
assert.match(text, /^Suspended until \d{2}\/\d{2}\/2026 \d{2}:\d{2}, retrying automatically$/);
|
||||
assert.equal(describeState({ state: "suspended", suspendedUntil: "" }), "Suspended, retrying automatically");
|
||||
});
|
||||
|
||||
test("other states show detail then last error", () => {
|
||||
assert.equal(describeState({ state: "online", detail: "ok", lastError: "e" }), "ok");
|
||||
assert.equal(describeState({ state: "backoff", detail: "", lastError: "e" }), "e");
|
||||
});
|
||||
|
||||
test("the server label is the authority and the local label is the fallback", () => {
|
||||
assert.equal(displayLabel({ deviceLabel: "Server name" }, { label: "typed" }), "Server name");
|
||||
assert.equal(displayLabel({ deviceLabel: "" }, { label: "typed" }), "typed");
|
||||
assert.equal(displayLabel({}, { label: "typed" }), "typed");
|
||||
});
|
||||
@@ -78,6 +78,8 @@ class FakeTimers {
|
||||
}
|
||||
}
|
||||
|
||||
const NOW = Date.parse("2026-01-01T00:00:00Z");
|
||||
|
||||
function build(overrides = {}) {
|
||||
FakeSocket.instances = [];
|
||||
const timers = new FakeTimers();
|
||||
@@ -104,6 +106,7 @@ function build(overrides = {}) {
|
||||
WebSocketImpl: FakeSocket,
|
||||
timers,
|
||||
random: overrides.random || (() => 0.5),
|
||||
clock: () => NOW,
|
||||
clientVersion: "9.9.9",
|
||||
platform: "firefox",
|
||||
});
|
||||
@@ -321,3 +324,109 @@ test("settings_update can only lower the effective limits", async () => {
|
||||
assert.equal(context.limits.concurrency, 1);
|
||||
assert.equal(context.limits.bandwidthBytes, 10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
function suspendedTicket(until) {
|
||||
return async () =>
|
||||
new Response(JSON.stringify({ error: { status: 403, message: "x", reason: "suspended", until } }), {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
test("a suspended ticket waits until the end instead of halting, then retries", async () => {
|
||||
const context = build({ random: () => 0, ticketFetch: suspendedTicket("2026-01-01T01:00:00Z") });
|
||||
await context.client.start();
|
||||
const snapshot = context.state.snapshot();
|
||||
assert.equal(snapshot.state, STATES.SUSPENDED);
|
||||
assert.equal(snapshot.suspendedUntil, "2026-01-01T01:00:00.000Z");
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, 3600 * 1000);
|
||||
assert.equal(context.client.wanted, true);
|
||||
await context.client.ensure();
|
||||
assert.equal(FakeSocket.instances.length, 0);
|
||||
});
|
||||
|
||||
test("suspension waits are clamped and an unusable until waits five minutes", async () => {
|
||||
const cases = [
|
||||
["2025-12-31T00:00:00Z", 60],
|
||||
["2030-01-01T00:00:00Z", 24 * 3600],
|
||||
["", 300],
|
||||
["tomorrow", 300],
|
||||
];
|
||||
for (const [until, seconds] of cases) {
|
||||
const context = build({ random: () => 0, ticketFetch: suspendedTicket(until) });
|
||||
await context.client.start();
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, seconds * 1000, until);
|
||||
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
|
||||
}
|
||||
const jittered = build({ random: () => 1, ticketFetch: suspendedTicket("") });
|
||||
await jittered.client.start();
|
||||
assert.equal(jittered.timers.timeouts.at(-1).ms, 330 * 1000);
|
||||
});
|
||||
|
||||
test("the retry after a suspension asks for a new ticket", async () => {
|
||||
let calls = 0;
|
||||
const context = build({
|
||||
random: () => 0,
|
||||
ticketFetch: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return suspendedTicket("2026-01-01T00:02:00Z")();
|
||||
}
|
||||
return new Response(JSON.stringify({ ticket: "tk2", ws_path: "/nodes/ws" }), { status: 200 });
|
||||
},
|
||||
});
|
||||
await context.client.start();
|
||||
context.timers.timeouts.at(-1).fn();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(FakeSocket.instances.length, 1);
|
||||
});
|
||||
|
||||
test("a revoked ticket or other 403 reason still halts", async () => {
|
||||
const context = build({
|
||||
ticketFetch: async () =>
|
||||
new Response(JSON.stringify({ error: { status: 403, message: "x", reason: "revoked" } }), { status: 403 }),
|
||||
});
|
||||
await context.client.start();
|
||||
assert.equal(context.state.snapshot().state, STATES.HALTED);
|
||||
});
|
||||
|
||||
test("a suspended kick with until closes the socket and holds until then", async () => {
|
||||
const context = build({ random: () => 0 });
|
||||
const socket = await online(context);
|
||||
await socket.receive({ type: "kick", reason: "suspended", until: "2026-01-01T00:10:00Z" });
|
||||
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, 600 * 1000);
|
||||
assert.equal(socket.closed.code, 1000);
|
||||
assert.equal(context.timers.intervals[0].cleared, true);
|
||||
});
|
||||
|
||||
test("a suspended kick without until waits five minutes", async () => {
|
||||
const context = build({ random: () => 0 });
|
||||
const socket = await online(context);
|
||||
await socket.receive({ type: "kick", reason: "suspended" });
|
||||
assert.equal(context.timers.timeouts.at(-1).ms, 300 * 1000);
|
||||
});
|
||||
|
||||
test("a close 4403 with reason suspended holds, any other 4403 halts", async () => {
|
||||
const context = build({ random: () => 0 });
|
||||
const socket = await online(context);
|
||||
await socket.serverClose(4403, "suspended");
|
||||
assert.equal(context.state.snapshot().state, STATES.SUSPENDED);
|
||||
});
|
||||
|
||||
test("welcome stores the server device label and clears an old suspension", async () => {
|
||||
const context = build();
|
||||
await context.state.set({ suspendedUntil: "2026-01-01T00:00:00.000Z" });
|
||||
await context.client.start();
|
||||
const socket = FakeSocket.instances[0];
|
||||
socket.open();
|
||||
await socket.receive(welcome({ device_label: "Work laptop" }));
|
||||
assert.equal(context.state.snapshot().deviceLabel, "Work laptop");
|
||||
assert.equal(context.state.snapshot().suspendedUntil, "");
|
||||
});
|
||||
|
||||
test("a welcome without a label leaves the label empty so the popup falls back", async () => {
|
||||
const context = build();
|
||||
await online(context);
|
||||
assert.equal(context.state.snapshot().deviceLabel, "");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user