generated from coulomb/repo-seed
Harden reconciliation conflict handling
This commit is contained in:
165
dashboard/test/status-control.test.mjs
Normal file
165
dashboard/test/status-control.test.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
class FakeClassList {
|
||||
constructor() {
|
||||
this.values = new Set();
|
||||
}
|
||||
|
||||
toggle(name, enabled) {
|
||||
if (enabled) {
|
||||
this.values.add(name);
|
||||
} else {
|
||||
this.values.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.children = [];
|
||||
this.listeners = {};
|
||||
this.classList = new FakeClassList();
|
||||
this.className = "";
|
||||
this.textContent = "";
|
||||
this.value = "";
|
||||
this.disabled = false;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
}
|
||||
|
||||
append(...children) {
|
||||
this.children.push(...children);
|
||||
}
|
||||
|
||||
addEventListener(type, listener) {
|
||||
this.listeners[type] = listener;
|
||||
}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const styles = new Map();
|
||||
globalThis.document = {
|
||||
head: {
|
||||
append(element) {
|
||||
if (element.id) styles.set(element.id, element);
|
||||
},
|
||||
},
|
||||
getElementById(id) {
|
||||
return styles.get(id) ?? null;
|
||||
},
|
||||
createElement(tagName) {
|
||||
return new FakeElement(tagName);
|
||||
},
|
||||
};
|
||||
globalThis.window = {
|
||||
confirm: () => true,
|
||||
prompt: () => "",
|
||||
};
|
||||
globalThis.setTimeout = () => 0;
|
||||
}
|
||||
|
||||
installDom();
|
||||
|
||||
const {statusControl} = await import("../src/components/status-control.js");
|
||||
|
||||
function okResponse(overrides = {}) {
|
||||
return {
|
||||
target_type: "task",
|
||||
target_id: "00000000-0000-0000-0000-000000000001",
|
||||
actor: "dashboard",
|
||||
current_status: "todo",
|
||||
target_status: "in_progress",
|
||||
file_backed: true,
|
||||
archived_file: false,
|
||||
task_linked: true,
|
||||
reconciliation_class: "write_through",
|
||||
reason: "task status can be represented in the workplan task block",
|
||||
follow_up: "patch task block status and sync the DB from file",
|
||||
write_through_result: "applied",
|
||||
workplan_path: "workplans/STATE-WP-9999-demo.md",
|
||||
reconciliation_record_id: null,
|
||||
conflict: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("status control posts dashboard changes through reconciliation", async () => {
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
requests.push({url, options, body: JSON.parse(options.body)});
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => okResponse(),
|
||||
};
|
||||
};
|
||||
|
||||
const entity = {id: "00000000-0000-0000-0000-000000000001", status: "todo"};
|
||||
let saved = null;
|
||||
const root = statusControl({
|
||||
entity,
|
||||
type: "task",
|
||||
statuses: ["todo", "in_progress"],
|
||||
onSaved: (updated, result) => {
|
||||
saved = {updated, result};
|
||||
},
|
||||
});
|
||||
const [select, message] = root.children;
|
||||
|
||||
select.value = "in_progress";
|
||||
await select.listeners.change();
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].url, "http://127.0.0.1:8000/reconciliation/state-change");
|
||||
assert.equal(requests[0].body.target_type, "task");
|
||||
assert.equal(requests[0].body.target_status, "in_progress");
|
||||
assert.equal(requests[0].body.expected_current_status, "todo");
|
||||
assert.equal(requests[0].body.apply, true);
|
||||
assert.equal(entity.status, "in_progress");
|
||||
assert.equal(message.textContent, "synced");
|
||||
assert.equal(saved.result.write_through_result, "applied");
|
||||
});
|
||||
|
||||
test("status control keeps local state on reconciliation conflicts", async () => {
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, options) => {
|
||||
requests.push({url, options, body: JSON.parse(options.body)});
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => okResponse({
|
||||
current_status: "done",
|
||||
target_status: "in_progress",
|
||||
reconciliation_class: "deferred",
|
||||
reason: "cached task status changed from expected 'todo' to 'done'",
|
||||
follow_up: "refresh the dashboard and retry the state change if it is still intended",
|
||||
write_through_result: "not_applicable",
|
||||
reconciliation_record_id: "00000000-0000-0000-0000-000000000002",
|
||||
conflict: true,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const entity = {id: "00000000-0000-0000-0000-000000000001", status: "todo"};
|
||||
let saved = null;
|
||||
const root = statusControl({
|
||||
entity,
|
||||
type: "task",
|
||||
statuses: ["todo", "in_progress"],
|
||||
onSaved: () => {
|
||||
saved = true;
|
||||
},
|
||||
});
|
||||
const [select, message] = root.children;
|
||||
|
||||
select.value = "in_progress";
|
||||
await select.listeners.change();
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(entity.status, "todo");
|
||||
assert.equal(select.value, "todo");
|
||||
assert.equal(message.textContent, "out of sync");
|
||||
assert.equal(saved, null);
|
||||
});
|
||||
Reference in New Issue
Block a user