feat(drift-trio): phase C — scenarios S2–S8 + re-resolve-on-fiber hooks
drift-trio-scenarios.spec.ts: disjoint ping-pong, same-item interleave (no settle between bursts), conflict pairs (move-vs-delete / value-vs-value / move-vs-move — winner is CRDT policy, asserted only as convergence + drift silence), undo storm, 12-edit burst churn, late-joiner adopt, and Ctrl+S mid peer burst; per-tool adapters, marker-waits before every sweep (finding #7). S4 exposed finding #9: mutation hooks resolved item pointers at call time and committed later on the fiber — a remote remove in between frees the pointer (doApplyItems) and the commit resurrects the deleted item. Phase-B mutation hooks now re-resolve by uuid ON the fiber; a vanished item makes the mutation lose silently. applyDeltaToY's concurrent update/delete verified coherent (full resurrect or full remove) — no shared change needed. trio.ts: TabSet oracles + exported startV2 for duo/late-join composition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5cAM9M6q34n5X4dbrfVvi
This commit is contained in:
parent
71a575d2d8
commit
23b0f43e73
4 changed files with 456 additions and 31 deletions
390
tests/kicad/drift-trio-scenarios.spec.ts
Normal file
390
tests/kicad/drift-trio-scenarios.spec.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import {
|
||||
TRIO_PCB,
|
||||
TRIO_SCH,
|
||||
type TabSet,
|
||||
type ToolCfg,
|
||||
type Trio,
|
||||
SYM1,
|
||||
FP1,
|
||||
VIA1,
|
||||
WIRE1,
|
||||
bootOpen,
|
||||
callHook,
|
||||
closeTrio,
|
||||
getPos,
|
||||
hasAbort,
|
||||
modelText,
|
||||
openTrio,
|
||||
oracleSweep,
|
||||
settleConverged,
|
||||
startV2,
|
||||
undoDepth,
|
||||
} from "./utils/trio";
|
||||
|
||||
/**
|
||||
* Drift trio harness — scenarios S2–S8 (standalone-hardening 0008 §6, phase C).
|
||||
*
|
||||
* S1 (per-action baseline + full catalogs) lives in drift-trio.spec.ts; this
|
||||
* file drives the CONCURRENT shapes: interleaved bursts with no settle between
|
||||
* them, conflicting edits on the same item, undo storms, churn, late joiners,
|
||||
* and save interplay. Assertions for conflicts are convergence + drift silence
|
||||
* — the winner is whatever the CRDT resolves, never a specific outcome.
|
||||
*
|
||||
* Concurrency pattern: fire both actors' sequences via Promise.all (each
|
||||
* actor's hooks run sequentially on its own tab), then wait for MARKER content
|
||||
* to land on every tab before settling — settleConverged alone can pass on the
|
||||
* pre-action state (finding #7), and in concurrent scenarios there is no
|
||||
* single actor save to gate on.
|
||||
*/
|
||||
|
||||
test.beforeAll(() => {
|
||||
execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit" });
|
||||
});
|
||||
|
||||
function skipFirefox(): void {
|
||||
test.skip(
|
||||
test.info().project.name.includes("firefox"),
|
||||
"three heavy wasm tabs exceed Firefox's per-process wasm budget",
|
||||
);
|
||||
}
|
||||
|
||||
/** Poll until every tab's silent save contains `marker`. */
|
||||
async function waitAllContain(set: TabSet, cfg: ToolCfg, marker: string, timeout = 30000): Promise<void> {
|
||||
for (const [label, page] of set.tabs) {
|
||||
await expect
|
||||
.poll(async () => (await modelText(page, cfg)).includes(marker), {
|
||||
timeout,
|
||||
intervals: [400],
|
||||
message: `${label} must receive "${marker}"`,
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-tool adapter for the scenario scripts. */
|
||||
interface ToolOps {
|
||||
cfg: ToolCfg;
|
||||
label: string;
|
||||
/** The symbol / footprint — the property-carrying primary item. */
|
||||
primary: string;
|
||||
/** A second fixture item (wire / via) for disjoint + move-move conflicts. */
|
||||
secondary: string;
|
||||
mm: number; // IU per mm
|
||||
move(page: Page, uuid: string, dxIU: number): Promise<boolean>;
|
||||
setValue(page: Page, text: string): Promise<boolean>;
|
||||
/** Add a text-ish marker item carrying `text`; returns its uuid. */
|
||||
addMarker(page: Page, text: string, slot: number): Promise<string>;
|
||||
}
|
||||
|
||||
const SCH_OPS: ToolOps = {
|
||||
cfg: TRIO_SCH,
|
||||
label: "eeschema",
|
||||
primary: SYM1,
|
||||
secondary: WIRE1,
|
||||
mm: 10000,
|
||||
move: (p, uuid, dx) => callHook<boolean>(p, "kicadCollabTestMoveSchItem", uuid, dx, 0),
|
||||
setValue: (p, text) => callHook<boolean>(p, "kicadCollabTestSetFieldText", SYM1, text),
|
||||
addMarker: (p, text, slot) =>
|
||||
callHook<string>(p, "kicadCollabTestAddLabel", "label", text, 400000 + slot * 60000, 400000),
|
||||
};
|
||||
|
||||
const PCB_OPS: ToolOps = {
|
||||
cfg: TRIO_PCB,
|
||||
label: "pcbnew",
|
||||
primary: FP1,
|
||||
secondary: VIA1,
|
||||
mm: 1000000,
|
||||
move: (p, uuid, dx) => callHook<boolean>(p, "kicadCollabTestMoveBoardItem", uuid, dx, 0),
|
||||
setValue: (p, text) => callHook<boolean>(p, "kicadCollabTestSetFootprintField", FP1, "Value", text),
|
||||
addMarker: (p, text, slot) =>
|
||||
callHook<string>(p, "kicadCollabTestAddBoardText", text, 20000000 + slot * 15000000, 130000000, "F.SilkS"),
|
||||
};
|
||||
|
||||
for (const ops of [SCH_OPS, PCB_OPS]) {
|
||||
const { cfg, label } = ops;
|
||||
|
||||
test.describe(`drift trio scenarios — ${label}`, () => {
|
||||
test.describe.configure({ timeout: 900000 });
|
||||
|
||||
// ── S2: disjoint ping-pong ───────────────────────────────────────────────
|
||||
test(`${label} S2: A and B alternate disjoint edits, 3 rounds`, async ({
|
||||
context,
|
||||
testLogger,
|
||||
}) => {
|
||||
skipFirefox();
|
||||
const trio = await openTrio(context, cfg, `s2-${label}-${test.info().workerIndex}`);
|
||||
|
||||
for (let round = 0; round < 3; round++) {
|
||||
await test.step(`round ${round + 1}`, async () => {
|
||||
// Landed-gate per actor (finding #7), then settle covers cross-tab.
|
||||
const beforeA = await getPos(trio.A, ops.primary);
|
||||
const beforeB = await getPos(trio.B, ops.secondary);
|
||||
await Promise.all([
|
||||
ops.move(trio.A, ops.primary, ops.mm),
|
||||
ops.move(trio.B, ops.secondary, ops.mm),
|
||||
]);
|
||||
await expect
|
||||
.poll(() => getPos(trio.A, ops.primary), { timeout: 15000, intervals: [300] })
|
||||
.not.toBe(beforeA);
|
||||
await expect
|
||||
.poll(() => getPos(trio.B, ops.secondary), { timeout: 15000, intervals: [300] })
|
||||
.not.toBe(beforeB);
|
||||
await settleConverged(trio, cfg);
|
||||
await oracleSweep(trio, cfg);
|
||||
});
|
||||
}
|
||||
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
await closeTrio(trio);
|
||||
});
|
||||
|
||||
// ── S3: same-item interleave (the 0008 headline scenario) ────────────────
|
||||
test(`${label} S3: A creates an item; B moves+renames it while A keeps editing around it`, async ({
|
||||
context,
|
||||
testLogger,
|
||||
}) => {
|
||||
skipFirefox();
|
||||
const trio = await openTrio(context, cfg, `s3-${label}-${test.info().workerIndex}`);
|
||||
|
||||
// A creates the contested item and everyone sees it.
|
||||
let target: string;
|
||||
if (label === "eeschema") {
|
||||
target = await callHook<string>(trio.A, "kicadCollabTestAddSymbol", "Device:R", 800000, 800000, "R9");
|
||||
} else {
|
||||
target = await callHook<string>(trio.A, "kicadCollabTestDuplicateBoardItem", FP1, 15000000, 0);
|
||||
}
|
||||
expect(target).toMatch(/[0-9a-f-]{36}/);
|
||||
await waitAllContain(trio, cfg, target);
|
||||
|
||||
// Interleaved bursts, NO settle in between: B moves the new item and
|
||||
// renames the primary's value while A wires/tracks around the new item.
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
if (label === "eeschema") {
|
||||
await callHook<string>(trio.A, "kicadCollabTestAddWire", 760000, 800000, 800000, 800000);
|
||||
await callHook<string>(trio.A, "kicadCollabTestAddWire", 800000, 838100, 800000, 880000);
|
||||
} else {
|
||||
await callHook<string>(trio.A, "kicadCollabTestAddTrack", 110000000, 100000000, 115000000, 100000000, 300000, "F.Cu");
|
||||
await callHook<string>(trio.A, "kicadCollabTestAddVia", 113000000, 100000000, 800000, 400000);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
await ops.move(trio.B, target, 2 * ops.mm);
|
||||
await ops.setValue(trio.B, "s3-renamed");
|
||||
})(),
|
||||
]);
|
||||
|
||||
// Marker-wait on the rename (last B op) + settle covers A's adds.
|
||||
await waitAllContain(trio, cfg, "s3-renamed");
|
||||
await settleConverged(trio, cfg);
|
||||
await oracleSweep(trio, cfg);
|
||||
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
await closeTrio(trio);
|
||||
});
|
||||
|
||||
// ── S4: conflict pairs on the SAME item ──────────────────────────────────
|
||||
test(`${label} S4: move-vs-delete, value-vs-value, move-vs-move`, async ({
|
||||
context,
|
||||
testLogger,
|
||||
}) => {
|
||||
skipFirefox();
|
||||
const trio = await openTrio(context, cfg, `s4-${label}-${test.info().workerIndex}`);
|
||||
|
||||
// Victim for move-vs-delete: a marker item A adds and everyone holds.
|
||||
const victim = await ops.addMarker(trio.A, "s4-victim", 0);
|
||||
expect(victim).toMatch(/[0-9a-f-]{36}/);
|
||||
await waitAllContain(trio, cfg, victim);
|
||||
|
||||
await test.step("move-vs-delete", async () => {
|
||||
// Winner is CRDT policy, not asserted; both hooks may race the item
|
||||
// away from under each other, so their return values are not asserted
|
||||
// either — convergence + silence is the contract.
|
||||
await Promise.all([
|
||||
ops.move(trio.A, victim, ops.mm),
|
||||
callHook<boolean>(trio.B, "kicadCollabTestRemoveItem", victim),
|
||||
]);
|
||||
await settleConverged(trio, cfg);
|
||||
await oracleSweep(trio, cfg);
|
||||
});
|
||||
|
||||
await test.step("value-vs-value", async () => {
|
||||
await Promise.all([ops.setValue(trio.A, "s4-from-A"), ops.setValue(trio.B, "s4-from-B")]);
|
||||
// One of the two values won everywhere; poll until every tab carries
|
||||
// SOME s4 value, then settle on byte equality.
|
||||
for (const [tabLabel, p] of trio.tabs) {
|
||||
await expect
|
||||
.poll(async () => /s4-from-[AB]/.test(await modelText(p, cfg)), {
|
||||
timeout: 20000,
|
||||
intervals: [400],
|
||||
message: `${tabLabel} must receive one of the racing values`,
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
await settleConverged(trio, cfg);
|
||||
await oracleSweep(trio, cfg);
|
||||
});
|
||||
|
||||
await test.step("move-vs-move", async () => {
|
||||
await Promise.all([
|
||||
ops.move(trio.A, ops.secondary, 2 * ops.mm),
|
||||
ops.move(trio.B, ops.secondary, -2 * ops.mm),
|
||||
]);
|
||||
await settleConverged(trio, cfg);
|
||||
await oracleSweep(trio, cfg);
|
||||
});
|
||||
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
await closeTrio(trio);
|
||||
});
|
||||
|
||||
// ── S5: undo storm ───────────────────────────────────────────────────────
|
||||
test(`${label} S5: A undoes its own ops while B keeps editing`, async ({
|
||||
context,
|
||||
testLogger,
|
||||
}) => {
|
||||
skipFirefox();
|
||||
const trio = await openTrio(context, cfg, `s5-${label}-${test.info().workerIndex}`);
|
||||
|
||||
// A's undoable ops, settled so peers hold them.
|
||||
expect(await ops.move(trio.A, ops.primary, ops.mm)).toBe(true);
|
||||
expect(await ops.setValue(trio.A, "s5-tmp")).toBe(true);
|
||||
await waitAllContain(trio, cfg, "s5-tmp");
|
||||
await settleConverged(trio, cfg);
|
||||
|
||||
// A undoes both while B lands fresh edits.
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
expect(await callHook<boolean>(trio.A, "kicadCollabTestUndo")).toBe(true);
|
||||
expect(await callHook<boolean>(trio.A, "kicadCollabTestUndo")).toBe(true);
|
||||
})(),
|
||||
(async () => {
|
||||
await ops.move(trio.B, ops.secondary, ops.mm);
|
||||
await ops.addMarker(trio.B, "s5-b-survives", 1);
|
||||
})(),
|
||||
]);
|
||||
|
||||
// B's edits survive A's undos; the undone value is gone everywhere.
|
||||
await waitAllContain(trio, cfg, "s5-b-survives");
|
||||
for (const [tabLabel, p] of trio.tabs) {
|
||||
await expect
|
||||
.poll(async () => (await modelText(p, cfg)).includes("s5-tmp"), {
|
||||
timeout: 20000,
|
||||
intervals: [400],
|
||||
message: `${tabLabel} must lose the undone value`,
|
||||
})
|
||||
.toBe(false);
|
||||
}
|
||||
await settleConverged(trio, cfg);
|
||||
await oracleSweep(trio, cfg);
|
||||
expect(await undoDepth(trio.C), "observer undo stack").toBe(0);
|
||||
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
await closeTrio(trio);
|
||||
});
|
||||
|
||||
// ── S6: burst churn ──────────────────────────────────────────────────────
|
||||
test(`${label} S6: 12-edit bursts from both sides, one settle at the end`, async ({
|
||||
context,
|
||||
testLogger,
|
||||
}) => {
|
||||
skipFirefox();
|
||||
const trio = await openTrio(context, cfg, `s6-${label}-${test.info().workerIndex}`);
|
||||
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
for (let i = 0; i < 12; i++) await ops.move(trio.A, ops.primary, Math.round(ops.mm / 5));
|
||||
await ops.addMarker(trio.A, "s6-done-a", 2);
|
||||
})(),
|
||||
(async () => {
|
||||
for (let i = 0; i < 12; i++) await ops.move(trio.B, ops.secondary, Math.round(ops.mm / 5));
|
||||
await ops.addMarker(trio.B, "s6-done-b", 3);
|
||||
})(),
|
||||
]);
|
||||
|
||||
await waitAllContain(trio, cfg, "s6-done-a", 45000);
|
||||
await waitAllContain(trio, cfg, "s6-done-b", 45000);
|
||||
await settleConverged(trio, cfg, 45000);
|
||||
await oracleSweep(trio, cfg);
|
||||
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
await closeTrio(trio);
|
||||
});
|
||||
|
||||
// ── S7: late joiner adopts a room that evolved without it ────────────────
|
||||
test(`${label} S7: C joins after A+B edited; adopt converges drift-silent`, async ({
|
||||
context,
|
||||
testLogger,
|
||||
}) => {
|
||||
skipFirefox();
|
||||
const room = `s7-${label}-${test.info().workerIndex}`;
|
||||
const A = await context.newPage();
|
||||
const B = await context.newPage();
|
||||
await bootOpen(A, cfg);
|
||||
await bootOpen(B, cfg);
|
||||
await startV2(A, { room, seedText: cfg.fixture });
|
||||
await startV2(B, { room, editorMatchesDoc: true });
|
||||
const duo: TabSet = { tabs: [["A", A], ["B", B]] };
|
||||
await settleConverged(duo, cfg);
|
||||
|
||||
// The room evolves before C exists.
|
||||
await ops.move(A, ops.primary, ops.mm);
|
||||
await ops.addMarker(B, "s7-early", 4);
|
||||
await waitAllContain(duo, cfg, "s7-early");
|
||||
await settleConverged(duo, cfg);
|
||||
|
||||
// C opened the ORIGINAL fixture file, so its editor does NOT match the
|
||||
// doc — it must take the ADOPT branch (no editorMatchesDoc, no seed).
|
||||
const C = await context.newPage();
|
||||
await bootOpen(C, cfg);
|
||||
await startV2(C, { room });
|
||||
|
||||
const trio: Trio = { A, B, C, tabs: [["A", A], ["B", B], ["C", C]] };
|
||||
await waitAllContain(trio, cfg, "s7-early");
|
||||
await settleConverged(trio, cfg);
|
||||
await oracleSweep(trio, cfg);
|
||||
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
await closeTrio(trio);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── S8: user-save during a peer's burst (pcbnew) ─────────────────────────────
|
||||
// Ctrl+S drives the FULL save flow (the real writer + the C++→JS onSave
|
||||
// notification chokepoint) while remote applies land — asyncify contention
|
||||
// between the save fiber and the apply fibers is exactly the surface.
|
||||
|
||||
test.describe("drift trio scenarios — pcbnew S8 save interplay", () => {
|
||||
test.describe.configure({ timeout: 900000 });
|
||||
|
||||
test("pcbnew S8: A saves (Ctrl+S) mid B-burst", async ({ context, testLogger }) => {
|
||||
skipFirefox();
|
||||
const trio = await openTrio(context, TRIO_PCB, `s8-pcb-${test.info().workerIndex}`);
|
||||
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
for (let i = 0; i < 6; i++)
|
||||
await callHook<boolean>(trio.B, "kicadCollabTestMoveBoardItem", VIA1, 300000, 0);
|
||||
await callHook<string>(trio.B, "kicadCollabTestAddBoardText", "s8-done", 20000000, 140000000, "F.SilkS");
|
||||
})(),
|
||||
(async () => {
|
||||
// Focus the canvas on an empty margin, then user-save twice mid-burst.
|
||||
await trio.A.locator("#canvas").click({ position: { x: 30, y: 300 } });
|
||||
await trio.A.keyboard.press("Control+s");
|
||||
await trio.A.keyboard.press("Control+s");
|
||||
})(),
|
||||
]);
|
||||
|
||||
await waitAllContain(trio, TRIO_PCB, "s8-done");
|
||||
await settleConverged(trio, TRIO_PCB);
|
||||
await oracleSweep(trio, TRIO_PCB);
|
||||
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
await closeTrio(trio);
|
||||
});
|
||||
});
|
||||
|
|
@ -261,7 +261,7 @@ export async function bootOpen(page: Page, cfg: ToolCfg): Promise<void> {
|
|||
await page.addScriptTag({ path: BUNDLE });
|
||||
}
|
||||
|
||||
function startV2(
|
||||
export function startV2(
|
||||
page: Page,
|
||||
opts: { room: string; settleMs?: number; seedText?: string; editorMatchesDoc?: boolean },
|
||||
): Promise<void> {
|
||||
|
|
@ -274,12 +274,15 @@ function startV2(
|
|||
}, opts);
|
||||
}
|
||||
|
||||
export interface Trio {
|
||||
/** Anything with labeled tabs — the oracles work on trios, duos, quads alike. */
|
||||
export interface TabSet {
|
||||
tabs: ReadonlyArray<readonly [label: string, page: Page]>;
|
||||
}
|
||||
|
||||
export interface Trio extends TabSet {
|
||||
A: Page;
|
||||
B: Page;
|
||||
C: Page;
|
||||
/** Sweep/settle order: seeder first, observer last. */
|
||||
tabs: ReadonlyArray<readonly [label: string, page: Page]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -404,7 +407,7 @@ function firstDiff(a: string, b: string): string {
|
|||
* Poll until every tab's silent save is byte-identical. The convergence gate
|
||||
* between scenario steps — bounded poll, no blind sleeps (tests/TESTING.md).
|
||||
*/
|
||||
export async function settleConverged(trio: Trio, cfg: ToolCfg, timeout = 30000): Promise<void> {
|
||||
export async function settleConverged(trio: TabSet, cfg: ToolCfg, timeout = 30000): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
|
|
@ -425,7 +428,7 @@ export async function settleConverged(trio: Trio, cfg: ToolCfg, timeout = 30000)
|
|||
* 2. all three silent saves are byte-identical,
|
||||
* 3. the room doc materializes, identically, on every tab.
|
||||
*/
|
||||
export async function oracleSweep(trio: Trio, cfg: ToolCfg): Promise<void> {
|
||||
export async function oracleSweep(trio: TabSet, cfg: ToolCfg): Promise<void> {
|
||||
for (const [label, page] of trio.tabs) {
|
||||
const d = await drift(page, cfg);
|
||||
expect(d?.added ?? [], `${label} drift added`).toEqual([]);
|
||||
|
|
|
|||
|
|
@ -1518,15 +1518,23 @@ bool schCollabTestMoveSchItem( std::string aId, int aDx, int aDy )
|
|||
if( !fr )
|
||||
return false;
|
||||
|
||||
SCH_SHEET_PATH path;
|
||||
SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ),
|
||||
&path, /*allowNull*/ true );
|
||||
|
||||
if( !item )
|
||||
SCH_SHEET_PATH probe;
|
||||
if( !fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ), &probe,
|
||||
/*allowNull*/ true ) )
|
||||
return false;
|
||||
|
||||
SCH_SCREEN* screen = path.LastScreen();
|
||||
fr->CallAfter( [fr, item, screen, aDx, aDy]() { collabTestMove( fr, item, screen, aDx, aDy ); } );
|
||||
// Re-resolve when the deferred body runs: a remote remove can apply in
|
||||
// between and the captured pointer would be dangling — the commit would
|
||||
// resurrect a deleted item (drift-trio S4 move-vs-delete). Vanished =>
|
||||
// the move loses, silently.
|
||||
fr->CallAfter( [fr, aId, aDx, aDy]() {
|
||||
SCH_SHEET_PATH path;
|
||||
SCH_ITEM* item = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ),
|
||||
&path, /*allowNull*/ true );
|
||||
|
||||
if( item )
|
||||
collabTestMove( fr, item, path.LastScreen(), aDx, aDy );
|
||||
} );
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1544,16 +1552,21 @@ bool schCollabTestMirrorSchItem( std::string aId, bool aHorizontal )
|
|||
if( !item )
|
||||
return false;
|
||||
|
||||
SCH_SCREEN* screen = path.LastScreen();
|
||||
pcbjam_collab::runOnFiber( fr, [fr, aId, aHorizontal]() { // re-resolve on the fiber (S4)
|
||||
SCH_SHEET_PATH path;
|
||||
SCH_ITEM* live = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ),
|
||||
&path, /*allowNull*/ true );
|
||||
|
||||
if( !live )
|
||||
return;
|
||||
|
||||
pcbjam_collab::runOnFiber( fr, [fr, item, screen, aHorizontal]() {
|
||||
SCH_COMMIT commit( fr );
|
||||
commit.Modify( item, screen );
|
||||
commit.Modify( live, path.LastScreen() );
|
||||
|
||||
if( aHorizontal )
|
||||
item->MirrorHorizontally( item->GetPosition().x );
|
||||
live->MirrorHorizontally( live->GetPosition().x );
|
||||
else
|
||||
item->MirrorVertically( item->GetPosition().y );
|
||||
live->MirrorVertically( live->GetPosition().y );
|
||||
|
||||
commit.Push( wxT( "Collab test mirror" ) );
|
||||
} );
|
||||
|
|
|
|||
|
|
@ -2111,12 +2111,16 @@ std::string pcbCollabTestAddZone( int aX1, int aY1, int aX2, int aY2, std::strin
|
|||
bool pcbCollabTestFlipBoardItem( std::string aId )
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
BOARD_ITEM* item = testResolve( fr, aId );
|
||||
|
||||
if( !item )
|
||||
if( !testResolve( fr, aId ) )
|
||||
return false;
|
||||
|
||||
pcbjam_collab::runOnFiber( fr, [fr, item]() {
|
||||
pcbjam_collab::runOnFiber( fr, [fr, aId]() { // re-resolve on the fiber (S4)
|
||||
BOARD_ITEM* item = testResolve( fr, aId );
|
||||
|
||||
if( !item )
|
||||
return;
|
||||
|
||||
BOARD_COMMIT commit( fr );
|
||||
commit.Modify( item );
|
||||
item->Flip( item->GetPosition(), FLIP_DIRECTION::LEFT_RIGHT );
|
||||
|
|
@ -2135,14 +2139,19 @@ bool pcbCollabTestSetFootprintField( std::string aId, std::string aField, std::s
|
|||
if( !item || item->Type() != PCB_FOOTPRINT_T )
|
||||
return false;
|
||||
|
||||
FOOTPRINT* fp = static_cast<FOOTPRINT*>( item );
|
||||
wxString text = wxString::FromUTF8( aText.c_str() );
|
||||
bool isRef = ( aField == "Reference" );
|
||||
wxString text = wxString::FromUTF8( aText.c_str() );
|
||||
bool isRef = ( aField == "Reference" );
|
||||
|
||||
if( !isRef && aField != "Value" )
|
||||
return false;
|
||||
|
||||
pcbjam_collab::runOnFiber( fr, [fr, fp, text, isRef]() {
|
||||
pcbjam_collab::runOnFiber( fr, [fr, aId, text, isRef]() { // re-resolve on the fiber (S4)
|
||||
BOARD_ITEM* live = testResolve( fr, aId );
|
||||
|
||||
if( !live || live->Type() != PCB_FOOTPRINT_T )
|
||||
return;
|
||||
|
||||
FOOTPRINT* fp = static_cast<FOOTPRINT*>( live );
|
||||
BOARD_COMMIT commit( fr );
|
||||
commit.Modify( fp );
|
||||
|
||||
|
|
@ -2160,12 +2169,16 @@ bool pcbCollabTestSetFootprintField( std::string aId, std::string aField, std::s
|
|||
bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked )
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
BOARD_ITEM* item = testResolve( fr, aId );
|
||||
|
||||
if( !item )
|
||||
if( !testResolve( fr, aId ) )
|
||||
return false;
|
||||
|
||||
pcbjam_collab::runOnFiber( fr, [fr, item, aLocked]() {
|
||||
pcbjam_collab::runOnFiber( fr, [fr, aId, aLocked]() { // re-resolve on the fiber (S4)
|
||||
BOARD_ITEM* item = testResolve( fr, aId );
|
||||
|
||||
if( !item )
|
||||
return;
|
||||
|
||||
BOARD_COMMIT commit( fr );
|
||||
commit.Modify( item );
|
||||
item->SetLocked( aLocked );
|
||||
|
|
@ -2179,12 +2192,18 @@ bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked )
|
|||
bool pcbCollabTestMoveBoardItem( std::string aId, int aDx, int aDy )
|
||||
{
|
||||
PCB_EDIT_FRAME* fr = pcbFrame();
|
||||
BOARD_ITEM* item = testResolve( fr, aId );
|
||||
|
||||
if( !item )
|
||||
if( !testResolve( fr, aId ) )
|
||||
return false;
|
||||
|
||||
pcbjam_collab::runOnFiber( fr, [fr, item, aDx, aDy]() { collabTestMove( fr, item, aDx, aDy ); } );
|
||||
// Re-resolve ON the fiber: a remote remove can apply between scheduling
|
||||
// and running, and doApplyItems FREES removed items — a captured pointer
|
||||
// would be dangling and the commit would resurrect a deleted item
|
||||
// (drift-trio S4 move-vs-delete). Vanished => the move loses, silently.
|
||||
pcbjam_collab::runOnFiber( fr, [fr, aId, aDx, aDy]() {
|
||||
if( BOARD_ITEM* item = testResolve( fr, aId ) )
|
||||
collabTestMove( fr, item, aDx, aDy );
|
||||
} );
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue