diff --git a/source/npm/qsharp/test/circuit-editor/_helpers.mjs b/source/npm/qsharp/test/circuit-editor/_helpers.mjs new file mode 100644 index 00000000000..b4294988018 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/_helpers.mjs @@ -0,0 +1,529 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// @ts-check +// +// Construction / extraction / assertion helpers for the +// `circuit-editor/circuit-actions/group*.test.mjs` suites. +// +// Leading underscore keeps this file out of the `**/*.test.mjs` discovery glob — it exports +// helpers, no tests of its own. +// +// Helpers return plain literal-shape objects (no validation, no class wrappers). They mirror what +// the model layer expects and have everything cast to `any` at the boundary so test bodies can stay +// free of `/** @type {any} */` ceremony. + +import assert from "node:assert/strict"; +import { CircuitModel } from "../../dist/ux/circuit-vis/data/circuitModel.js"; +import { findOperation } from "../../dist/ux/circuit-vis/utils.js"; + +// --------------------------------------------------------------- +// Construction +// --------------------------------------------------------------- + +/** + * Build a qubits array. + * + * @param {number} n number of qubits + * @param {Record} [results] map from qubit index to `numResults`. Defaults to no + * `numResults` on any wire. + * @returns {any[]} + */ +export const qubits = (n, results) => + Array.from({ length: n }, (_, i) => + results && results[i] !== undefined + ? { id: i, numResults: results[i] } + : { id: i }, + ); + +/** + * Unitary op. + * + * gate("H", 0) single-target + * gate("SWAP", [0, 2]) multi-target + * gate("X", 1, { ctrls: [0] }) one quantum control + * gate("X", 1, { ctrls: [3, 4] }) two quantum controls + * gate("X", 1, { ctrls: [{ q: 0, r: 0 }], one classical control + * conditional: true }) (and conditional execution) + * + * @param {string} name + * @param {number | number[]} target + * @param {{ ctrls?: (number | { q: number, r?: number })[], conditional?: boolean }} [opts] + * @returns {any} + */ +export const gate = (name, target, opts) => { + const targets = (Array.isArray(target) ? target : [target]).map((q) => ({ + qubit: q, + })); + /** @type {any} */ + const out = { kind: "unitary", gate: name, targets }; + if (opts?.ctrls) { + out.controls = opts.ctrls.map((c) => + typeof c === "number" ? { qubit: c } : { qubit: c.q, result: c.r ?? 0 }, + ); + } + if (opts?.conditional) out.isConditional = true; + return out; +}; + +/** + * Measurement op. + * + * meas(2) // M on q2 producing result 0 + * meas(2, { result: 1 }) // M on q2 producing result 1 + * meas(2, { gate: "Measure" }) // customize gate name + * + * @param {number} qubit + * @param {{ gate?: string, result?: number }} [opts] + * @returns {any} + */ +export const meas = (qubit, opts) => ({ + kind: "measurement", + gate: opts?.gate ?? "M", + qubits: [{ qubit }], + results: [{ qubit, result: opts?.result ?? 0 }], +}); + +/** + * Group (a unitary that owns a children grid). + * + * group("Foo", [ + * [gate("H", 0), gate("X", 1)], // inner column 0 + * [gate("Z", 1)], // inner column 1 + * ]) + * group("Foo", [...], { ctrls: [3] }) // quantum-controlled group + * + * The group's `.targets` is auto-derived as the union of every direct child's `.targets` and + * `.controls` quantum wires (recursively for nested groups, since each nested group's own + * `.targets` is similarly auto-derived). The group's own controls (from `opts.ctrls`) live on + * `.controls`, not `.targets` — same as the real `CircuitModel`. Pass `opts.span` to force a wider + * extent than the children imply (e.g. a group that visually covers a wire none of its children + * touch). + * + * @param {string} name + * @param {any[][]} innerGrid array of inner columns, each column an + * array of ops (built with `gate` / `meas` / nested `group`) + * @param {{ ctrls?: (number | { q: number, r?: number })[], + * conditional?: boolean, + * expanded?: boolean, + * span?: number[] }} [opts] + * @returns {any} + */ +export const group = (name, innerGrid, opts) => { + // Union of every direct child's quantum wire span (target qubits + // + control qubits). Measurements expose `.qubits` instead of + // `.targets`; controls always live on `.controls` regardless. + const childWires = new Set(); + for (const col of innerGrid) { + for (const child of col) { + const targets = child.targets ?? []; + const controls = child.controls ?? []; + const measQubits = child.qubits ?? []; + for (const t of targets) { + if (typeof t.qubit === "number") childWires.add(t.qubit); + } + for (const c of controls) { + if (typeof c.qubit === "number") childWires.add(c.qubit); + } + for (const q of measQubits) { + if (typeof q.qubit === "number") childWires.add(q.qubit); + } + } + } + const wires = [...childWires].sort((a, b) => a - b); + // `opts.span` overrides the derived extent when the group should visually cover wires its + // children don't all occupy. + const targetWires = opts?.span ?? wires; + + /** @type {any} */ + const out = { + kind: "unitary", + gate: name, + targets: targetWires.map((q) => ({ qubit: q })), + children: innerGrid.map((col) => ({ components: col })), + }; + if (opts?.ctrls) { + out.controls = opts.ctrls.map((c) => + typeof c === "number" ? { qubit: c } : { qubit: c.q, result: c.r ?? 0 }, + ); + } + if (opts?.conditional) out.isConditional = true; + // Mark the group as render-expanded (the renderer reads `dataAttributes.expanded` to show the + // body instead of a collapsed box). + if (opts?.expanded) out.dataAttributes = { expanded: "true" }; + return out; +}; + +/** + * Build a circuit literal. + * + * circuit(4, [[gate("H", 0)], [gate("X", 1)]]) circuit(qubits(4, { 0: 1 }), [...]) // qubits + * with numResults + * + * @param {number | any[]} numQubitsOrQubits + * @param {any[][]} grid outer grid: array of columns, each column an array of ops + * @returns {any} + */ +export const circuit = (numQubitsOrQubits, grid) => ({ + qubits: + typeof numQubitsOrQubits === "number" + ? qubits(numQubitsOrQubits) + : numQubitsOrQubits, + componentGrid: grid.map((col) => ({ components: col })), +}); + +/** + * Build a `CircuitModel` from a circuit literal. + * + * @param {any} circuitObj + * @returns {any} + */ +export const build = (circuitObj) => new CircuitModel(circuitObj); + +// --------------------------------------------------------------- +// Extraction +// --------------------------------------------------------------- + +/** + * Look up an op in `model` by location string. Cast to `any` for ergonomic property access in + * tests. + * + * @param {any} model + * @param {string} location e.g. `"0,0"`, `"0,0-1,0"`, `"0,0-0,0-1,0"` + * @returns {any} + */ +export const at = (model, location) => + /** @type {any} */ (findOperation(model.componentGrid, location)); + +/** + * Wire indices of an op's targets. + * @param {any} op + * @returns {number[]} + */ +export const wires = (op) => op.targets.map((/** @type {any} */ t) => t.qubit); + +/** + * Wire indices of an op's controls (quantum or classical). + * @param {any} op + * @returns {number[]} + */ +export const ctrlWires = (op) => + (op.controls ?? []).map((/** @type {any} */ c) => c.qubit); + +/** + * Gate names of every top-level column, as a 2D array. [["Foo", "X"], ["Z"]] + * @param {any} model + * @returns {string[][]} + */ +export const topShape = (model) => + model.componentGrid.map((/** @type {any} */ col) => + col.components.map((/** @type {any} */ op) => op.gate), + ); + +/** + * Gate names of every inner column of a group, as a 2D array. + * @param {any} groupOp + * @returns {string[][]} + */ +export const innerShape = (groupOp) => + (groupOp.children ?? []).map((/** @type {any} */ col) => + col.components.map((/** @type {any} */ op) => op.gate), + ); + +// --------------------------------------------------------------- +// Assertions +// --------------------------------------------------------------- + +/** + * Assert the model's top-level grid matches the expected gate shape. + * @param {any} model + * @param {string[][]} expected + */ +export const assertTopShape = (model, expected) => { + const actual = topShape(model); + assert.deepEqual( + actual, + expected, + `top-level grid shape mismatch; got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, + ); +}; + +/** + * Assert a group's inner grid matches the expected gate shape. + * @param {any} groupOp + * @param {string[][]} expected + */ +export const assertInnerShape = (groupOp, expected) => { + const actual = innerShape(groupOp); + assert.deepEqual( + actual, + expected, + `inner grid shape mismatch; got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, + ); +}; + +/** + * Assert an op's target wires match `expected` (order-independent). + * @param {any} op + * @param {number[]} expected + */ +export const assertWires = (op, expected) => { + const actual = [...wires(op)].sort((a, b) => a - b); + const want = [...expected].sort((a, b) => a - b); + assert.deepEqual( + actual, + want, + `wire mismatch; got ${JSON.stringify(actual)}, expected ${JSON.stringify(want)}`, + ); +}; + +/** + * Assert an op's target wires INCLUDE every wire in `required`. + * @param {any} op + * @param {...number} required + */ +export const assertEnclosesWires = (op, ...required) => { + const w = wires(op); + for (const q of required) { + assert.ok( + w.includes(q), + `expected wires to include ${q}; got ${JSON.stringify(w)}`, + ); + } +}; + +/** + * Assert an op's target wires DO NOT include any wire in `excluded`. + * @param {any} op + * @param {...number} excluded + */ +export const assertExcludesWires = (op, ...excluded) => { + const w = wires(op); + for (const q of excluded) { + assert.ok( + !w.includes(q), + `expected wires NOT to include ${q}; got ${JSON.stringify(w)}`, + ); + } +}; + +// --------------------------------------------------------------- +// Shape-DSL assertions. +// +// `expectGrid(model, gridSpec)` and `expectOp(op, opSpec)` match against a declarative spec +// literal. Conventions: +// +// - Objects (op spec bodies) are SUBSET matches: only declared keys are checked. +// - Arrays (`wires`, `qubits`, `ctrls`, `results`, `componentGrid` columns, column components) +// are EXACT matches: length must agree. `targets` / `qubits` / `ctrls` / `results` are sorted +// before compare. Ops within a column / inner-column are matched ORDER-INDEPENDENTLY (greedy): +// each spec item is matched against any remaining actual op. Within a child grid, COLUMN order +// IS load-bearing (columns are temporal). +// - `children` is an ordered array of columns (temporal); each column's ops are matched +// order-independently. +// +// Op spec grammar: +// "H" // gate name only, no further checks +// { H: 2 } // sugar for { H: { targets: [2] } } +// { H: [0, 2] } // sugar for { H: { targets: [0, 2] } } +// { H: { targets, qubits, ctrls, // full form; any subset of these +// results, conditional, +// children } } +// +// Leg shorthands inside `ctrls` and `results`: +// 3 -> { qubit: 3 } (quantum control) +// { q: 2 } -> { qubit: 2 } (quantum control) +// { q: 2, r: 0 } -> { qubit: 2, result: 0 } (classical leg) +// --------------------------------------------------------------- + +/** @param {any} c */ +const normCtrl = (c) => + typeof c === "number" + ? { qubit: c } + : c.r === undefined + ? { qubit: c.q } + : { qubit: c.q, result: c.r }; + +/** @param {any} c */ +const actualCtrl = (c) => + c.result === undefined + ? { qubit: c.qubit } + : { qubit: c.qubit, result: c.result }; + +/** @param {any} r */ +const normResult = (r) => + typeof r === "number" + ? { qubit: r, result: 0 } + : { qubit: r.q, result: r.r ?? 0 }; + +/** @param {any} r */ +const actualResult = (r) => ({ qubit: r.qubit, result: r.result }); + +/** + * @param {number[]} list + * @returns {number[]} + */ +const sortNums = (list) => [...list].sort((a, b) => a - b); + +/** + * @param {{ qubit: number, result?: number }[]} list + */ +const sortLegs = (list) => + [...list].sort( + (a, b) => a.qubit - b.qubit || (a.result ?? -1) - (b.result ?? -1), + ); + +/** + * @param {any} actual + * @param {any} spec + * @param {string} path + */ +const matchOp = (actual, spec, path) => { + if (actual === undefined || actual === null) { + assert.fail(`${path}: expected an op, got ${actual}`); + } + // Bare-string spec: only check gate name. + if (typeof spec === "string") { + assert.equal(actual.gate, spec, `${path}: gate name`); + return; + } + const keys = Object.keys(spec); + assert.equal( + keys.length, + 1, + `${path}: op spec must have exactly one key (the gate name); got ${JSON.stringify(keys)}`, + ); + const gateName = keys[0]; + const body = spec[gateName]; + assert.equal( + actual.gate, + gateName, + `${path}: expected gate "${gateName}", got "${actual.gate}"`, + ); + + // Wire shorthand: number or number[]. + const props = + typeof body === "number" + ? { targets: [body] } + : Array.isArray(body) + ? { targets: body } + : body; + + const here = `${path}.${gateName}`; + + if (props.targets !== undefined) { + const got = sortNums( + (actual.targets ?? []).map((/** @type {any} */ t) => t.qubit), + ); + const want = sortNums(props.targets); + assert.deepEqual(got, want, `${here}.targets`); + } + if (props.qubits !== undefined) { + const got = sortNums( + (actual.qubits ?? []).map((/** @type {any} */ q) => q.qubit), + ); + const want = sortNums(props.qubits); + assert.deepEqual(got, want, `${here}.qubits`); + } + if (props.ctrls !== undefined) { + const got = sortLegs((actual.controls ?? []).map(actualCtrl)); + const want = sortLegs(props.ctrls.map(normCtrl)); + assert.deepEqual(got, want, `${here}.ctrls`); + } + if (props.results !== undefined) { + const got = sortLegs((actual.results ?? []).map(actualResult)); + const want = sortLegs(props.results.map(normResult)); + assert.deepEqual(got, want, `${here}.results`); + } + if (props.conditional !== undefined) { + assert.equal( + !!actual.isConditional, + !!props.conditional, + `${here}.conditional`, + ); + } + if (props.children !== undefined) { + const childGrid = actual.children ?? []; + assert.equal( + childGrid.length, + props.children.length, + `${here}.children: column count (got ${childGrid.length}, expected ${props.children.length})`, + ); + props.children.forEach((/** @type {any} */ col, /** @type {number} */ i) => + matchColumn(childGrid[i].components, col, `${here}.children[${i}]`), + ); + } +}; + +/** + * Try to match `actual` against `spec`. Returns true on success, false on any mismatch. Used by + * `matchColumn` to greedy-pair spec items with actual ops without depending on storage order. + * + * @param {any} actual + * @param {any} spec + */ +const wouldMatchOp = (actual, spec) => { + try { + matchOp(actual, spec, "probe"); + return true; + } catch { + return false; + } +}; + +/** + * @param {any[]} actualOps + * @param {any[]} specOps + * @param {string} path + */ +const matchColumn = (actualOps, specOps, path) => { + assert.equal( + actualOps.length, + specOps.length, + `${path}: op count (got ${actualOps.length}, expected ${specOps.length})`, + ); + // Order-independent (greedy): for each spec item, claim the first unclaimed actual that matches. + // Column data order is essentially insertion order and not load-bearing — the renderer positions + // ops by wire, not by list index. + const remaining = [...actualOps]; + specOps.forEach((spec, i) => { + const idx = remaining.findIndex((op) => wouldMatchOp(op, spec)); + if (idx === -1) { + // Re-run match against the first remaining op to surface a useful diagnostic (gate-name + // mismatch, wires mismatch, etc). + matchOp(remaining[0], spec, `${path}[${i}]`); + assert.fail( + `${path}[${i}]: no remaining op in column matches spec ${JSON.stringify(spec)}`, + ); + } + remaining.splice(idx, 1); + }); +}; + +/** + * Assert that `op` matches the given spec (subset on object keys, exact on arrays). See the comment + * block above for grammar. + * + * @param {any} op + * @param {any} spec + */ +export const expectOp = (op, spec) => matchOp(op, spec, "op"); + +/** + * Assert that `model`'s top-level grid matches the given spec (a 2D array: outer = columns, inner = + * ops per column). + * + * @param {any} model + * @param {any[][]} gridSpec + */ +export const expectGrid = (model, gridSpec) => { + const grid = model.componentGrid; + assert.equal( + grid.length, + gridSpec.length, + `grid: column count (got ${grid.length}, expected ${gridSpec.length})`, + ); + gridSpec.forEach((col, i) => + matchColumn(grid[i].components, col, `grid[${i}]`), + ); +}; diff --git a/source/npm/qsharp/test/circuit-editor/angleExpression.test.mjs b/source/npm/qsharp/test/circuit-editor/angleExpression.test.mjs new file mode 100644 index 00000000000..8a5ca4cb72a --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/angleExpression.test.mjs @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// angleExpression tests — direct coverage for the two helpers in `angleExpression.ts` that drive +// the Edit Argument input prompt in [contextMenu.ts](../../ux/circuit-vis/editor/contextMenu.ts): +// +// - `isValidAngleExpression(expr)` is the predicate the prompt consults on every keystroke to +// enable/disable OK. Anything it accepts ends up persisted into the operation's `args`. +// - `normalizeAngleExpression(expr)` runs BEFORE validation: it trims surrounding whitespace and +// folds case-insensitive `pi` to `π`. The persisted value is the normalized form, so OK's +// enabled state must agree with what the user sees after Save. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isValidAngleExpression, + normalizeAngleExpression, +} from "../../dist/ux/circuit-vis/angleExpression.js"; + +// --------------------------------------------------------------------------- +// isValidAngleExpression — positive cases +// --------------------------------------------------------------------------- + +test("isValidAngleExpression: plain numbers (positive, negative, decimal) are valid", () => { + // The simplest path — the prompt should accept any literal numeric value the user types, + // including the "5." trailing-dot form (mirroring JavaScript's `parseFloat` tolerance). + assert.equal(isValidAngleExpression("0"), true); + assert.equal(isValidAngleExpression("5"), true); + assert.equal(isValidAngleExpression("-5"), true); + assert.equal(isValidAngleExpression("+5"), true); + assert.equal(isValidAngleExpression("3.5"), true); + assert.equal(isValidAngleExpression("-3.5"), true); + assert.equal(isValidAngleExpression("5."), true); +}); + +test("isValidAngleExpression: bare π is valid in all four case forms", () => { + // The prompt's placeholder example is `"π / 2.0"`; the user can type π directly via the on-screen + // π button, or use any case variant of `pi` which `normalizeAngleExpression` folds to π before + // this check runs. + assert.equal(isValidAngleExpression("π"), true); + assert.equal(isValidAngleExpression("pi"), true); + assert.equal(isValidAngleExpression("Pi"), true); + assert.equal(isValidAngleExpression("PI"), true); +}); + +test("isValidAngleExpression: signed π is valid", () => { + // The `-π` and `+π` forms are the unary-sign-on-pi-factor path through the parser; pin both. + assert.equal(isValidAngleExpression("-π"), true); + assert.equal(isValidAngleExpression("+π"), true); + assert.equal(isValidAngleExpression("-pi"), true); +}); + +test("isValidAngleExpression: arithmetic combinations are valid", () => { + // The four supported binary operators, with both numbers and π. These mirror the prompt's example + // text ("π / 2.0", "2.0 * π"). + assert.equal(isValidAngleExpression("2 + 3"), true); + assert.equal(isValidAngleExpression("2 - 3"), true); + assert.equal(isValidAngleExpression("2 * 3"), true); + assert.equal(isValidAngleExpression("6 / 2"), true); + assert.equal(isValidAngleExpression("π / 2"), true); + assert.equal(isValidAngleExpression("2 * π"), true); + assert.equal(isValidAngleExpression("2.0 * π"), true); +}); + +test("isValidAngleExpression: parentheses (including nesting) are valid", () => { + // Recursive descent through `parseFactor`'s `lpar` branch. + assert.equal(isValidAngleExpression("(π)"), true); + assert.equal(isValidAngleExpression("(2 + 3) * π"), true); + assert.equal(isValidAngleExpression("((π))"), true); + assert.equal(isValidAngleExpression("π / (2 * 3)"), true); +}); + +test("isValidAngleExpression: leading/trailing whitespace is tolerated", () => { + // `normalizeAngleExpression` (called internally by the evaluator) trims; the prompt also + // normalizes the value BEFORE this predicate runs, so being lenient here matches what the user + // sees after the auto-trim. + assert.equal(isValidAngleExpression(" π "), true); + assert.equal(isValidAngleExpression("\tπ / 2\n"), true); +}); + +// --------------------------------------------------------------------------- +// isValidAngleExpression — negative cases +// --------------------------------------------------------------------------- + +test("isValidAngleExpression: empty / whitespace-only input is invalid", () => { + // The prompt's default OK-disabled state for an empty input. `evaluateAngleExpression` + // short-circuits on a falsy `expr` or a falsy post-normalize string. + assert.equal(isValidAngleExpression(""), false); + assert.equal(isValidAngleExpression(" "), false); + assert.equal(isValidAngleExpression("\t\n"), false); +}); + +test("isValidAngleExpression: unknown characters are invalid", () => { + // Any character outside `[0-9.+\-*/()\sπ]` (after the `pi` → `π` fold) takes the tokenizer's + // "unknown character" fallthrough and returns undefined. Pin a few common typos. + assert.equal(isValidAngleExpression("π^2"), false); + assert.equal(isValidAngleExpression("sin(π)"), false); + assert.equal(isValidAngleExpression("π & 2"), false); + assert.equal(isValidAngleExpression("π,2"), false); +}); + +test("isValidAngleExpression: malformed numbers are invalid", () => { + // The number tokenizer requires `\d+(\.\d*)?` — no leading dot, no multiple decimals. Both are + // rejected. + assert.equal( + isValidAngleExpression(".5"), + false, + "leading dot is not a valid number", + ); + assert.equal( + isValidAngleExpression("1.2.3"), + false, + "multiple decimal points are not a valid number", + ); +}); + +test("isValidAngleExpression: unbalanced parentheses are invalid", () => { + // `parseFactor`'s `lpar` branch requires a matching `rpar` and returns undefined if it doesn't + // see one. The "extra rpar" case fails the trailing `k !== toks.length` guard. + assert.equal(isValidAngleExpression("(π"), false); + assert.equal(isValidAngleExpression("π)"), false); + assert.equal(isValidAngleExpression("((π)"), false); +}); + +test("isValidAngleExpression: trailing / dangling operators are invalid", () => { + // `parseExpr` / `parseTerm` call `parseFactor` after consuming an operator; if the RHS factor is + // missing, the parse returns undefined. + assert.equal(isValidAngleExpression("π +"), false); + assert.equal(isValidAngleExpression("π *"), false); + assert.equal(isValidAngleExpression("2 +"), false); +}); + +test("isValidAngleExpression: lone operators / empty parens are invalid", () => { + // No factor at all (operator only, empty parens) → parseFactor returns undefined on the first + // call. + assert.equal(isValidAngleExpression("+"), false); + assert.equal(isValidAngleExpression("-"), false); + assert.equal(isValidAngleExpression("*"), false); + assert.equal(isValidAngleExpression("()"), false); +}); + +test("isValidAngleExpression: infinite results (division by zero) are invalid", () => { + // The evaluator's final `!isFinite(result)` guard rejects `1/0` and friends — the prompt should + // not allow the user to commit an angle that would evaluate to ±Infinity. + assert.equal(isValidAngleExpression("1 / 0"), false); + assert.equal(isValidAngleExpression("π / 0"), false); + assert.equal(isValidAngleExpression("-1 / 0"), false); +}); + +test("isValidAngleExpression: adjacent factors without an operator are invalid", () => { + // Implicit multiplication isn't supported; "2π" is rejected even though it's a common + // math-notation shorthand. The parser leaves the `pi` token unconsumed after `parseFactor` + // returns `2`, then the trailing `k !== toks.length` guard trips. (If implicit-multiply support + // is added later, this test should be updated to expect `true`.) + assert.equal(isValidAngleExpression("2π"), false); + assert.equal(isValidAngleExpression("π2"), false); +}); + +// --------------------------------------------------------------------------- +// normalizeAngleExpression +// --------------------------------------------------------------------------- + +test("normalizeAngleExpression: trims surrounding whitespace", () => { + // The prompt persists the normalized value, so leading/trailing whitespace must not survive into + // the operation's `args`. + assert.equal(normalizeAngleExpression(" π "), "π"); + assert.equal(normalizeAngleExpression("\tπ / 2\n"), "π / 2"); + assert.equal(normalizeAngleExpression(""), ""); +}); + +test("normalizeAngleExpression: folds case-insensitive 'pi' → 'π'", () => { + // The π button on the prompt inserts the literal character, but users can also type any case + // variant of `pi`. Both must normalize to the same persisted form so `args` doesn't depend on + // which input path was used. + assert.equal(normalizeAngleExpression("pi"), "π"); + assert.equal(normalizeAngleExpression("Pi"), "π"); + assert.equal(normalizeAngleExpression("PI"), "π"); + assert.equal(normalizeAngleExpression("pI"), "π"); +}); + +test("normalizeAngleExpression: folds 'pi' embedded inside an expression", () => { + // The fold is unanchored — every occurrence within an expression is replaced. Pin the common case + // (a `pi` factor inside an arithmetic expression). + assert.equal(normalizeAngleExpression("pi / 2"), "π / 2"); + assert.equal(normalizeAngleExpression("2 * Pi + PI"), "2 * π + π"); + assert.equal(normalizeAngleExpression("(pi)"), "(π)"); +}); + +test("normalizeAngleExpression: leaves already-normalized π untouched", () => { + // Idempotency — passing the output back through the normalizer must be a no-op. The prompt + // re-runs the normalize step on every input event, so this property is what keeps the OK button's + // enabled state stable. + assert.equal(normalizeAngleExpression("π / 2"), "π / 2"); + assert.equal(normalizeAngleExpression("2 * π"), "2 * π"); + assert.equal( + normalizeAngleExpression(normalizeAngleExpression("PI / 2")), + "π / 2", + ); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/addRemove.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/addRemove.test.mjs new file mode 100644 index 00000000000..b84a006c17a --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/addRemove.test.mjs @@ -0,0 +1,658 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Add/remove mutator tests on flat (non-grouped) shapes against `CircuitModel`. Group recursion and +// group-internal span widening live in `groupAddRemove.test.mjs`. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CircuitModel } from "../../../dist/ux/circuit-vis/data/circuitModel.js"; +import { + addControl, + addOperation, + removeControl, + removeOperation, +} from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + circuit, + expectGrid, + expectOp, + gate, + group, + meas, + qubits, +} from "../_helpers.mjs"; + +/** Fresh empty circuit literal with `n` qubits and no operations. */ +const emptyCircuit = (/** @type {number} */ n) => circuit(n, []); + +/** Single-target unitary template on wire 0 (what `addOperation` copies). */ +const unitary = (/** @type {string} */ g) => gate(g, 0); + +// --------------------------------------------------------------------------- +// addOperation +// --------------------------------------------------------------------------- + +test("addOperation: basic add to an empty circuit", () => { + const model = new CircuitModel(emptyCircuit(2)); + + const added = addOperation(model, unitary("H"), "0,0", 0); + + assert.ok(added, "addOperation should return the new operation"); + expectGrid(model, [["H"]]); + // Returned value is the inserted reference (deep-copied from the template). + assert.equal(added, at(model, "0,0")); + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +test("addOperation: add on an existing wire bumps its use count without growing qubits", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + assert.deepEqual(model.qubitUseCounts, [1, 0]); + + // Second op on the SAME wire (fresh column 1 to avoid same-column overlap). + addOperation(model, unitary("X"), "1,0", 0); + + assert.equal(model.qubits.length, 2); + assert.deepEqual(model.qubitUseCounts, [2, 0]); + expectGrid(model, [["H"], ["X"]]); +}); + +test("addOperation: add on a new wire grows the qubit register by one", () => { + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("H"), "0,0", 0); + assert.equal(model.qubits.length, 1); + + // Drop on wire 1 (one past the end). H@q0 and X@q1 don't overlap, so X + // shares column 0; the register grows to 2. + addOperation(model, gate("X", 1), "0,1", 1); + + assert.equal(model.qubits.length, 2); + assert.deepEqual(model.qubitUseCounts, [1, 1]); + expectGrid(model, [["H", "X"]]); +}); + +test("addOperation: add on a wire several IDs past the end bulk-grows the register", () => { + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("H"), "0,0", 0); + + // Drop on wire 5 — ensureQubitCount(5) adds wires 1..5 in one shot. + addOperation(model, gate("X", 5), "0,1", 5); + + assert.equal(model.qubits.length, 6); + assert.deepEqual(model.qubitUseCounts, [1, 0, 0, 0, 0, 1]); + // Wire IDs stay contiguous after the bulk grow. + for (let i = 0; i < model.qubits.length; i++) { + assert.equal(model.qubits[i].id, i); + } + expectGrid(model, [["H", "X"]]); +}); + +test("addOperation: deep-copies the source operation template", () => { + const model = new CircuitModel(emptyCircuit(2)); + const template = unitary("H"); + + const added = addOperation(model, template, "0,0", 0); + + // Mutating the template after the add must not affect the model. + template.gate = "MUTATED"; + assert.equal(/** @type {any} */ (added).gate, "H"); + expectOp(at(model, "0,0"), "H"); +}); + +test("addOperation: an overlapping insert forces a new column even with insertNewColumn=false", () => { + // Grid invariant: a column is one time-step, so two ops can't occupy the + // same wire within it. When an insert into the target column would overlap + // an op already there, addOperation splits in a fresh column regardless of + // insertNewColumn — the flag can *request* a new column, but an overlap + // *forces* one. The inserted op lands ahead of the op it displaced. + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + const added = addOperation(model, gate("X", 0), "0,0", 0); + + assert.ok(added, "an overlapping insert is resolved, not rejected"); + expectGrid(model, [["X"], ["H"]]); + assert.deepEqual(model.qubitUseCounts, [2, 0]); +}); + +test("addOperation: a root/empty location returns null", () => { + const model = new CircuitModel(emptyCircuit(2)); + + // Empty location parses to root; last() is null → failure, no change. + const result = addOperation(model, unitary("H"), "", 0); + + assert.equal(result, null); + expectGrid(model, []); +}); + +test("addOperation: an op index one past the column's append slot returns null", () => { + // Column 0 holds one op (index 0), so index 1 is the valid append slot and + // index 2 is the minimal out-of-range op index. Rejected the add. + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + const result = addOperation(model, gate("X", 1), "0,2", 1); + + assert.equal(result, null, "out-of-range op index must be rejected"); + expectGrid(model, [["H"]]); + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +test("addOperation: a column index one past the trailing-append slot returns null", () => { + // The grid has one column (index 0), so index 1 is the valid trailing-append + // slot and index 2 is the minimal out-of-range column index. Rejected — + // assigning a column there would leave a sparse hole in the grid. + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + const result = addOperation(model, gate("X", 1), "2,0", 1); + + assert.equal(result, null, "out-of-range column index must be rejected"); + expectGrid(model, [["H"]]); + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +test("addOperation: insertNewColumn at the first column shifts every existing gate right", () => { + const model = new CircuitModel(emptyCircuit(3)); + addOperation(model, gate("H", 0), "0,0", 0); + addOperation(model, gate("Y", 1), "0,1", 1); + addOperation(model, gate("Z", 2), "1,0", 2); + expectGrid(model, [["H", "Y"], ["Z"]]); + + // A new lead column at index 0 shifts both existing columns right. + addOperation(model, gate("X", 0), "0,0", 0, /* insertNewColumn */ true); + + expectGrid(model, [["X"], ["H", "Y"], ["Z"]]); + assert.deepEqual(model.qubitUseCounts, [2, 1, 1]); +}); + +test("addOperation: insertNewColumn in the middle moves only the gates at and after the insert point", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, gate("H", 0), "0,0", 0); + addOperation(model, gate("Y", 1), "0,1", 1); + addOperation(model, gate("Z", 2), "1,0", 2); + expectGrid(model, [["H", "Y"], ["Z"]]); + + addOperation(model, gate("X", 1), "1,0", 1, /* insertNewColumn */ true); + + expectGrid(model, [["H", "Y"], ["X"], ["Z"]]); +}); + +test("addOperation: insertNewColumn at location len,0 appends a fresh trailing column", () => { + // colIndex === grid length is the valid trailing-append slot. With insertNewColumn it + // appends a new last column. + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + const added = addOperation( + model, + gate("X", 1), + "1,0", + 1, + /* insertNewColumn */ true, + ); + + assert.ok(added, "column index equal to the grid length must be valid"); + expectGrid(model, [["H"], ["X"]]); + assert.deepEqual(model.qubitUseCounts, [1, 1]); +}); + +test("addOperation: insertNewColumn at location len+1,0 returns null", () => { + // One past the trailing-append slot is out of range even with + // insertNewColumn — the bounds guard rejects it. + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + const result = addOperation( + model, + gate("X", 1), + "2,0", + 1, + /* insertNewColumn */ true, + ); + + assert.equal(result, null, "out-of-range column index must be rejected"); + expectGrid(model, [["H"]]); + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +// --------------------------------------------------------------------------- +// removeOperation +// --------------------------------------------------------------------------- + +test("removeOperation drops the op and decrements qubitUseCounts", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + addOperation(model, unitary("X"), "1,0", 1); + assert.equal(model.componentGrid.length, 2); + assert.deepEqual(model.qubitUseCounts, [1, 1]); + + // Remove the X (column 1). + removeOperation(model, "1,0"); + + expectGrid(model, [["H"]]); + // Wire 1 dropped to 0 uses → trailing-wire trim removes it. + assert.deepEqual(model.qubitUseCounts, [1]); + assert.equal(model.qubits.length, 1); +}); + +test("removeOperation from an interior wire leaves qubits.length untouched", () => { + const model = new CircuitModel(emptyCircuit(3)); + addOperation(model, unitary("H"), "0,0", 0); + addOperation(model, unitary("X"), "1,0", 1); + addOperation(model, unitary("Z"), "2,0", 2); + assert.equal(model.qubits.length, 3); + + // Remove the middle op. Wire 1 is interior (wire 2 still used), so the trim leaves qubits.length + // at 3. + removeOperation(model, "1,0"); + + assert.equal(model.qubits.length, 3); + assert.deepEqual(model.qubitUseCounts, [1, 0, 1]); +}); + +test("removeOperation bulk-trims every trailing unused wire down to the next anchor", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + // Far-out op gives a wide trailing gap when removed. + addOperation(model, unitary("Z"), "1,0", 5); + assert.equal(model.qubits.length, 6); + assert.deepEqual(model.qubitUseCounts, [1, 0, 0, 0, 0, 1]); + + // Remove the far op. Trim walks back from the end, stops at wire 0 (H). + removeOperation(model, "1,0"); + + assert.equal(model.qubits.length, 1); + assert.deepEqual(model.qubitUseCounts, [1]); +}); + +test("removeOperation trims to a mid-stack anchor introduced by an in-gap add", () => { + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("H"), "0,0", 0); // anchor at wire 0 + addOperation(model, unitary("A"), "1,0", 8); // far out → grows to 9 + assert.equal(model.qubits.length, 9); + + // B inside the grown range (wire 4); no growth. + addOperation(model, unitary("B"), "2,0", 4); + assert.equal(model.qubits.length, 9); + assert.deepEqual(model.qubitUseCounts, [1, 0, 0, 0, 1, 0, 0, 0, 1]); + + // Remove A. Trim stops at wire 4 (B), not the wire-0 anchor. + removeOperation(model, "1,0"); + + assert.equal(model.qubits.length, 5); + assert.deepEqual(model.qubitUseCounts, [1, 0, 0, 0, 1]); +}); + +test("removeOperation on a root location is a safe no-op", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + // Root location "" → last == null, safe no-op. + const result = removeOperation(model, ""); + + assert.equal(result, null); + expectGrid(model, [["H"]]); + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +test("removeOperation of the only remaining gate empties the circuit", () => { + // Removing the last op drops its column, and the trailing-wire trim + // then collapses every now-unused wire — leaving a fully empty model. + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + removeOperation(model, "0,0"); + + expectGrid(model, []); + assert.equal(model.componentGrid.length, 0); + assert.deepEqual(model.qubitUseCounts, []); + assert.equal(model.qubits.length, 0); +}); + +test("removeOperation at an out-of-bounds location is a safe no-op", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + // Out-of-range column ("9,0") and out-of-range op index in a valid + // column ("0,9") both resolve to no op → null, model untouched. + assert.equal(removeOperation(model, "9,0"), null); + assert.equal(removeOperation(model, "0,9"), null); + + expectGrid(model, [["H"]]); + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +test("removeOperation at an in-range but empty interior slot is a safe no-op", () => { + // Three populated columns on q0: [["H"], ["Y"], ["Z"]]. + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("H"), "0,0", 0); + addOperation(model, unitary("Y"), "1,0", 0); + addOperation(model, unitary("Z"), "2,0", 0); + expectGrid(model, [["H"], ["Y"], ["Z"]]); + + // "1,1" is a real, populated column that sits + // BETWEEN H and Z, but op index 1 points to no gate (the column holds a + // single op at index 0). Unlike "9,0"/"0,9", the coordinate is interior + // rather than past the end — it still resolves to no op, so removal is a + // safe no-op rather than dropping the neighboring gate. + const result = removeOperation(model, "1,1"); + + assert.equal(result, null, "an interior empty slot must resolve to no op"); + expectGrid(model, [["H"], ["Y"], ["Z"]]); + assert.deepEqual(model.qubitUseCounts, [3]); +}); + +// --------------------------------------------------------------------------- +// addControl +// --------------------------------------------------------------------------- + +test("addControl on an existing wire bumps qubitUseCounts without growing qubits", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("X"), "0,0", 0); + const op = at(model, "0,0"); + assert.equal(model.qubits.length, 2); + + // Control on wire 1 — already in the qubit list, so no growth. + assert.equal(addControl(model, op, 1), true); + assert.equal(model.qubits.length, 2); + assert.deepEqual(model.qubitUseCounts, [1, 1]); + expectOp(op, { X: { ctrls: [1] } }); +}); + +test("addControl on a wire several IDs beyond the end bulk-grows qubits", () => { + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("X"), "0,0", 0); + const op = at(model, "0,0"); + assert.equal(model.qubits.length, 1); + + // Control on wire 5 — ensureQubitCount(5) growth, same as addOperation. + assert.equal(addControl(model, op, 5), true); + + assert.equal(model.qubits.length, 6); + assert.deepEqual(model.qubitUseCounts, [1, 0, 0, 0, 0, 1]); + for (let i = 0; i < model.qubits.length; i++) { + assert.equal(model.qubits[i].id, i); + } + expectOp(op, { X: { ctrls: [5] } }); +}); + +test("addControl is a no-op when the wire is already a control", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("X"), "0,0", 0); + const op = at(model, "0,0"); + + assert.equal(addControl(model, op, 1), true); + assert.equal(model.qubitUseCounts[1], 1); + + // Second call on the same wire — already a control, no re-bump. + assert.equal(addControl(model, op, 1), false); + assert.equal(model.qubitUseCounts[1], 1); + expectOp(op, { X: { ctrls: [1] } }); +}); + +// --------------------------------------------------------------------------- +// removeControl +// --------------------------------------------------------------------------- + +test("removeControl from an interior wire leaves qubits.length untouched", () => { + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("X"), "0,0", 0); + const op = at(model, "0,0"); + // Controls on wires 1 (interior) and 2 (trailing). + addControl(model, op, 1); + addControl(model, op, 2); + assert.equal(model.qubits.length, 3); + assert.deepEqual(model.qubitUseCounts, [1, 1, 1]); + + // Remove the interior control (wire 1); wire 2 still anchors length. + assert.equal(removeControl(model, op, 1), true); + assert.equal(model.qubits.length, 3); + assert.deepEqual(model.qubitUseCounts, [1, 0, 1]); + expectOp(op, { X: { ctrls: [2] } }); +}); + +test("removeControl on the trailing wire bulk-trims every trailing unused wire", () => { + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("X"), "0,0", 0); + const op = at(model, "0,0"); + // Single far-out control on wire 5; wires 1..4 are zero-use trailers. + addControl(model, op, 5); + assert.equal(model.qubits.length, 6); + assert.deepEqual(model.qubitUseCounts, [1, 0, 0, 0, 0, 1]); + + // Remove the trailing control; trim walks back to wire 0 (target). + assert.equal(removeControl(model, op, 5), true); + + assert.equal(model.qubits.length, 1); + assert.deepEqual(model.qubitUseCounts, [1]); + expectOp(op, { X: { ctrls: [] } }); +}); + +test("removeControl trims to a mid-stack anchor introduced by an in-gap addControl", () => { + const model = new CircuitModel(emptyCircuit(1)); + addOperation(model, unitary("X"), "0,0", 0); + const op = at(model, "0,0"); + + addControl(model, op, 8); // grows to 9 + addControl(model, op, 4); // mid-stack anchor; no growth + assert.equal(model.qubits.length, 9); + assert.deepEqual(model.qubitUseCounts, [1, 0, 0, 0, 1, 0, 0, 0, 1]); + + // Remove the trailing control; trim walks back to wire 4. + assert.equal(removeControl(model, op, 8), true); + assert.equal(model.qubits.length, 5); + assert.deepEqual(model.qubitUseCounts, [1, 0, 0, 0, 1]); + expectOp(op, { X: { ctrls: [4] } }); +}); + +test("removeControl on a wire with no control returns false", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("X"), "0,0", 0); + const op = at(model, "0,0"); + + // No controls at all. + assert.equal(removeControl(model, op, 1), false); + + // Add one, then try to remove a different wire. + addControl(model, op, 1); + assert.equal(removeControl(model, op, 0), false); + expectOp(op, { X: { ctrls: [1] } }); +}); + +// --------------------------------------------------------------------------- +// addControl / removeControl: classical-ref entries don't shadow quantum controls +// --------------------------------------------------------------------------- +// +// A classically-controlled op carries a classical-ref `{qubit, result}` in both `.targets` and +// `.controls`. The control actions filter to pure-quantum entries (`result === undefined`), so +// add/remove on the classical-owner wire touches only the quantum entry. + +test("addControl: adding a quantum control on a wire that already has a classical-ref control succeeds", () => { + // M on q0 produces c_0.0; conditional X on q1 reads it. Adding a quantum control on q0 must + // succeed (the existing q0 entry is classical). + const model = new CircuitModel( + circuit(qubits(2, { 0: 1 }), [ + [meas(0)], + [gate("X", 1, { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ); + const condX = at(model, "1,0"); + + const ok = addControl(model, condX, 0); + + assert.equal(ok, true, "addControl must succeed on the classical-owner wire"); + // Both the classical-ref and the new quantum entry are present. + expectOp(condX, { X: { ctrls: [0, { q: 0, r: 0 }] } }); +}); + +test("removeControl: removing a quantum control on a wire that also has a classical-ref control leaves the classical ref intact", () => { + // Conditional X on q2 has a quantum control on q0 AND reads c_0.0. Removing the q0 control drops + // only the quantum entry. + const model = new CircuitModel( + circuit(qubits(3, { 0: 1 }), [ + [meas(0)], + [gate("X", 2, { ctrls: [0, { q: 0, r: 0 }], conditional: true })], + ]), + ); + const condX = at(model, "1,0"); + + const ok = removeControl(model, condX, 0); + + assert.equal(ok, true); + expectOp(condX, { X: { ctrls: [{ q: 0, r: 0 }] } }); +}); + +test("removeControl: removing a control on a wire that only has a classical-ref returns false (no-op)", () => { + // The classical-ref is the conditional dependency, not a removable control. + const model = new CircuitModel( + circuit(qubits(2, { 0: 1 }), [ + [meas(0)], + [gate("X", 1, { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ); + const condX = at(model, "1,0"); + + const ok = removeControl(model, condX, 0); + + assert.equal( + ok, + false, + "removeControl must refuse to remove a classical-ref", + ); + expectOp(condX, { X: { ctrls: [{ q: 0, r: 0 }] } }); +}); + +// --------------------------------------------------------------------------- +// addControl: collision-split with a same-column sibling (flat grid) +// --------------------------------------------------------------------------- + +test("addControl: top-level widening into a same-column sibling splits the column", () => { + // CNOT(target q0, control q1) shares col 0 with H@q2. Adding a control on q3 widens the CNOT to + // span q0..q3, overlapping H. + const model = new CircuitModel( + circuit(4, [[gate("X", 0, { ctrls: [1] }), gate("H", 2)]]), + ); + const cnotOp = at(model, "0,0"); + const ok = addControl(model, cnotOp, 3); + assert.ok(ok, "addControl should succeed on a fresh wire"); + + // Column splits into [CNOT] then [H]; CNOT carries both controls. + expectGrid(model, [[{ X: { ctrls: [1, 3] } }], ["H"]]); +}); + +test("addControl: no overlap means no split", () => { + // CNOT(target q0, control q1) shares col 0 with H@q3. Adding a control on q2 keeps the CNOT span + // clear of H — no split. + const model = new CircuitModel( + circuit(4, [[gate("X", 0, { ctrls: [1] }), gate("H", 3)]]), + ); + const cnotOp = at(model, "0,0"); + const ok = addControl(model, cnotOp, 2); + assert.ok(ok); + + expectGrid(model, [["X", "H"]]); +}); + +test("addControl: widening past MULTIPLE same-column siblings shifts every sibling right", () => { + // col 0 = [CNOT(target q0, control q1), Y@q2, Z@q3]. A control on the clear wire q4 widens the + // CNOT over both Y and Z. The split inserts ONE fresh column for the CNOT; siblings stay paired. + const model = new CircuitModel( + circuit(5, [[gate("X", 0, { ctrls: [1] }), gate("Y", 2), gate("Z", 3)]]), + ); + const cnotOp = at(model, "0,0"); + const ok = addControl(model, cnotOp, 4); + assert.ok(ok); + + expectGrid(model, [["X"], ["Y", "Z"]]); +}); + +// --------------------------------------------------------------------------- +// addControl / removeControl: shape refusals (multi-target ops & groups) +// --------------------------------------------------------------------------- + +test("addControl: refuses on a classically-controlled GROUP (groups never carry quantum controls by design)", () => { + // Groups (any op with children) may carry classical controls only — the editor refuses to author + // quantum controls on them. + const model = new CircuitModel( + circuit(qubits(4, { 0: 1 }), [ + [meas(0)], + [ + group("CondGroup", [[gate("H", 1), gate("X", 2)]], { + ctrls: [{ q: 0, r: 0 }], + conditional: true, + }), + ], + ]), + ); + const groupOp = at(model, "1,0"); + + const ok = addControl(model, groupOp, 3); + + assert.equal(ok, false, "addControl must refuse on a group"); + // Only the original classical-ref control survives, untouched. + expectOp(groupOp, { CondGroup: { ctrls: [{ q: 0, r: 0 }] } }); +}); + +test("addControl: still succeeds on a classically-controlled single-target UNITARY (no children)", () => { + // A single-target classically-controlled unitary isn't multi-target, so a quantum control on a + // fresh wire is allowed. + const model = new CircuitModel( + circuit(qubits(3, { 0: 1 }), [ + [meas(0)], + [gate("X", 1, { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ); + const op = at(model, "1,0"); + + const ok = addControl(model, op, 2); + + assert.equal(ok, true); + // New quantum control plus the original classical-ref. + expectOp(op, { X: { ctrls: [2, { q: 0, r: 0 }] } }); +}); + +test("addControl: refuses on a multi-target unitary even without children", () => { + // SWAP: targets.length === 2, no children — multi-leg, so refused. + const model = new CircuitModel(circuit(3, [[gate("SWAP", [0, 1])]])); + const swap = at(model, "0,0"); + + const ok = addControl(model, swap, 2); + + assert.equal(ok, false); + expectOp(swap, { SWAP: { targets: [0, 1], ctrls: [] } }); +}); + +test("addControl: refuses on a plain group (no classical conditions)", () => { + // Pure organizational group — children, no controls. Multi-leg, refused. + const model = new CircuitModel( + circuit(3, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + const groupOp = at(model, "0,0"); + + const ok = addControl(model, groupOp, 2); + + assert.equal(ok, false); + expectOp(groupOp, { Foo: { ctrls: [] } }); +}); + +test("removeControl: refuses on a multi-target / group op, leaving existing controls in place", () => { + // A group loaded with a pre-existing quantum control (e.g. from external data): the editor + // refuses to remove it. + const model = new CircuitModel( + circuit(4, [ + [group("Foo", [[gate("H", 1), gate("X", 2)]], { ctrls: [0] })], + ]), + ); + const groupOp = at(model, "0,0"); + + const ok = removeControl(model, groupOp, 0); + + assert.equal(ok, false); + // The pre-existing control survives a refused removeControl. + expectOp(groupOp, { Foo: { ctrls: [0] } }); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/groupAddRemove.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupAddRemove.test.mjs new file mode 100644 index 00000000000..acab620e2e0 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupAddRemove.test.mjs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Add- and remove-mutator tests on grouped shapes, driven through the public `addOperation` and +// `removeOperation` actions. Counterpart to `addRemove.test.mjs` (which covers the flat, +// non-grouped case). + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + addOperation, + removeOperation, +} from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + build, + circuit, + expectGrid, + expectOp, + gate, + group, +} from "../_helpers.mjs"; + +test("addOperation: dropping on a group's trailing inner-column slot adds the op as a child", () => { + const model = build(circuit(2, [[group("Foo", [[gate("H", 0)]])]])); + + // Drop Y on Foo's trailing inner-column slot "0,0-1,0". + const added = addOperation(model, gate("Y", 0), "0,0-1,0", 0); + assert.ok(added, "addOperation should return the new op"); + + expectGrid(model, [["Foo"]]); + expectOp(at(model, "0,0"), { + Foo: { children: [[{ H: 0 }], [{ Y: 0 }]] }, + }); +}); + +test("addOperation: adding to an interior inner column on a clear wire merges into that column", () => { + // Foo's inner grid is [[H@0], [Z@0]]. Adding Y@1 at inner column 1 (a real, populated column) + // with no overlap merges Y alongside Z rather than splitting. + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0)], [gate("Z", 0)]])]]), + ); + + const added = addOperation(model, gate("Y", 1), "0,0-1,0", 1); + assert.ok(added); + + expectOp(at(model, "0,0"), { + Foo: { children: [[{ H: 0 }], [{ Y: 1 }, { Z: 0 }]] }, + }); +}); + +test("addOperation: insertNewColumn splits an interior inner column, shifting later columns right", () => { + // Foo's inner grid is [[H@0], [Z@0]]. Inserting Y at inner column 1 with insertNewColumn pushes + // the existing Z column one step right inside the group. + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0)], [gate("Z", 0)]])]]), + ); + + const added = addOperation( + model, + gate("Y", 0), + "0,0-1,0", + 0, + /* insertNewColumn */ true, + ); + assert.ok(added); + + expectOp(at(model, "0,0"), { + Foo: { children: [[{ H: 0 }], [{ Y: 0 }], [{ Z: 0 }]] }, + }); +}); + +test("addOperation: an overlapping insert inside a group forces a new inner column", () => { + // Inner column 0 already holds H@0. Adding X@0 there would collide on wire 0, so the add splits + // into a fresh inner column ahead of H. + const model = build(circuit(2, [[group("Foo", [[gate("H", 0)]])]])); + + const added = addOperation(model, gate("X", 0), "0,0-0,0", 0); + assert.ok(added, "an overlapping insert is resolved, not rejected"); + + expectOp(at(model, "0,0"), { + Foo: { children: [[{ X: 0 }], [{ H: 0 }]] }, + }); +}); + +test("removeOperation strips a leaf inside an expanded group", () => { + const model = build( + circuit(2, [[group("Group", [[gate("H", 0), gate("X", 1)]])]]), + ); + + // Remove the nested X (group col 0, row 1). + removeOperation(model, "0,0-0,1"); + + // Outer group remains; the X inside its single child column is gone. + expectOp(at(model, "0,0"), { Group: { children: [[{ H: 0 }]] } }); +}); + +test("removeOperation: removing a deep child narrows the group's targets", () => { + // Foo spans wires 0-1; removing the nested Y must narrow Foo's cached targets to just [0]. + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0)], [gate("Y", 1)]])]]), + ); + + removeOperation(model, "0,0-1,0"); + + expectOp(at(model, "0,0"), { Foo: { targets: [0] } }); +}); + +test("removeOperation: cascade — removing across multiple nested groups narrows every ancestor", () => { + // Outer ⊃ Inner, one gate per inner column. Removing the nested Y narrows Inner to [0,1] and + // Outer in lockstep. + const model = build( + circuit(3, [ + [ + group("Outer", [ + [group("Inner", [[gate("H", 0)], [gate("X", 1)], [gate("Y", 2)]])], + ]), + ], + ]), + ); + + removeOperation(model, "0,0-0,0-2,0"); + + expectOp(at(model, "0,0"), { Outer: { targets: [0, 1] } }); + expectOp(at(model, "0,0-0,0"), { Inner: { targets: [0, 1] } }); +}); + +test("removeOperation: removing a group's last child prunes the emptied group", () => { + const model = build(circuit(2, [[group("Foo", [[gate("H", 0)]])]])); + + removeOperation(model, "0,0-0,0"); + + // Foo held only H; with H gone the group is deleted, leaving an empty circuit. + expectGrid(model, []); +}); + +test("removeOperation: prune cascades through nested groups emptied in lockstep", () => { + // Inner is Outer's only child, so removing Inner's only gate empties Inner, which empties Outer. + const model = build( + circuit(2, [[group("Outer", [[group("Inner", [[gate("H", 0)]])]])]]), + ); + + removeOperation(model, "0,0-0,0-0,0"); + + expectGrid(model, []); +}); + +test("removeOperation: prune STOPS at the first non-empty ancestor", () => { + // Y keeps Outer alive after Inner is emptied, so only Inner is pruned. + const model = build( + circuit(2, [ + [group("Outer", [[group("Inner", [[gate("H", 0)]]), gate("Y", 0)]])], + ]), + ); + + removeOperation(model, "0,0-0,0-0,0"); + + // Inner is gone; Outer survives holding just Y. + expectOp(at(model, "0,0"), { Outer: { children: [[{ Y: 0 }]] } }); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/groupAncestorRefresh.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupAncestorRefresh.test.mjs new file mode 100644 index 00000000000..c81dcac00ea --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupAncestorRefresh.test.mjs @@ -0,0 +1,334 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Ancestor `.targets` cache refresh after a child mutation: when a group's children change shape +// (via `addOperation`, `removeOperation`, `addControl`, `removeControl`, or `moveOperation`), every +// ancestor's eager `.targets` is re-derived bottom-up in canonical `(qubit, result)` order that the +// renderer (`_splitTargetsY`, `_unitary` box geometry) depends on. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + addControl, + addOperation, + moveOperation, + removeControl, + removeOperation, +} from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + assertEnclosesWires, + at, + build, + circuit, + expectOp, + gate, + group, + meas, + topShape, + wires, +} from "../_helpers.mjs"; + +// --------------------------------------------------------------------------- +// addOperation / removeOperation: ancestor refresh. +// +// Both paths mutate a group's children, so the group's eager `.targets` cache must be refreshed +// afterwards. +// --------------------------------------------------------------------------- + +test("addOperation: adding a child to a group on a wire outside its span extends the group's targets", () => { + const model = build(circuit(3, [[group("Foo", [[gate("H", 0)]])]])); + + // add Y into Foo's trailing inner slot on q2 (outside its span) + const added = addOperation(model, gate("Y", 0), "0,0-1,0", 2); + assert.ok(added, "addOperation should return the new op"); + + assertEnclosesWires(at(model, "0,0"), 2); +}); + +test("addOperation: cascade — adding deep into a nested group extends both inner and outer ancestors", () => { + const model = build( + circuit(3, [[group("Outer", [[group("Inner", [[gate("H", 0)]])]])]]), + ); + + // add Y deep inside Inner on q2 (outside both spans) + const added = addOperation(model, gate("Y", 0), "0,0-0,0-1,0", 2); + assert.ok(added); + + assertEnclosesWires(at(model, "0,0-0,0"), 2); + assertEnclosesWires(at(model, "0,0"), 2); +}); + +test("removeOperation: removing the only child on a wire narrows the group's targets", () => { + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0)], [gate("Y", 1)]])]]), + ); + + // remove Y@q1 — Foo's span must shrink to just [0] + removeOperation(model, "0,0-1,0"); + + expectOp(at(model, "0,0"), { Foo: { targets: [0] } }); +}); + +test("removeOperation: cascade — removing a deep child narrows nested ancestors", () => { + const model = build( + circuit(3, [ + [ + group("Outer", [ + [group("Inner", [[gate("H", 0)], [gate("X", 1)], [gate("Y", 2)]])], + ]), + ], + ]), + ); + + // remove Y@q2 — Inner and Outer both narrow to [0, 1] + removeOperation(model, "0,0-0,0-2,0"); + + expectOp(at(model, "0,0"), { Outer: { targets: [0, 1] } }); + expectOp(at(model, "0,0-0,0"), { Inner: { targets: [0, 1] } }); +}); + +// --------------------------------------------------------------------------- +// addControl / removeControl: ancestor refresh. +// +// Adding/removing a control widens or narrows the op's wire span, which must propagate into every +// ancestor's `.targets` cache. +// --------------------------------------------------------------------------- + +test("addControl: adding a control to a child op on a wire outside the group's span extends the group's targets", () => { + const model = build(circuit(3, [[group("Foo", [[gate("H", 0)]])]])); + + // control H @ q2 (outside Foo's span) + const hOp = at(model, "0,0-0,0"); + const added = addControl(model, hOp, 2); + assert.ok(added, "addControl should return true on a fresh wire"); + + assertEnclosesWires(at(model, "0,0"), 2); +}); + +test("addControl: cascade — adding a control deep inside a nested group extends both ancestors", () => { + const model = build( + circuit(3, [[group("Outer", [[group("Inner", [[gate("H", 0)]])]])]]), + ); + + // control the deepest H @ q2 — Inner and Outer both extend + addControl(model, at(model, "0,0-0,0-0,0"), 2); + + assertEnclosesWires(at(model, "0,0-0,0"), 2); + assertEnclosesWires(at(model, "0,0"), 2); +}); + +test("removeControl: removing the only control extending a group's span narrows the group's targets", () => { + const model = build( + circuit(3, [[group("Foo", [[gate("H", 0, { ctrls: [2] })]])]]), + ); + + // remove H's q2 control — the only thing reaching q2 inside Foo + const hOp = at(model, "0,0-0,0"); + const removed = removeControl(model, hOp, 2); + assert.ok(removed, "removeControl should return true when control existed"); + + expectOp(at(model, "0,0"), { Foo: { targets: [0] } }); +}); + +// --------------------------------------------------------------------------- +// moveOperation: ancestor refresh. +// +// `moveOperation` re-derives each destination ancestor's `.targets` from its post-move children. +// The target location string is authoritative: dropping into group G makes G's `.targets` reflect +// that, even when the drop wire was outside G's pre-move span. The cascade walks innermost-out and +// stops at the first ancestor that already encloses the widened child below it. +// --------------------------------------------------------------------------- + +test("moveOperation extend: shift-drop onto a wire just outside group's span extends the group's targets", () => { + // With H now Foo's only child, Foo re-derives to [2]; the point is that q2 (outside the old span + // 0-1) is enclosed. + const model = build(circuit(3, [[group("Foo", [[gate("H", 0)]])]])); + + // shift-extend H from q0 → q2 + const moved = moveOperation( + model, + /* sourceLocation */ "0,0-0,0", + /* targetLocation */ "0,0-0,0", + /* sourceWire */ 0, + /* targetWire */ 2, + /* movingControl */ false, + /* insertNewColumn */ false, + ); + assert.ok(moved, "extend move should return the moved op"); + + const fooOp = at(model, "0,0"); + assertEnclosesWires(fooOp, 2); + expectOp(fooOp, { Foo: { children: [[{ H: 2 }]] } }); +}); + +test("moveOperation extend: shift-drop several wires past the span extends across the gap", () => { + // A non-contiguous span is unrepresentable; .targets is a set whose min/max define the rendered + // span, so it must reach q4. + const model = build(circuit(5, [[group("Foo", [[gate("H", 0)]])]])); + + // shift-extend H from q0 → q4 (two-wire gap) + const moved = moveOperation(model, "0,0-0,0", "0,0-0,0", 0, 4, false, false); + assert.ok(moved); + + assertEnclosesWires(at(model, "0,0"), 4); +}); + +test("moveOperation extend: multi-wire source extends to cover its new top wire", () => { + // Grabbing CNOT by its target (q1) and dropping on q2 slides control 0→1 and target 1→2; Foo's + // new max must reach q2. + const model = build( + circuit(4, [[group("Foo", [[gate("X", 1, { ctrls: [0] })]])]]), + ); + + // shift-extend CNOT, grabbed by target q1, dropped on q2 (delta=1) + const moved = moveOperation( + model, + /* sourceLocation */ "0,0-0,0", + /* targetLocation */ "0,0-0,0", + /* sourceWire */ 1, + /* targetWire */ 2, + /* movingControl */ false, + /* insertNewColumn */ false, + ); + assert.ok(moved); + + const fooWires = wires(at(model, "0,0")); + assert.ok( + Math.max(...fooWires) >= 2, + `Foo's span must extend to at least wire 2; got ${JSON.stringify(fooWires)}`, + ); +}); + +test("moveOperation extend: cascade refreshes nested ancestors whose span is now exceeded", () => { + // Cascade: Inner extends to enclose q2, then Outer (no longer enclosing Inner's new span) extends + // too. + const model = build( + circuit(3, [[group("Outer", [[group("Inner", [[gate("H", 0)]])]])]]), + ); + + // shift-extend H (deep inside Inner) from q0 → q2 + const moved = moveOperation( + model, + "0,0-0,0-0,0", + "0,0-0,0-0,0", + 0, + 2, + false, + false, + ); + assert.ok(moved); + + assertEnclosesWires(at(model, "0,0-0,0"), 2); + assertEnclosesWires(at(model, "0,0"), 2); +}); + +test("moveOperation extend: cascade stops at first ancestor that already encloses the child", () => { + // Outer spans 0-3 (padding P0@q0, P3@q3); Inner spans 1-2. Dropping H onto q0 is inside Outer but + // outside Inner: Inner extends, Outer is already wide enough and the cascade early-exits. + const model = build( + circuit(4, [ + [ + group("Outer", [ + [gate("P0", 0)], + [group("Inner", [[gate("H", 1)]])], + [gate("P3", 3)], + ]), + ], + ]), + ); + + // shift-extend H from q1 → q0 (inside Outer, outside Inner) + const moved = moveOperation( + model, + "0,0-1,0-0,0", + "0,0-1,0-1,0", + 1, + 0, + false, + false, + ); + assert.ok(moved); + + assertEnclosesWires(at(model, "0,0-1,0"), 0); + // Outer still anchored by P0 and P3 — cascade didn't need to widen it. + assertEnclosesWires(at(model, "0,0"), 0, 3); +}); + +test("moveOperation extend: dest cascade is a no-op when dest is top-level, even when source-side prune empties the source group", () => { + // Moving Foo's only child to top level empties Foo: the source-side prune removes Foo while the + // dest-side cascade (top-level dest) is a no-op. Confirms the two halves don't interfere. + const model = build( + circuit(3, [[group("Foo", [[gate("H", 0)]])], [gate("Y", 2)]]), + ); + + // move H from inside Foo to top-level "1,1" (q2), emptying Foo + const moved = moveOperation(model, "0,0-0,0", "1,1", 0, 2, false, true); + assert.ok(moved, "move must succeed when dest is top-level"); + + const top = topShape(model).flat(); + assert.ok(!top.includes("Foo"), "Foo must be pruned after last child leaves"); + assert.ok(top.includes("H"), "H must land at top level"); + assert.ok(top.includes("Y"), "Y must remain at top level"); +}); + +test("moveOperation extend: external source dropped into group on off-span wire extends the group", () => { + // Cross-chain move: source lives OUTSIDE Foo, so the source-side refresh acts on H's old + // top-level ancestors. The dest-side cascade is the only thing keeping Foo's `.targets` honest + // here. + const model = build( + circuit(3, [[gate("H", 2)], [group("Foo", [[gate("X", 0)]])]]), + ); + + // move top-level H@q2 into Foo's trailing inner col on q2 (off-span) + const moved = moveOperation( + model, + /* sourceLocation */ "0,0", + /* targetLocation */ "1,0-1,0", + /* sourceWire */ 2, + /* targetWire */ 2, + /* movingControl */ false, + /* insertNewColumn */ false, + ); + assert.ok(moved, "move must succeed"); + + // Top-level col 0 (only had H) is now empty and pruned, so Foo lands at top-level "0,0". + assertEnclosesWires(at(model, "0,0"), 2); +}); + +// --------------------------------------------------------------------------- +// Canonical target-order invariant. +// +// Refreshed group targets must be in canonical `(qubit, result)` order — qubit-only refs before +// their classical-result siblings — regardless of child iteration order. Renderer consumers +// (`_splitTargetsY`, `_unitary` box geometry) depend on this. +// --------------------------------------------------------------------------- + +test("ancestor refresh: produces canonical (qubit, result) target order regardless of child-iteration order", () => { + // Child-iteration visits refs as [q2, c2.0, q0]; the refresh must re-sort to canonical (qubit + // index first; qubit-only before result-bearing for the same qubit). + const model = build( + circuit(3, [ + [ + group("Foo", [ + [meas(2)], + [gate("H", 0, { ctrls: [{ q: 2, r: 0 }], conditional: true })], + ]), + ], + ]), + ); + + // control H @ q1 — triggers an ancestor refresh + addControl(model, at(model, "0,0-1,0"), 1); + + const keys = at(model, "0,0").targets.map((/** @type {any} */ r) => + r.result === undefined ? `q${r.qubit}` : `c${r.qubit}.${r.result}`, + ); + + assert.deepEqual( + keys, + ["q0", "q1", "q2", "c2.0"], + `Foo.targets must be canonically sorted (qubit, result); got ${JSON.stringify(keys)}`, + ); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/groupCollisionSplit.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupCollisionSplit.test.mjs new file mode 100644 index 00000000000..5b6d8d485d5 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupCollisionSplit.test.mjs @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Group collision-split tests. +// +// When an op's `.targets` grow (shift-extend during a move, or `addControl` widening), the action +// layer's `_resolveSpanChange` checks the op against its column siblings and propagates up through +// every ancestor. If the widened drawn span overlaps a sibling, the column splits: the widened op +// gets a fresh column at its current index; surviving siblings shift one slot right. The flat base +// case lives in `circuit-actions/addRemove.test.mjs`. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + addControl, + moveOperation, +} from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + build, + circuit, + expectGrid, + expectOp, + gate, + group, + meas, +} from "../_helpers.mjs"; + +// --------------------------------------------------------------------------- +// Collision-split when extending a group's span overlaps a sibling. +// --------------------------------------------------------------------------- + +test("moveOperation extend: extending across a column-sibling splits the column, group shifts left", () => { + // Y@0 pins Foo's low end so shift-dropping H from q0→q2 widens Foo to [0, 2], overlapping Z@1. + const model = build( + circuit(3, [[group("Foo", [[gate("Y", 0), gate("H", 1)]]), gate("Z", 2)]]), + ); + + // shift-extend H from q0 → q2 + const moved = moveOperation(model, "0,0-0,1", "0,0-0,1", 0, 3, false, false); + assert.ok(moved); + + expectGrid(model, [[{ Foo: { targets: [0, 3] } }], [{ Z: 2 }]]); +}); + +test("moveOperation extend: extending-move the only child doesn't split column", () => { + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0)]]), gate("Z", 1)]]), + ); + + // shift-extend the lone H from q0 → q2 + const moved = moveOperation(model, "0,0-0,0", "0,0-0,0", 0, 2, false, false); + assert.ok(moved); + + expectGrid(model, [[{ Foo: { targets: [2] } }, { Z: 1 }]]); +}); + +test("moveOperation extend: extending without collision does NOT split the column", () => { + // Sibling Z@3 sits OUTSIDE Foo's post-extend span [0, 2] — no split. + const model = build( + circuit(4, [[group("Foo", [[gate("H", 0), gate("Y", 1)]]), gate("Z", 3)]]), + ); + + // shift-extend H from q0 → q2 (stays clear of Z@3) + const moved = moveOperation(model, "0,0-0,0", "0,0-0,0", 0, 2, false, false); + assert.ok(moved); + + expectGrid(model, [ + [{ Foo: { targets: [1, 2], children: [[{ Y: 1 }, { H: 2 }]] } }, { Z: 3 }], + ]); +}); + +test("moveOperation extend: multiple column-siblings all survive the split", () => { + // X@0 pins Foo's low end; shift-dropping H to q4 widens Foo to [0, 4], overlapping both Y@2 and + // Z@3. + const model = build( + circuit(5, [ + [ + group("Foo", [[gate("X", 0)], [gate("H", 0)]]), + gate("Y", 2), + gate("Z", 3), + ], + ]), + ); + + // shift-extend H from q0 → q4 + const moved = moveOperation(model, "0,0-1,0", "0,0-1,0", 0, 4, false, false); + assert.ok(moved); + + // Y and Z preserved in their original relative order. + expectGrid(model, [["Foo"], ["Y", "Z"]]); +}); + +test("moveOperation extend: nested ancestor splits its own containing column on cascade", () => { + // Inner's two children both pin q0; shift-dropping H to q2 widens Inner to [0, 2], overlapping + // Z@1 inside Outer's child grid. The split happens at the inner level. Outer's span stays [0, 2] + // (S@2 still pins its high end), so the cascade bubbles up but finds nothing to split at the top + // level. + const model = build( + circuit(3, [ + [ + group("Outer", [ + [group("Inner", [[gate("H", 0)], [gate("Y", 0)]]), gate("Z", 1)], + [gate("S", 2)], + ]), + ], + ]), + ); + + // shift-extend H (deep inside Inner) from q0 → q2 + const moved = moveOperation( + model, + "0,0-0,0-0,0", + "0,0-0,0-0,0", + 0, + 2, + false, + false, + ); + assert.ok(moved); + + expectGrid(model, [["Outer"]]); + expectOp(at(model, "0,0"), { + Outer: { children: [["Inner"], ["Z"], ["S"]] }, + }); +}); + +// --------------------------------------------------------------- +// Shift-extend cross-over cases. +// +// When a group is shift-extended past an external sibling on an in-between wire, the cascade splits +// the outer column so the sibling slides one column right. These tests pin the case where the +// in-between sibling is itself a multi-wire op. +// --------------------------------------------------------------- + +test("moveOperation extend: cross-over a GROUP sibling splits the column, leaving both groups intact", () => { + // Foo[0,1] and Bar[3,4] sit side-by-side in one outer column. X@1 pins Foo's low end, so moving H + // to q4 widens Foo to [1, 4] (still one outer column, just more wires), overlapping Bar. + const model = build( + circuit(5, [ + [ + group("Foo", [[gate("H", 0), gate("X", 1)]]), + group("Bar", [[gate("Y", 3), gate("Z", 4)]]), + ], + ]), + ); + + // shift-extend H from q0 → q4 (into Foo's trailing inner column) + const moved = moveOperation(model, "0,0-0,0", "0,0-1,0", 0, 4, false, false); + assert.ok(moved); + + expectGrid(model, [ + [{ Foo: { targets: [1, 4] } }], + [{ Bar: { children: [[{ Y: 3 }, { Z: 4 }]] } }], + ]); +}); + +test("moveOperation extend: cross-over a sibling on an IN-BETWEEN wire (drop wire is clear past it)", () => { + // X@0 pins Foo's low end; shift-dropping H past Z@3 onto a clear wire q4 widens Foo to [0, 4], + // catching Z even though the drop wire itself is clear. + const model = build( + circuit(5, [[group("Foo", [[gate("X", 0), gate("H", 1)]]), gate("Z", 3)]]), + ); + + // shift-extend H from q1 → q4 (past Z@3) + const moved = moveOperation(model, "0,0-0,1", "0,0-1,0", 1, 4, false, false); + assert.ok(moved); + + // Z stays on wire 3 — the resolver shifts COLUMNS, not WIRES. + expectGrid(model, [[{ Foo: { targets: [0, 4] } }], [{ Z: 3 }]]); +}); + +test("moveOperation extend: deeply-nested source past a multi-wire ancestor sibling splits at the top ancestor", () => { + // 3-deep nesting (Outer > Middle > Foo) with Sib[3,4] a sibling of Outer. X@0 pins Foo's low end; + // shift-dropping H to q5 widens Foo/Middle/Outer all the way up, so Outer's [0, 5] overlaps Sib. + // The dest-side cascade must keep walking past unchanged rungs to resolve the collision at the + // topmost ancestor. + const model = build( + circuit(6, [ + [ + group("Outer", [ + [group("Middle", [[group("Foo", [[gate("X", 0), gate("H", 1)]])]])], + ]), + group("Sib", [[gate("Y", 3), gate("Z", 4)]]), + ], + ]), + ); + + // shift-extend H (deepest leaf) from q1 → q5 (past Sib[3,4]) + const moved = moveOperation( + model, + "0,0-0,0-0,0-0,1", + "0,0-0,0-0,0-1,0", + 1, + 5, + false, + false, + ); + assert.ok(moved); + + expectGrid(model, [ + [{ Outer: { targets: [0, 5] } }], + [{ Sib: { children: [[{ Y: 3 }, { Z: 4 }]] } }], + ]); +}); + +// --------------------------------------------------------------- +// Centralized post-widening cleanup. +// +// Any path that widens an op's `.targets` / `.controls` must trigger the split-and-shift via +// `_resolveSpanChange`. These cover the `addControl` widening path, group-internal and +// group-via-ancestor. +// --------------------------------------------------------------- + +test("addControl: nested widening into a same-column sibling inside a group splits inside the group", () => { + // Adding a control on q3 to H widens it to q0..q3, overlapping Z@3 inside Foo's child grid. + const model = build( + circuit(4, [[group("Foo", [[gate("H", 0), gate("Z", 3)]])]]), + ); + // control H @ q3 + const hOp = at(model, "0,0-0,0"); + assert.ok(addControl(model, hOp, 3)); + + expectOp(at(model, "0,0"), { Foo: { children: [["H"], ["Z"]] } }); +}); + +test("addControl: widening that pushes the OUTER GROUP into its top-level sibling also splits the top-level column", () => { + // Control on q3 widens H to q0..q3 → Foo's cache widens → Foo overlaps X@3 at the top level. + const model = build( + circuit(4, [[group("Foo", [[gate("H", 0)]]), gate("X", 3)]]), + ); + // control H @ q3 + const hOp = at(model, "0,0-0,0"); + addControl(model, hOp, 3); + + expectGrid(model, [[{ Foo: { targets: [0, 3] } }], ["X"]]); +}); + +// --------------------------------------------------------------- +// Overlap-collision check uses the drawn span of siblings. +// +// `getMinMaxRegIdx` includes classical-control connector wires, so a sibling whose target is on a +// high wire but whose classical control points at a low-wire measurement visually occupies every +// wire between them. A widened op intersecting that connector collides even when it misses the +// quantum target. +// --------------------------------------------------------------- + +test("addControl widening: sibling with classical control on a LOW wire (drawn-span overlap) triggers split even when quantum target is clear", () => { + // X's quantum span is [q3]; its drawn span is [q1, q3] (connector down to the M@q1 producer). + // Widening Foo to q0..q2 misses q3 but crosses X's connector. + const model = build( + circuit(5, [ + [meas(1)], + [ + group("Foo", [[gate("H", 0)]]), + gate("X", 3, { ctrls: [{ q: 1, r: 0 }] }), + ], + ]), + ); + // control H @ q2 + const hOp = at(model, "1,0-0,0"); + addControl(model, hOp, 2); + + // Foo's targets are exactly [0, 2] (NOT q3), so the split was driven ONLY by the drawn-span + // collision with X's classical connector. + expectGrid(model, [["M"], [{ Foo: { targets: [0, 2] } }], ["X"]]); +}); + +test("moveOperation shift-extend: cross-over a sibling whose drawn span includes a classical-control wire", () => { + // Same drawn-span vs quantum-span distinction, via shift-extend. Foo widens to q0..q2, crossing + // X's classical connector (q1..q3) without touching X's quantum target q3. + const model = build( + circuit(5, [ + [meas(1)], + [ + group("Foo", [[gate("H", 0)]]), + gate("X", 3, { ctrls: [{ q: 1, r: 0 }] }), + ], + ]), + ); + + // shift-extend H from q0 → q2 + const moved = moveOperation(model, "1,0-0,0", "1,0-1,0", 0, 2, false, false); + assert.ok(moved, "moveOperation must succeed"); + + expectGrid(model, [["M"], [{ Foo: { targets: [2] } }], ["X"]]); +}); + +test("no false split: widening from ABOVE that lands just short of a classically-controlled sibling's drawn span stays put", () => { + // The classical row q1.r0 sits BETWEEN q1 and q2 (row order: q0, q1, q1.r0, q2). X's drawn span + // is rows q1.r0..q2 (connector up from q2, never reaching q1). Widening Z to rows q0..q1 lands + // strictly ABOVE q1.r0 → no overlap → no split. + const model = build( + circuit(3, [ + [meas(1)], + [gate("Z", 0), gate("X", 2, { ctrls: [{ q: 1, r: 0 }] })], + ]), + ); + // control Z @ q1 + const zOp = at(model, "1,0"); + addControl(model, zOp, 1); + + expectGrid(model, [["M"], ["X", "Z"]]); +}); + +test("split: widening from BELOW that reaches into a classically-controlled sibling's drawn span splits the column", () => { + // Mirror of the previous test, other direction. X is ABOVE the measurement, reaching DOWN to + // q1.r0; its drawn span is rows q0..q1.r0. Widening Z to rows q1..q2 includes q1.r0 (between q1 + // and q2) → overlap → split. The widened op (Z) gets the fresh column; X shifts one column right. + const model = build( + circuit(5, [ + [meas(1)], + [gate("X", 0, { ctrls: [{ q: 1, r: 0 }] }), gate("Z", 2)], + ]), + ); + // control Z @ q1 + const zOp = at(model, "1,1"); + addControl(model, zOp, 1); + + expectGrid(model, [["M"], [{ Z: { targets: [2], ctrls: [1] } }], ["X"]]); +}); + +// --------------------------------------------------------------- +// Ordinary (non-shift-extend) move into a sibling-occupied column. +// +// Same `_resolveSpanChange` chokepoint, for source shapes the other tests don't cover: a CONTROLLED +// gate (control leg drives the collision) and a MULTI-TARGET gate (SWAP). +// --------------------------------------------------------------- + +test("moveOperation: moving a CONTROLLED gate into a sibling-occupied column splits the column", () => { + // CNOT's span [q0, q3] envelops Y@q1 in the destination column. + const model = build( + circuit(4, [ + [gate("CNOT", 0, { ctrls: [2] })], + [gate("S", 3)], + [gate("Y", 1)], + ]), + ); + + // move CNOT into Y's column (q0) + const moved = moveOperation(model, "0,0", "2,0", 0, 0, false, false); + assert.ok(moved); + + expectGrid(model, [ + [{ S: 3 }], + [{ CNOT: { targets: [0], ctrls: [2] } }], + [{ Y: 1 }], + ]); +}); + +test("moveOperation: moving a MULTI-TARGET gate (SWAP) into a sibling-occupied column splits the column", () => { + // SWAP moves as a unit; its span [q0, q2] envelops Y@q1 in the destination column. + const model = build( + circuit(4, [[gate("SWAP", [0, 2])], [gate("S", 3)], [gate("Y", 1)]]), + ); + + // move SWAP into Y's column + const moved = moveOperation(model, "0,0", "2,0", 0, 0, false, false); + assert.ok(moved); + + expectGrid(model, [[{ S: 3 }], [{ SWAP: [0, 2] }], [{ Y: 1 }]]); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/groupMove.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupMove.test.mjs new file mode 100644 index 00000000000..7e9df9fbe3f --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupMove.test.mjs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Group move tests: moving a child out of a group, dragging the group as a rigid unit, +// classical-control anchoring on the group's children, empty-group cleanup, trailing inner-column +// dropzone, and quantum control-leg drags on multi-target gates. Groups themselves carry classical +// controls only — the authoring layer refuses quantum controls on groups — so the control-leg drag +// mechanics are exercised on multi-target gates, which share the same multi-wire-leg shape and +// single-leg drag path (`_moveAsUnit` returns false whenever a control is moving). Single-target +// (CNOT / CCX) control-leg drags are covered separately in the `circuit-actions/` suite. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { moveOperation } from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + build, + circuit, + expectGrid, + expectOp, + gate, + group, +} from "../_helpers.mjs"; + +// --------------------------------------------------------------------------- +// `moveOperation` cross-scope correctness. +// +// After a successful move, the original location's grid no longer contains the op, and the target +// grid contains exactly one copy. `moveOperation` resolves the source op's parent grid BEFORE +// `_moveX` mutates the model so that splicing a new column ahead of the source's path (e.g. moving +// a child out of a group to a fresh top-level column at index 0) doesn't stale the source location +// lookup and leave a duplicate behind. +// --------------------------------------------------------------------------- + +test("moveOperation: moving a child out of a group to a new column ahead of the group does NOT leave a duplicate behind", () => { + const model = build( + circuit(3, [ + [gate("X", 2)], + [group("Group", [[gate("H", 0), gate("Z", 1)]])], + ]), + ); + + // move H to a fresh top-level column ahead of the group + const moved = moveOperation(model, "1,0-0,0", "0,0", 0, 0, false, true); + assert.ok(moved, "move should return the new operation"); + + // H lands in the new lead column; X and the surviving Group shift right by one. Exactly one H — + // no duplicate left behind. + expectGrid(model, [[{ H: 0 }], [{ X: 2 }], ["Group"]]); + expectOp(at(model, "2,0"), { Group: { children: [[{ Z: 1 }]] } }); +}); + +test("moveOperation: moving a child out of a group updates the group's targets to drop the departed wire", () => { + // The parent group's `targets` is a derived render-extent claim: it must reflect the union of its + // remaining children's wires. + const model = build( + circuit(3, [[group("Group", [[gate("H", 0), gate("Z", 1)]])]]), + ); + + // Move H out to top-level on wire 2. + moveOperation(model, "0,0-0,0", "1,0", 0, 2, false, true); + + // Group now only contains Z on wire 1. + expectOp(at(model, "0,0"), { Group: { targets: [1] } }); +}); + +// --------------------------------------------------------------------------- +// Empty-group cleanup. +// --------------------------------------------------------------------------- + +test("moveOperation: moving the last child out deletes the empty group", () => { + const model = build(circuit(3, [[group("Group", [[gate("H", 0)]])]])); + + // move H out to a new top-level column on q1 + moveOperation(model, "0,0-0,0", "0,1", 0, 1, false, true); + + expectGrid(model, [[{ H: 1 }]]); +}); + +test("moveOperation: empty-group cleanup cascades through nested groups", () => { + // Inner is Outer's only child, so emptying Inner prunes BOTH groups. + const model = build( + circuit(2, [[group("Outer", [[group("Inner", [[gate("H", 0)]])]])]]), + ); + + // move the deepest leaf out to a new top-level column on q1 + moveOperation(model, "0,0-0,0-0,0", "0,1", 0, 1, false, true); + + expectGrid(model, [[{ H: 1 }]]); +}); + +test("moveOperation: cleanup STOPS at the first non-empty ancestor", () => { + // Y keeps Outer alive after Inner is pruned, so cleanup must not over-delete: only the emptied + // Inner disappears. + const model = build( + circuit(2, [ + [group("Outer", [[group("Inner", [[gate("H", 0)]]), gate("Y", 0)]])], + ]), + ); + + // move H out; insertNewColumn shifts Outer to col 1 + moveOperation(model, "0,0-0,0-0,0", "0,1", 0, 1, false, true); + + expectOp(at(model, "1,0"), { Outer: { children: [[{ Y: 0 }]] } }); +}); + +// --------------------------------------------------------------------------- +// Trailing inner-column dropzone of an expanded group. +// --------------------------------------------------------------------------- + +test("moveOperation: moving an external gate to a group's trailing inner-column slot pulls it into the group", () => { + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0)]])], [gate("Y", 0)]]), + ); + + // move Y into Foo's trailing inner-column slot "0,0-1,0" + const moved = moveOperation(model, "1,0", "0,0-1,0", 0, 0, false, false); + assert.ok(moved, "move should return the moved op"); + + expectGrid(model, [["Foo"]]); + expectOp(at(model, "0,0"), { + Foo: { children: [[{ H: 0 }], [{ Y: 0 }]] }, + }); +}); + +test("moveOperation: moving an internal gate to its group's trailing inner-column slot keeps it inside the group", () => { + // The exact post-move column count is an implementation detail; what matters is the flat gate + // sequence ends up [X, H]. + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0)], [gate("X", 1)]])]]), + ); + + // move H to Foo's trailing inner slot "0,0-2,0" + const moved = moveOperation(model, "0,0-0,0", "0,0-2,0", 0, 0, false, false); + assert.ok(moved, "move should return the moved op"); + + expectGrid(model, [["Foo"]]); + + const fooOp = at(model, "0,0"); + /** @type {string[]} */ + const innerGates = []; + for (const col of fooOp.children) { + for (const op of col.components) { + innerGates.push(op.gate); + } + } + assert.deepEqual( + innerGates, + ["X", "H"], + "H must land after X in the inner grid; no duplicate H, no stray", + ); +}); + +test("moveOperation: promoting a gate into its grandparent group lands it beside the parent group", () => { + // Outer ▷ Inner ▷ [H | Z]. Dropping H on Outer's trailing inner slot + // "0,0-1,0" pulls H up one level into Outer, as a sibling of Inner. + // Inner keeps Z, so it survives the promotion (no empty-group prune). + const model = build( + circuit(3, [ + [group("Outer", [[group("Inner", [[gate("H", 0)], [gate("Z", 0)]])]])], + ]), + ); + + const moved = moveOperation( + model, + "0,0-0,0-0,0", + "0,0-1,0", + 0, + 0, + false, + false, + ); + assert.ok(moved, "promotion into the grandparent group must succeed"); + + // Outer now holds [Inner(Z)] then [H]; Inner survives with just Z. + expectOp(at(model, "0,0"), { + Outer: { + children: [[{ Inner: { children: [[{ Z: 0 }]] } }], [{ H: 0 }]], + }, + }); +}); + +test("moveOperation: moving a gate into a sibling group relocates it across scopes", () => { + // A ▷ [H] and B ▷ [X] are sibling top-level groups. Dropping H on B's + // trailing inner slot "0,1-1,0" moves it out of A and into B, rewired + // to B's wire (q2). A is emptied and pruned, so B collapses to "0,0". + const model = build( + circuit(4, [[group("A", [[gate("H", 0)]]), group("B", [[gate("X", 2)]])]]), + ); + + const moved = moveOperation(model, "0,0-0,0", "0,1-1,0", 0, 2, false, false); + assert.ok(moved, "move into the sibling group must succeed"); + + // A is gone; B holds [X] then the relocated [H] on wire 2. + expectGrid(model, [["B"]]); + expectOp(at(model, "0,0"), { + B: { children: [[{ X: 2 }], [{ H: 2 }]] }, + }); +}); + +// --------------------------------------------------------------- +// Multi-target gate + quantum-control drag. +// +// Control-leg drags always take the single-leg path (`_moveAsUnit` returns false when a control is +// moving), so a multi-target gate with a quantum control exercises the same mechanics a group would +// — but it's a shape the editor can actually author. Groups support classical controls only, +// covered by the anchoring tests above. +// --------------------------------------------------------------- + +test("moveOperation: vertical control drag on a multi-target gate rewires only the control, leaving the body untouched", () => { + const model = build(circuit(4, [[gate("Foo", [1, 2], { ctrls: [0] })]])); + + // drag the control q0 → q3 (vertical: targets stay put) + const moved = moveOperation(model, "0,0", "0,0", 0, 3, true, false); + assert.ok(moved); + + expectOp(at(model, "0,0"), { + Foo: { + targets: [1, 2], // body wires unchanged + ctrls: [3], // control rewired + }, + }); +}); + +test("moveOperation: dropping a multi-target gate's control onto a body wire swaps the control with that wire", () => { + const model = build(circuit(3, [[gate("Foo", [1, 2], { ctrls: [0] })]])); + + // drop the control on q2 (a target wire) → control and target q2 swap + const moved = moveOperation(model, "0,0", "0,0", 0, 2, true, false); + assert.ok(moved); + + expectOp(at(model, "0,0"), { + Foo: { + targets: [0, 1], // target q2 moved to the old control wire q0 + ctrls: [2], // control moved to q2 + }, + }); +}); + +test("moveOperation: dropping a multi-target gate's control onto a wire already occupied by another control is a no-op", () => { + // Like-register guard: dragging a control onto an existing control. + const model = build(circuit(5, [[gate("Foo", [3, 4], { ctrls: [1, 2] })]])); + + // drag the control q1 → q2 (already a control) → no-op + const moved = moveOperation(model, "0,0", "0,0", 1, 2, true, false); + assert.ok(moved); + + expectOp(at(model, "0,0"), { Foo: { targets: [3, 4], ctrls: [1, 2] } }); +}); + +test("moveOperation: horizontal control drag on a multi-target gate moves the whole op to the new column", () => { + // Horizontal drag (targetWire === sourceWire, new column) is the regular column-move flow: the + // whole op relocates. Sibling G@5 shares column 0 with Foo and stays put; Foo moves out to column + // 1. + const model = build( + circuit(6, [[gate("Foo", [1, 2], { ctrls: [0] }), gate("G", 5)]]), + ); + + // drag the control to column 1 (same wire) → whole op relocates + const moved = moveOperation(model, "0,0", "1,0", 0, 0, true, false); + assert.ok(moved); + + // G stays in column 0; Foo (topology intact) now occupies column 1. + expectGrid(model, [[{ G: 5 }], [{ Foo: { targets: [1, 2], ctrls: [0] } }]]); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/measurementCascade.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/measurementCascade.test.mjs new file mode 100644 index 00000000000..201bccf7c76 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/measurementCascade.test.mjs @@ -0,0 +1,407 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Measurement move / delete with downstream consumers. +// +// `collectMeasurementConsumers` walks the grid and finds every op whose classical-ref `(qubit, +// result)` matches one of the M's `results` entries. Both the prompt layer and the cascade actions +// consume its output. +// +// `removeMeasurementWithDependents` deletes a measurement together with its downstream consumers, +// then keeps the surviving circuit consistent. Test surface: predicate-match correctness, +// M-location re-derivation after the cascade collapses columns, and the renumber-then-remap pass +// for surviving Ms whose result indices shift. +// +// `moveMeasurementWithDependents` is the bulk of the new logic: pre-/post-move (qubit, result) +// snapshotting, wire-level renumbering remap propagation, survivor / invalidated partition by +// object identity, and post-mutation overlap resolution for changed visual spans. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + collectMeasurementConsumers, + moveMeasurementWithDependents, + removeMeasurementWithDependents, +} from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + build, + circuit, + expectGrid, + expectOp, + gate, + group, + meas, +} from "../_helpers.mjs"; + +// Local shorthands over the shared helpers: these suites use the "Measure" gate name (asserted in +// places) and classically-controlled X consumers. +const _mGate = (/** @type {number} */ q, /** @type {number} */ r) => + meas(q, { gate: "Measure", result: r }); + +const _ccx = ( + /** @type {number} */ targetQubit, + /** @type {number} */ ctrlQubit, + /** @type {number} */ ctrlResult, +) => gate("X", targetQubit, { ctrls: [{ q: ctrlQubit, r: ctrlResult }] }); + +// --------------------------------------------------------------------------- +// collectMeasurementConsumers +// --------------------------------------------------------------------------- + +test("collectMeasurementConsumers: empty when no consumer references the M", () => { + const model = build(circuit(2, [[_mGate(0, 0)], [gate("H", 1)]])); + assert.equal( + collectMeasurementConsumers(model.componentGrid, "0,0").length, + 0, + ); +}); + +test("collectMeasurementConsumers: finds a top-level classically-controlled consumer", () => { + const model = build(circuit(2, [[_mGate(0, 0)], [_ccx(1, 0, 0)]])); + const consumers = collectMeasurementConsumers(model.componentGrid, "0,0"); + assert.equal(consumers.length, 1); + assert.equal(consumers[0].location, "1,0"); +}); + +test("collectMeasurementConsumers: walks into nested children", () => { + // Consumer is buried two levels deep inside non-classically-controlled groups; the walker still + // finds it. The wrappers carry no classical ref in their `.controls`. + const model = build( + circuit(2, [ + [_mGate(0, 0)], + [group("Outer", [[group("Inner", [[_ccx(1, 0, 0)]])]])], + ]), + ); + const consumers = collectMeasurementConsumers(model.componentGrid, "0,0"); + // Only the leaf X is a logical consumer. + assert.equal( + consumers.length, + 1, + `expected only the leaf X to be flagged; got ${JSON.stringify( + consumers.map((c) => c.op.gate), + )}`, + ); + assert.equal(consumers[0].op.gate, "X"); +}); + +test("collectMeasurementConsumers: ancestor groups with propagated .targets are NOT flagged", () => { + // Simulates the post-`_deepRefreshDerivedTargets` state where the outer group's `.targets` cache + // has propagated the classical ref upward. Inspecting `.targets` (instead of just leaf consumers) + // would flag the Outer group and cascade-delete its unrelated sibling Y. The consumer scan must + // look at leaves only. + const outer = group("Outer", [ + [_ccx(1, 0, 0)], // the actual consumer + [gate("Y", 2)], // unrelated sibling — purely quantum, MUST survive + ]); + // PROPAGATED cache: rewrite the plain q0 target into the classical ref the inner X would push up + // through `_deepRefreshDerivedTargets`. + outer.targets = outer.targets.map((/** @type {any} */ t) => + t.qubit === 0 ? { qubit: 0, result: 0 } : t, + ); + const model = build(circuit(3, [[_mGate(0, 0)], [outer]])); + const consumers = collectMeasurementConsumers(model.componentGrid, "0,0"); + assert.equal( + consumers.length, + 1, + `Outer group with propagated .targets must NOT be flagged; ` + + `expected only the leaf X. Got ${JSON.stringify( + consumers.map((c) => c.op.gate), + )}`, + ); + assert.equal(consumers[0].op.gate, "X"); + + // End-to-end: removing the M with this consumer set must leave the Y intact inside the + // (now-shrunken) Outer group. + removeMeasurementWithDependents( + model, + "0,0", + consumers.map((c) => c.op), + ); + // Outer survives with only its unrelated Y child; the consumer X is gone. + expectOp(at(model, "0,0"), { Outer: { children: [["Y"]] } }); +}); + +test("collectMeasurementConsumers: classical-ref must MATCH (qubit, result); other Ms don't trigger", () => { + // Two Ms on different wires; the consumer references only M_1. + const model = build( + circuit(3, [[_mGate(0, 0)], [_mGate(1, 0)], [_ccx(2, 1, 0)]]), + ); + assert.equal( + collectMeasurementConsumers(model.componentGrid, "0,0").length, + 0, + "M_0 has no consumer (the consumer references M_1's (q1, r0))", + ); + assert.equal( + collectMeasurementConsumers(model.componentGrid, "1,0").length, + 1, + "M_1's consumer is the classically-controlled X", + ); +}); + +// --------------------------------------------------------------------------- +// removeMeasurementWithDependents +// --------------------------------------------------------------------------- + +test("removeMeasurementWithDependents: deletes M and all classical-ref consumers", () => { + const model = build( + circuit(3, [[_mGate(0, 0)], [_ccx(1, 0, 0)], [_ccx(2, 0, 0)]]), + ); + const consumers = collectMeasurementConsumers(model.componentGrid, "0,0"); + assert.equal(consumers.length, 2); + removeMeasurementWithDependents( + model, + "0,0", + consumers.map((c) => c.op), + ); + // Every column should be gone. + expectGrid(model, []); +}); + +test("removeMeasurementWithDependents: M's location is re-derived after the cascade collapses columns", () => { + // Consumer alone in col 0 collapses col 0; M shifts from col 1 down to col 0. The action layer + // re-derives M by ref, not by the now-stale "1,0". + const model = build(circuit(2, [[_ccx(1, 0, 0)], [_mGate(0, 0)]])); + const consumers = collectMeasurementConsumers(model.componentGrid, "1,0"); + assert.equal(consumers.length, 1); + removeMeasurementWithDependents( + model, + "1,0", + consumers.map((c) => c.op), + ); + expectGrid(model, []); +}); + +test("removeMeasurementWithDependents: surviving Ms' result-index renumbering propagates to their consumers", () => { + // M_a → result 0, M_b → result 1, both on wire 0. A consumer references (0, 1) — M_b. Deleting + // M_a renumbers M_b from result 1 → 0; the consumer's ref must remap to (0, 0) or the next render + // throws "Classical register ID 1 invalid". + const model = build( + circuit(2, [[_mGate(0, 0)], [_mGate(0, 1)], [_ccx(1, 0, 1)]]), + ); + // M_a has no consumers (the ccx references M_b, not M_a). + const consumers = collectMeasurementConsumers(model.componentGrid, "0,0"); + assert.equal(consumers.length, 0, "M_a has no direct consumers"); + + removeMeasurementWithDependents(model, "0,0", []); + + // The surviving ccx's classical-ref must remap (0,1) → (0,0) to track M_b's new result index. + expectOp(at(model, "1,0"), { X: { ctrls: [{ q: 0, r: 0 }] } }); + // And the model's per-wire numResults must reflect the single surviving M. + assert.equal( + model.qubits[0].numResults, + 1, + "wire 0 must report exactly 1 classical register after deletion", + ); +}); + +// --------------------------------------------------------------------------- +// moveMeasurementWithDependents +// --------------------------------------------------------------------------- + +test("moveMeasurementWithDependents: surviving consumer's classical-ref tracks the M's new wire", () => { + // M on wire 0, consumer in a later column on wire 2 with classical-ref (0, 0). M moves down to + // wire 1; the consumer's ref must become (1, 0). + const model = build(circuit(3, [[_mGate(0, 0)], [_ccx(2, 0, 0)]])); + // Target column 0 is strictly before consumer column 1 → survives. + const moved = moveMeasurementWithDependents( + model, + "0,0", + "0,0", + 0, + 1, + /* insertNewColumn */ false, + [], + ); + assert.ok(moved); + + // Consumer's classical-ref must track M's new wire: (0,0) → (1,0). + expectOp(at(model, "1,0"), { X: { ctrls: [{ q: 1, r: 0 }] } }); +}); + +test("moveMeasurementWithDependents: invalidated consumer is cascade-deleted", () => { + // M@col 0, ccx consumer@col 1, unrelated H@col 2. Moving M to "2,0" lands it in a column after + // the ccx, so the consumer is now in an earlier column — invalidated — and gets deleted. + const model = build( + circuit(3, [[_mGate(0, 0)], [_ccx(1, 0, 0)], [gate("H", 2)]]), + ); + const consumers = collectMeasurementConsumers(model.componentGrid, "0,0"); + // Hand the single consumer in as invalidated directly. + const moved = moveMeasurementWithDependents( + model, + "0,0", + "2,0", + 0, + 0, + /* insertNewColumn */ false, + consumers.map((c) => c.op), + ); + assert.ok(moved); + // The ccx should be gone; only M and H remain. + const remainingGates = model.componentGrid + .flatMap((/** @type {any} */ col) => col.components) + .map((/** @type {any} */ op) => op.gate) + .sort(); + assert.deepEqual( + remainingGates, + ["H", "Measure"], + `ccx must be cascade-deleted; got ${JSON.stringify(remainingGates)}`, + ); +}); + +test("moveMeasurementWithDependents: consumer of an UNMOVED M whose result index gets renumbered is also remapped", () => { + // Two Ms on wire 0 (results 0 and 1). A consumer of the SECOND M references (0, 1). Moving the + // FIRST M to wire 1 renumbers the remaining wire-0 M down to result 0, so the consumer must remap + // (0, 1) → (0, 0). + const model = build( + circuit(3, [[_mGate(0, 0)], [_mGate(0, 1)], [_ccx(2, 0, 1)]]), + ); + + // Move M_first from wire 0 to wire 1. invalidatedConsumers=[] — the consumer is downstream of + // M_second (unmoved). + const moved = moveMeasurementWithDependents( + model, + "0,0", + "0,0", + 0, + 1, + /* insertNewColumn */ false, + [], + ); + assert.ok(moved); + + // Consumer of M_second must remap (0,1) → (0,0) after M_first's move triggered the wire-0 + // renumber. + expectOp(at(model, "2,0"), { X: { ctrls: [{ q: 0, r: 0 }] } }); +}); + +test("moveMeasurementWithDependents: M with no consumers behaves like a regular move", () => { + // Sanity check: the cascade overhead is a no-op when there's no consumer to remap or invalidate. + const model = build(circuit(2, [[_mGate(0, 0)]])); + const moved = moveMeasurementWithDependents( + model, + "0,0", + "0,0", + 0, + 1, + /* insertNewColumn */ false, + [], + ); + assert.ok(moved); + // M moved from wire 0 to wire 1; no consumer to remap. + expectOp(at(model, "0,0"), { Measure: { qubits: [1] } }); +}); + +test("moveMeasurementWithDependents: moving an M onto a wire that already has multiple Ms-with-consumers does not double-remap M results", () => { + // `_applyClassicalRefRemap` must skip producer registers (`.results` on measurements) and only + // remap consumer classical refs. Otherwise, after `_updateMeasurementLines` authoritatively + // renumbers result indices on the affected wire, walking those producer values back through the + // consumer remap can chain-react: each M's new result index happens to match another M's pre-move + // key, so `.results` gets remapped a second time — collapsing into duplicate result indices and + // orphaning consumers whose target M had its `.results` clobbered. + // + // Setup: three Ms with consumers spread across two wires. Wire 0 already has M_a (r=0) and M_b + // (r=1), each with a downstream classically-controlled gate. Wire 1 has M_c (r=0) with its own + // consumer. We move M_c onto wire 0 in front of M_a, which forces _updateMeasurementLines to + // renumber wire 0 as: M_c=0, M_a=1, M_b=2. + const model = build( + circuit(3, [ + [_mGate(0, 0)], // col 0: M_a (wire 0, r=0) + [_mGate(0, 1)], // col 1: M_b (wire 0, r=1) + [_mGate(1, 0)], // col 2: M_c (wire 1, r=0) + [_ccx(2, 0, 0)], // col 3: C_a → "0:0" + [_ccx(2, 0, 1)], // col 4: C_b → "0:1" + [_ccx(2, 1, 0)], // col 5: C_c → "1:0" + ]), + ); + + // Move M_c (col 2, idx 0) to wire 0, inserting a fresh column at position 0. After the move, wire + // 0's doc order is M_c, M_a, M_b → _updateMeasurementLines assigns r=0, 1, 2 respectively. The + // keyRemap must rewrite every consumer: C_a "0:0" → "0:1" (M_a moved down) C_b "0:1" → "0:2" (M_b + // moved down) C_c "1:0" → "0:0" (M_c switched wires) + const moved = moveMeasurementWithDependents( + model, + "2,0", + "0,0", + 1, + 0, + /* insertNewColumn */ true, + [], + ); + assert.ok(moved); + + // Collect every M and every classically-controlled consumer in the post-move grid. + /** @type {any[]} */ + const ms = []; + /** @type {any[]} */ + const consumers = []; + for (const col of model.componentGrid) { + for (const op of col.components) { + if (op.kind === "measurement") { + ms.push(op); + } else if ( + op.kind === "unitary" && + op.controls && + op.controls.some((/** @type {any} */ c) => c.result !== undefined) + ) { + consumers.push(op); + } + } + } + assert.equal(ms.length, 3, "all three Ms must still be present"); + assert.equal( + consumers.length, + 3, + "all three consumers must still be present", + ); + + // INVARIANT 1: every M's `.results` entry has a unique (qubit, result) key. The bug previously + // caused two Ms to share the same `.results` value. + /** @type {Set} */ + const resultKeys = new Set(); + for (const m of ms) { + for (const r of m.results) { + const key = `${r.qubit}:${r.result}`; + assert.ok( + !resultKeys.has(key), + `duplicate M.results key ${key} — at least two Ms claim the same classical register`, + ); + resultKeys.add(key); + } + } + + // INVARIANT 2: every consumer's classical ref points at a key that some M actually produces. The + // bug previously left consumers pointing at result indices no M owned (orphaned classical-control + // indicator). + for (const consumer of consumers) { + const classicalRef = consumer.controls.find( + (/** @type {any} */ c) => c.result !== undefined, + ); + const key = `${classicalRef.qubit}:${classicalRef.result}`; + assert.ok( + resultKeys.has(key), + `consumer references ${key}, but no M produces it (orphaned indicator)`, + ); + } + + // INVARIANT 3: on wire 0, result indices are assigned in doc order starting at 0 (the contract of + // _updateMeasurementLines). Verifies the renumbering itself wasn't corrupted by the remap walk. + /** @type {number[]} */ + const wire0ResultsInDocOrder = []; + for (const col of model.componentGrid) { + for (const op of col.components) { + if (op.kind === "measurement" && op.qubits[0].qubit === 0) { + wire0ResultsInDocOrder.push( + /** @type {number} */ (op.results[0].result), + ); + } + } + } + assert.deepEqual( + wire0ResultsInDocOrder, + [0, 1, 2], + "wire 0's three Ms must have result indices 0, 1, 2 in doc order", + ); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/moveStamp.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/moveStamp.test.mjs new file mode 100644 index 00000000000..df157645c56 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/moveStamp.test.mjs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// View-state stamp contract (`sqore-prev-location`). +// +// `moveOperation` deep-clones the source op, so the returned op has a different identity than the +// one in `Sqore.lastLocationMap`. A naive identity-keyed rebase in `Sqore.rebaseViewState` would +// drop the ViewState entry for the moved op, causing user-set expand/collapse choices to be lost. +// The most visible symptom is on classically-controlled groups: when no ViewState entry exists, the +// renderer's `hasClassicalControls && hasChildren` default re-expands groups the user had +// explicitly collapsed. +// +// `moveOperation` stamps `dataAttributes["sqore-prev-location"]` on the new op with the pre-move +// location. Sqore consumes the stamp as a fallback during rebase. These tests pin the stamp +// contract at the action layer. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { moveOperation } from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { at, build, circuit, gate, group } from "../_helpers.mjs"; + +test("moveOperation: returned op carries sqore-prev-location stamp with the source location", () => { + const model = build(circuit(2, [[gate("H", 0)], [gate("X", 1)]])); + + const moved = moveOperation(model, "0,0", "1,0", 0, 1, false, false); + assert.ok(moved); + assert.equal( + /** @type {any} */ (moved).dataAttributes?.["sqore-prev-location"], + "0,0", + "stamp must hold the PRE-move source location so Sqore can recover the ViewState entry", + ); +}); + +test("moveOperation: stamp survives the deep-clone roundtrip even when source had no prior dataAttributes", () => { + const model = build(circuit(2, [[gate("H", 0)], [gate("X", 1)]])); + // Precondition: no dataAttributes on the source op going in. + assert.equal(/** @type {any} */ (at(model, "0,0")).dataAttributes, undefined); + + const moved = moveOperation(model, "0,0", "1,0", 0, 1, false, false); + assert.ok(moved); + assert.equal( + /** @type {any} */ (moved).dataAttributes?.["sqore-prev-location"], + "0,0", + ); +}); + +test("moveOperation: stamp persists for a control-leg move on a group", () => { + // Control-leg move on a group takes a distinct `_moveY` branch but must still stamp prev-location + // for the ViewState transfer. + const model = build( + circuit(4, [ + [group("Foo", [[gate("H", 1), gate("X", 2)]], { ctrls: [0] })], + ]), + ); + const moved = moveOperation(model, "0,0", "0,0", 0, 3, true, false); + assert.ok(moved); + assert.equal( + /** @type {any} */ (moved).dataAttributes?.["sqore-prev-location"], + "0,0", + "control-leg move on a group must still stamp the prev-location for ViewState transfer", + ); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/producerOrdering.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/producerOrdering.test.mjs new file mode 100644 index 00000000000..ac16cdcd14c --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/producerOrdering.test.mjs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// `Location` + `collectExternalProducerLocations` + `moveOperation` producer-ordering guards: an op +// carrying a classical-ref must land strictly after the M that produces the result. Covers the +// `Location.before` / `Location.inEarlierColumnThan` helpers that back the dropzone filter and the +// action-layer safety net that refuses drops violating the rule. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + collectExternalProducerLocations, + moveOperation, +} from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { Location } from "../../../dist/ux/circuit-vis/data/location.js"; +import { build, circuit, gate, group, meas, qubits } from "../_helpers.mjs"; + +// classically-conditional group: H@0 gated on classical reg 0:0. +const ifGroup = () => + group("if", [[gate("H", 0)]], { ctrls: [{ q: 0, r: 0 }], conditional: true }); + +/** Serialized snapshot of the mutable model state, for immutability checks. */ +const snapshot = (/** @type {any} */ model) => + JSON.stringify({ qubits: model.qubits, componentGrid: model.componentGrid }); + +// --------------------------------------------------------------------------- +// Location helpers +// --------------------------------------------------------------------------- + +test("Location.before: document-order comparison", () => { + // Backs the dropzone-filter and moveOperation safety-net. Each row: [a, b, a.before(b), why]. + /** @type {[string, string, boolean, string][]} */ + const cases = [ + ["0,0", "0,1", true, "same col, smaller op first"], + ["0,1", "0,0", false, "same col, larger op last"], + ["0,0", "1,0", true, "smaller col first"], + ["1,0", "0,0", false, "larger col last"], + ["0,1", "0,1", false, "equal is not strictly before"], + ["0,0", "0,0-0,0", true, "ancestor renders before descendant"], + ["0,0-0,0", "0,0", false, "descendant does not come before ancestor"], + ["0,0-5,5", "0,1", true, "deeply nested in col 0 comes before col 1"], + ["0,1", "0,0-5,5", false, "col 1 not before anything inside col 0"], + ]; + for (const [a, b, want, why] of cases) { + assert.equal(Location.parse(a).before(Location.parse(b)), want, why); + } +}); + +test("Location.inEarlierColumnThan: column-strict, ancestor-aware", () => { + // Backs the dropzone-filter and moveOperation safety-net for the "producer must precede consumer" + // rule. Unlike document-order `before`: two ops in the same column are simultaneous, and ancestor + // groups project their column down onto everything they contain. Each row: [a, b, + // a.inEarlierColumnThan(b), why]. + /** @type {[string, string, boolean, string][]} */ + const cases = [ + ["0,0", "1,0", true, "earlier top-level column"], + ["1,0", "0,0", false, "later top-level column"], + ["0,0", "0,1", false, "same col, different op is simultaneous"], + ["0,1", "0,0", false, "same col, different op is simultaneous (reverse)"], + ["0,0", "0,0", false, "identical is not strictly earlier"], + ["0,0", "0,0-1,0", false, "ancestor shares outer column with descendant"], + ["0,0-1,0", "0,0", false, "descendant shares outer column with ancestor"], + ["0,0-1,0", "0,0-2,0", true, "same outer group, later inner column"], + ["0,0-1,0", "0,0-1,1", false, "same inner column is simultaneous"], + ]; + for (const [a, b, want, why] of cases) { + assert.equal( + Location.parse(a).inEarlierColumnThan(Location.parse(b)), + want, + why, + ); + } +}); + +// --------------------------------------------------------------------------- +// collectExternalProducerLocations: only producers OUTSIDE the moved subtree count as constraints +// on where the consumer can land. +// --------------------------------------------------------------------------- + +test("collectExternalProducerLocations: classical control with external M", () => { + // M@0 produces (0,0); the conditional group at col 1 consumes it. + const model = build(circuit(qubits(2, { 0: 1 }), [[meas(0)], [ifGroup()]])); + const producers = collectExternalProducerLocations( + model.componentGrid, + "1,0", + ); + assert.deepEqual( + producers, + ["0,0"], + "external producer M at top-level col 0 must be reported", + ); +}); + +test("collectExternalProducerLocations: internal producer M is excluded", () => { + // Producer M lives INSIDE the moved subtree → it travels with the consumer, so it imposes no + // drop-target constraint. + const model = build( + circuit(qubits(2, { 0: 1 }), [ + [ + group("Group", [ + [meas(0)], + [gate("X", 0, { ctrls: [{ q: 0, r: 0 }] })], + ]), + ], + ]), + ); + const producers = collectExternalProducerLocations( + model.componentGrid, + "0,0", + ); + assert.deepEqual( + producers, + [], + "producer is internal to the moved subtree → not reported", + ); +}); + +// --------------------------------------------------------------------------- +// moveOperation: refuses drops that would violate producer-first ordering. +// --------------------------------------------------------------------------- + +test("moveOperation: refuses dropping a conditional before its producer M", () => { + // Dropping the conditional before its producing M would leave its classical ref pointing at a + // not-yet-produced register. + const model = build(circuit(qubits(2, { 0: 1 }), [[meas(0)], [ifGroup()]])); + const before = snapshot(model); + + // drop the conditional at col 0 (insertNewColumn) → before M → refuse + const result = moveOperation(model, "1,0", "0,0", 0, 0, false, true); + + assert.equal(result, null, "move must be refused"); + assert.equal(snapshot(model), before, "refusal must not mutate the model"); +}); + +test("moveOperation: allows dropping a conditional AFTER its producer M", () => { + // Boundary check: the refusal mustn't over-trigger for a drop that lands strictly after the + // producer. Y@1 is filler. + const model = build( + circuit(qubits(2, { 0: 1 }), [[meas(0)], [gate("Y", 1)], [ifGroup()]]), + ); + + // drop the conditional at new col 1 (after M at col 0) → allowed + const result = moveOperation(model, "2,0", "1,0", 0, 0, false, true); + + assert.ok(result, "move must succeed: consumer remains after producer"); +}); + +test("moveOperation: allows moving a group whose classical producer is INTERNAL", () => { + // Producer M lives inside the moved subtree → no external constraint, so the move can go + // anywhere. Y@1 is filler. + const model = build( + circuit(qubits(2, { 0: 1 }), [ + [gate("Y", 1)], + [ + group("Group", [ + [meas(0)], + [gate("X", 0, { ctrls: [{ q: 0, r: 0 }] })], + ]), + ], + ]), + ); + + // drop the group at col 0 (insertNewColumn) → allowed (internal M) + const result = moveOperation(model, "1,0", "0,0", 0, 0, false, true); + + assert.ok( + result, + "move must succeed: internal producer travels with the consumer", + ); +}); + +test("moveOperation: refuses promoting a conditional to a sibling of the producer's outer group", () => { + // Producer M and consumer both start inside Outer. Promoting the consumer out to a top-level + // sibling at Outer's own column lands it in the same time-step as the producer → refuse. + const model = build( + circuit(qubits(2, { 0: 1 }), [[group("Outer", [[meas(0)], [ifGroup()]])]]), + ); + const before = snapshot(model); + + // promote consumer "0,0-1,0" to top-level col 0 (Outer's col) → refuse + const result = moveOperation(model, "0,0-1,0", "0,0", 0, 0, false, true); + assert.equal(result, null, "must refuse: same top-level column as producer"); + assert.equal(snapshot(model), before, "refusal must not mutate the model"); +}); + +test("moveOperation: allows promoting a conditional to a strictly later top-level column", () => { + // Boundary check: promotion to col 1 (strictly after Outer at col 0) must succeed. Y@1 is filler. + const model = build( + circuit(qubits(2, { 0: 1 }), [ + [group("Outer", [[meas(0)], [ifGroup()]])], + [gate("Y", 1)], + ]), + ); + + // promote consumer "0,0-1,0" to top-level col 1 (after Outer) → allowed + const result = moveOperation(model, "0,0-1,0", "1,0", 0, 0, false, true); + assert.ok(result, "move must succeed: strictly later outer column"); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuit-actions/qubitOps.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/qubitOps.test.mjs new file mode 100644 index 00000000000..a6a42add7f3 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/qubitOps.test.mjs @@ -0,0 +1,354 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// `moveQubit` / `removeQubit` and their interaction with classical-control consumers of +// measurements. Exercises the wire-permutation contract: every register reference (top-level, +// nested, cached `.targets`, and classical-ref consumers) gets rewritten by the same 1-to-1 +// function, with no result-index renumbering. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + moveQubit, + removeQubit, + removeQubitWithDependents, +} from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + build, + circuit, + expectGrid, + expectOp, + gate, + group, + meas, +} from "../_helpers.mjs"; + +// Local shorthands over the shared helpers. +const _mGate = (/** @type {number} */ q, /** @type {number} */ r) => + meas(q, { gate: "Measure", result: r }); + +const _ccx = ( + /** @type {number} */ targetQubit, + /** @type {number} */ ctrlQubit, + /** @type {number} */ ctrlResult, +) => gate("X", targetQubit, { ctrls: [{ q: ctrlQubit, r: ctrlResult }] }); + +// --------------------------------------------------------------------------- +// moveQubit / removeQubit (flat-grid base cases) +// --------------------------------------------------------------------------- + +test("moveQubit swaps register references and reorders ops within a column", () => { + const model = build(circuit(2, [[gate("X", 0), gate("H", 1)]])); + + moveQubit( + model, + /* sourceWire */ 0, + /* targetWire */ 1, + /* isBetween */ false, + ); + + // Column re-sorts so H (lowest reg) comes first. + const ops = model.componentGrid[0].components; + expectOp(ops[0], { H: 0 }); + expectOp(ops[1], { X: 1 }); + // Qubit ids are renumbered to match positions. + assert.equal(model.qubits[0].id, 0); + assert.equal(model.qubits[1].id, 1); +}); + +test("removeQubit shifts higher wire indices down by one", () => { + const model = build(circuit(3, [[gate("X", 2)]])); + assert.deepEqual(model.qubitUseCounts, [0, 0, 1]); + + removeQubit(model, 1); + + assert.equal(model.qubits.length, 2); + // Wire 2's reference shifts down to wire 1. + expectOp(at(model, "0,0"), { X: 1 }); + assert.deepEqual(model.qubitUseCounts, [0, 1]); +}); + +test("moveQubit with isBetween=true inserts before the target wire", () => { + const model = build( + circuit(4, [[gate("W", 0), gate("X", 1), gate("Y", 2), gate("Z", 3)]]), + ); + + // Move wire 0 to just before wire 3 (isBetween=true). + moveQubit(model, 0, 3, true); + + // New wire order [X, Y, W, Z]; ops carry their new target indices. + const ops = model.componentGrid[0].components; + expectOp(ops[0], { X: 0 }); + expectOp(ops[1], { Y: 1 }); + expectOp(ops[2], { W: 2 }); + expectOp(ops[3], { Z: 3 }); +}); + +test("removeQubitWithDependents strips ops on the wire and drops it", () => { + // The public cascade: remove every op touching the doomed wire, then rewire the higher indices + // down. + const model = build( + circuit(3, [[gate("X", 0)], [gate("H", 1)], [gate("Z", 2)]]), + ); + assert.deepEqual(model.qubitUseCounts, [1, 1, 1]); + + removeQubitWithDependents(model, 1); + + assert.equal(model.qubits.length, 2); + expectGrid(model, [[{ X: 0 }], [{ Z: 1 }]]); +}); + +test("moveQubit: moving an interior empty wire to the bottom prunes it as a trailing unused wire", () => { + // Wire 1 is empty; wires 0 and 2 carry ops. Swapping the empty wire down to the bottom leaves it + // as the highest, unused wire, which `removeTrailingUnusedQubits` drops immediately. + const model = build(circuit(3, [[gate("X", 0), gate("Z", 2)]])); + + moveQubit(model, 1, 2, false); + + assert.equal(model.qubits.length, 2); + // Z shifts up from wire 2 to wire 1; the emptied trailing wire is gone. + expectGrid(model, [[{ X: 0 }, { Z: 1 }]]); +}); + +// --------------------------------------------------------------------------- +// removeQubit / moveQubit recurse into nested groups +// --------------------------------------------------------------------------- + +test("removeQubit: shifts wire indices on ops nested inside groups", () => { + // Raw JSON: Foo's targets [1,2] aren't derivable from a lone H@2. + const model = build( + circuit(3, [ + [ + { + kind: "unitary", + gate: "Foo", + targets: [{ qubit: 1 }, { qubit: 2 }], + children: [{ components: [gate("H", 2)] }], + }, + ], + ]), + ); + + removeQubit(model, 0); + + // Removing wire 0 shifts every >0 wire down: nested H 2 → 1, Foo's cached targets [1,2] → [0,1]. + expectOp(at(model, "0,0"), { + Foo: { targets: [0, 1], children: [[{ H: 1 }]] }, + }); +}); + +test("moveQubit: swaps wire indices on ops nested inside groups", () => { + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + + moveQubit(model, 0, 1, false); + + // Swap propagates into nested ops; column re-sorts so X (now wire 0) precedes H (now wire 1). + const innerOps = at(model, "0,0").children[0].components; + expectOp(innerOps[0], { X: 0 }); + expectOp(innerOps[1], { H: 1 }); +}); + +test("moveQubit: refreshes group `.targets` cache after wire swap", () => { + const model = build(circuit(3, [[group("Foo", [[gate("H", 0)]])]])); + + moveQubit(model, 0, 1, false); + + // Foo's cached `.targets` must be re-derived to [1], not left stale. + expectOp(at(model, "0,0"), { Foo: { targets: [1], children: [[{ H: 1 }]] } }); +}); + +test("moveQubit: resolves nested-group overlaps introduced by widening", () => { + // Swapping wires 0 and 1 keeps the H/X span non-overlapping, so the nested column stays single + // (no split, no corruption). + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + + moveQubit(model, 0, 1, false); + + expectOp(at(model, "0,0"), { Foo: { children: [["H", "X"]] } }); +}); + +test("moveQubit: swap inside a group splits a nested column when a child's control moves over a sibling", () => { + // Swapping wires 1 and 2 widens CX's span to 0-2 (ctrl 1 → 2) and lands H on wire 1, between CX's + // target and control — forcing a collision-split of the nested column. + const model = build( + circuit(3, [ + [group("Foo", [[gate("X", 0, { ctrls: [1] }), gate("H", 2)]])], + ]), + ); + + moveQubit(model, 1, 2, false); + + const fooOp = at(model, "0,0"); + assert.equal( + fooOp.children.length, + 2, + `Foo's nested grid must split into two columns after the wire swap; got ${fooOp.children.length}`, + ); + + // Both children survive with wire refs rewritten by the 1-to-1 permutation. + const flattened = fooOp.children.flatMap( + (/** @type {any} */ col) => col.components, + ); + assert.equal(flattened.length, 2); + + const cx = flattened.find((/** @type {any} */ op) => op.gate === "X"); + const h = flattened.find((/** @type {any} */ op) => op.gate === "H"); + assert.ok(cx, "CX child must survive the split"); + assert.ok(h, "H child must survive the split"); + expectOp(cx, { X: { targets: [0], ctrls: [2] } }); + expectOp(h, { H: 1 }); + + // CX and H must end up in different nested columns. + const cxColIdx = fooOp.children.findIndex((/** @type {any} */ col) => + col.components.some((/** @type {any} */ op) => op.gate === "X"), + ); + const hColIdx = fooOp.children.findIndex((/** @type {any} */ col) => + col.components.some((/** @type {any} */ op) => op.gate === "H"), + ); + assert.notEqual( + cxColIdx, + hColIdx, + "CX and H must be split into separate nested columns", + ); + // Parent still claims the full wire span it covered before. + expectOp(fooOp, { Foo: { targets: [0, 1, 2] } }); +}); + +// --------------------------------------------------------------------------- +// moveQubit + Ms-with-classical-consumers +// +// `moveQubit` rewrites every register reference (consumer classical refs AND measurement +// `.results`) by the same 1-to-1 wire-permutation, without renumbering result indices. Invariant: +// every consumer must still reference a real, unique (qubit, result) key some M produces. +// --------------------------------------------------------------------------- + +test("moveQubit: classical-control consumer follows a moved M's qubit index", () => { + const model = build(circuit(3, [[_mGate(0, 0)], [_ccx(2, 0, 0)]])); + + moveQubit(model, 0, 1, false); + + // M (and its `.results`) and the consumer's classical ref all rewire 0 → 1. + expectOp(at(model, "0,0"), { + Measure: { qubits: [1], results: [{ q: 1, r: 0 }] }, + }); + expectOp(at(model, "1,0"), { X: { ctrls: [{ q: 1, r: 0 }] } }); +}); + +test("moveQubit: swap of two wires that both have Ms with consumers preserves per-wire uniqueness", () => { + // M_a (wire 0) and M_b (wire 1) hold the SAME result index 0. The wire-permutation keeps them on + // distinct wires, so (qubit, result) keys stay unique without any renumbering. + const model = build( + circuit(3, [ + [_mGate(0, 0)], + [_mGate(1, 0)], + [_ccx(2, 0, 0)], // consumes M_a (wire 0, r=0) + [_ccx(2, 1, 0)], // consumes M_b (wire 1, r=0) + ]), + ); + + moveQubit(model, 0, 1, false); + + // M_a 0 → 1, M_b 1 → 0; each consumer follows its M. + expectOp(at(model, "0,0"), { + Measure: { qubits: [1], results: [{ q: 1, r: 0 }] }, + }); + expectOp(at(model, "1,0"), { + Measure: { qubits: [0], results: [{ q: 0, r: 0 }] }, + }); + expectOp(at(model, "2,0"), { X: { ctrls: [{ q: 1, r: 0 }] } }); + expectOp(at(model, "3,0"), { X: { ctrls: [{ q: 0, r: 0 }] } }); +}); + +test("moveQubit: swap of a wire carrying multiple Ms keeps the consumer chain in sync", () => { + // Wire 0 carries M_a (r=0) and M_b (r=1); wire 1 is empty. Swapping moves both Ms 0 → 1 with + // result indices preserved (the destination wire had no Ms to collide with). + const model = build( + circuit(3, [ + [_mGate(0, 0)], + [_mGate(0, 1)], + [_ccx(2, 0, 0)], // consumes M_a + [_ccx(2, 0, 1)], // consumes M_b + ]), + ); + + moveQubit(model, 0, 1, false); + + // Both Ms 0 → 1 (results 0 and 1 intact); each consumer follows. + expectOp(at(model, "0,0"), { + Measure: { qubits: [1], results: [{ q: 1, r: 0 }] }, + }); + expectOp(at(model, "1,0"), { + Measure: { qubits: [1], results: [{ q: 1, r: 1 }] }, + }); + expectOp(at(model, "2,0"), { X: { ctrls: [{ q: 1, r: 0 }] } }); + expectOp(at(model, "3,0"), { X: { ctrls: [{ q: 1, r: 1 }] } }); +}); + +test("moveQubit isBetween: moving a wire past one with Ms-with-consumers remaps every party in lockstep", () => { + // Move wire 0 to between wires 2 and 3 → new order [1, 2, 0, 3], remapping old→new: 0→2, 1→0, + // 2→1, 3→3. + const model = build( + circuit(4, [ + [_mGate(1, 0)], + [_mGate(2, 0)], + [_ccx(3, 1, 0)], // consumes M_a + [_ccx(3, 2, 0)], // consumes M_b + ]), + ); + + moveQubit(model, 0, 3, true); + + // M_a 1 → 0, M_b 2 → 1; consumers (target wire 3) follow. + expectOp(at(model, "0,0"), { + Measure: { qubits: [0], results: [{ q: 0, r: 0 }] }, + }); + expectOp(at(model, "1,0"), { + Measure: { qubits: [1], results: [{ q: 1, r: 0 }] }, + }); + expectOp(at(model, "2,0"), { X: { targets: [3], ctrls: [{ q: 0, r: 0 }] } }); + expectOp(at(model, "3,0"), { X: { targets: [3], ctrls: [{ q: 1, r: 0 }] } }); +}); + +test("moveQubit: swap remaps a classical-control consumer buried inside a group", () => { + // Consumer is two groups deep on wire 2; wrapper `.targets` set by hand to keep them on wire 2 + // only. Swapping wires 0 and 1 must still reach the buried consumer's classical ref. + const model = build( + circuit(3, [ + [_mGate(0, 0)], + [ + { + kind: "unitary", + gate: "Foo", + targets: [{ qubit: 2 }], + children: [ + { + components: [ + { + kind: "unitary", + gate: "Bar", + targets: [{ qubit: 2 }], + children: [{ components: [_ccx(2, 0, 0)] }], + }, + ], + }, + ], + }, + ], + ]), + ); + + moveQubit(model, 0, 1, false); + + // Buried consumer's classical ref and the M both rewire 0 → 1. + expectOp(at(model, "1,0-0,0-0,0"), { X: { ctrls: [{ q: 1, r: 0 }] } }); + expectOp(at(model, "0,0"), { + Measure: { qubits: [1], results: [{ q: 1, r: 0 }] }, + }); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuitModel.test.mjs b/source/npm/qsharp/test/circuit-editor/circuitModel.test.mjs new file mode 100644 index 00000000000..b1e2317eee5 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuitModel.test.mjs @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// CircuitModel tests — exercises the Data layer of the circuit editor +// (`ux/circuit-vis/data/circuitModel.ts`) directly. Pure data, no JSDOM. Covers the invariants the +// model maintains on behalf of the Action layer: per-wire use counts, qubit-list growth/trim, and +// the borrow-by-reference contract with the underlying `Circuit`. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CircuitModel } from "../../dist/ux/circuit-vis/data/circuitModel.js"; + +/** + * Build a fresh empty Circuit with `n` qubits and no operations. + * @param {number} n + * @returns {import("../../dist/ux/circuit-vis/index.js").Circuit} + */ +function emptyCircuit(n) { + return { + qubits: Array.from({ length: n }, (_, id) => ({ id })), + componentGrid: [], + }; +} + +/** + * Build a unitary op targeting `targetQubit`, optionally with controls on `controlQubits`. + * @param {string} gate + * @param {number} targetQubit + * @param {number[]} [controlQubits] + * @returns {import("../../dist/ux/circuit-vis/index.js").Operation} + */ +function unitary(gate, targetQubit, controlQubits) { + const op = { + kind: "unitary", + gate, + targets: [{ qubit: targetQubit }], + }; + if (controlQubits && controlQubits.length > 0) { + /** @type {any} */ (op).controls = controlQubits.map((qubit) => ({ + qubit, + })); + } + return /** @type {any} */ (op); +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +test("constructor on empty circuit produces zero-filled use counts", () => { + const model = new CircuitModel(emptyCircuit(3)); + + assert.equal(model.qubits.length, 3); + assert.deepEqual(model.qubitUseCounts, [0, 0, 0]); + assert.deepEqual(model.componentGrid, []); +}); + +test("constructor seeds qubitUseCounts from existing operations", () => { + /** @type {any} */ + const circuit = { + qubits: [{ id: 0 }, { id: 1 }, { id: 2 }], + componentGrid: [ + { + components: [ + unitary("X", 0, [1]), // wire 0 (target) + wire 1 (control) + unitary("H", 2), // wire 2 (target) + ], + }, + { + components: [unitary("Y", 1)], // wire 1 again + }, + ], + }; + + const model = new CircuitModel(circuit); + + assert.deepEqual(model.qubitUseCounts, [1, 2, 1]); +}); + +test("constructor borrows componentGrid and qubits by reference", () => { + const circuit = emptyCircuit(2); + const model = new CircuitModel(circuit); + + // Both arrays are shared with the underlying circuit. + assert.equal(circuit.qubits, model.qubits); + assert.equal(circuit.componentGrid, model.componentGrid); + + // A mutation via the model is visible on the underlying circuit. + model.componentGrid.push({ components: [unitary("H", 0)] }); + assert.equal(circuit.componentGrid.length, 1); +}); + +test("constructor with measurement op counts only qubits, not result registers", () => { + /** @type {any} */ + const circuit = { + qubits: [{ id: 0 }, { id: 1 }, { id: 2 }], + componentGrid: [ + { + components: [ + { + kind: "measurement", + gate: "Measure", + qubits: [{ qubit: 0 }], + results: [{ qubit: 0, result: 0 }], + }, + ], + }, + ], + }; + + const model = new CircuitModel(circuit); + + // Wire 0 counted once for the qubit register; the result register (which has `result` defined) is + // excluded by the bounds check. + assert.deepEqual(model.qubitUseCounts, [1, 0, 0]); +}); + +test("constructor silently ignores ops referencing out-of-range wires", () => { + /** @type {any} */ + const circuit = { + qubits: [{ id: 0 }, { id: 1 }], + componentGrid: [ + { + components: [unitary("X", 5)], // wire 5 doesn't exist + }, + ], + }; + + const model = new CircuitModel(circuit); + + // No throw, no growth — out-of-range refs are dropped. + assert.deepEqual(model.qubitUseCounts, [0, 0]); + assert.equal(model.qubits.length, 2); +}); + +// --------------------------------------------------------------------------- +// ensureQubitCount +// --------------------------------------------------------------------------- + +test("ensureQubitCount grows qubits and qubitUseCounts to fit a wire index", () => { + const model = new CircuitModel(emptyCircuit(2)); + + model.ensureQubitCount(4); + + assert.equal(model.qubits.length, 5); + assert.deepEqual(model.qubitUseCounts, [0, 0, 0, 0, 0]); + // Newly-added qubits get their position as id. + assert.equal(model.qubits[2].id, 2); + assert.equal(model.qubits[4].id, 4); +}); + +test("ensureQubitCount is a no-op when already large enough", () => { + const model = new CircuitModel(emptyCircuit(3)); + + model.ensureQubitCount(1); + + assert.equal(model.qubits.length, 3); + assert.deepEqual(model.qubitUseCounts, [0, 0, 0]); +}); + +// --------------------------------------------------------------------------- +// removeTrailingUnusedQubits +// --------------------------------------------------------------------------- + +test("removeTrailingUnusedQubits drops only zero-count tail wires", () => { + /** @type {any} */ + const circuit = { + qubits: [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }], + componentGrid: [{ components: [unitary("X", 1)] }], + }; + const model = new CircuitModel(circuit); + assert.deepEqual(model.qubitUseCounts, [0, 1, 0, 0]); + + model.removeTrailingUnusedQubits(); + + // Wires 2 and 3 (trailing zeros) are gone; wires 0 and 1 stay because the trim stops at the first + // non-zero from the right. + assert.equal(model.qubits.length, 2); + assert.deepEqual(model.qubitUseCounts, [0, 1]); +}); + +test("removeTrailingUnusedQubits: all-used is a no-op, all-unused empties the model", () => { + // All wires used -> nothing to trim. + /** @type {any} */ + const allUsed = { + qubits: [{ id: 0 }, { id: 1 }], + componentGrid: [ + { components: [unitary("X", 0)] }, + { components: [unitary("H", 1)] }, + ], + }; + const usedModel = new CircuitModel(allUsed); + usedModel.removeTrailingUnusedQubits(); + assert.equal(usedModel.qubits.length, 2); + assert.deepEqual(usedModel.qubitUseCounts, [1, 1]); + + // No wires used -> trims everything. + const emptyModel = new CircuitModel(emptyCircuit(3)); + emptyModel.removeTrailingUnusedQubits(); + assert.equal(emptyModel.qubits.length, 0); + assert.deepEqual(emptyModel.qubitUseCounts, []); +}); + +test("removeTrailingUnusedQubits walks nested children, not just qubitUseCounts", () => { + // The trim must walk the actual op tree (including each group's derived `.targets`), not the + // incrementally-maintained `qubitUseCounts`. Groups can name wires in their derived `.targets` + // that the use-count cache no longer reflects; trusting the cache could drop a wire still + // referenced by a group, leaving the renderer with a stale row index. + /** @type {any} */ + const circuit = { + qubits: [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }], + componentGrid: [ + { + components: [ + { + kind: "unitary", + gate: "Group", + // Group's own derived targets claim wire 3, even though the only nested child is on + // wire 0. + targets: [{ qubit: 0 }, { qubit: 3 }], + children: [ + { + components: [ + { kind: "unitary", gate: "X", targets: [{ qubit: 0 }] }, + ], + }, + ], + }, + ], + }, + ], + }; + const model = new CircuitModel(circuit); + // The constructor only walks top-level ops, so it counts the Group op's targets [0, 3] → + // useCounts = [1, 0, 0, 1]. Now hand-corrupt qubitUseCounts to model the post-getChildTargets + // state: imagine a move that rewrote Group.targets and an intervening `_removeOp` zeroed out wire + // 3's counter even though Group still claims it. + model.qubitUseCounts = [1, 0, 0, 0]; + + model.removeTrailingUnusedQubits(); + + // Wire 3 must NOT have been dropped, because Group's `.targets` still names it. (The renderer + // will read those targets and crash if wire 3 is gone.) + assert.equal( + model.qubits.length, + 4, + "wire 3 must stay alive because Group still references it", + ); +}); + +test("removeTrailingUnusedQubits is recursive into expanded-group children", () => { + // Even when the parent's derived `.targets` happens to be in sync, a wire used only deep inside a + // group's children must still keep the wire alive. + /** @type {any} */ + const circuit = { + qubits: [{ id: 0 }, { id: 1 }, { id: 2 }], + componentGrid: [ + { + components: [ + { + kind: "unitary", + gate: "Group", + targets: [{ qubit: 0 }], + children: [ + { + components: [ + { + kind: "unitary", + gate: "Inner", + targets: [{ qubit: 0 }], + children: [ + { + components: [ + { + kind: "unitary", + gate: "Y", + targets: [{ qubit: 2 }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + const model = new CircuitModel(circuit); + // Top-level constructor only sees Group's targets [0] → useCounts = [1, 0, 0]. Wire 2 is used + // only deep inside, so the counter is zero. The grid walk must keep wire 2. + assert.deepEqual(model.qubitUseCounts, [1, 0, 0]); + + model.removeTrailingUnusedQubits(); + + assert.equal( + model.qubits.length, + 3, + "wire 2 must stay because a nested child references it", + ); +}); + +// --------------------------------------------------------------------------- +// increment / decrement +// --------------------------------------------------------------------------- + +test("increment/decrementQubitUseCountForOp count every register and ignore out-of-range", () => { + const model = new CircuitModel(emptyCircuit(3)); + + // Counts target + both controls. + const op = /** @type {any} */ (unitary("X", 0, [1, 2])); + model.incrementQubitUseCountForOp(op); + assert.deepEqual(model.qubitUseCounts, [1, 1, 1]); + + // Out-of-range registers are silently skipped. + model.incrementQubitUseCountForOp(/** @type {any} */ (unitary("X", 5))); + assert.deepEqual(model.qubitUseCounts, [1, 1, 1]); + + // Decrement mirrors increment back to zero. + model.decrementQubitUseCountForOp(op); + assert.deepEqual(model.qubitUseCounts, [0, 0, 0]); +}); diff --git a/source/npm/qsharp/test/circuit-editor/contextMenu.test.mjs b/source/npm/qsharp/test/circuit-editor/contextMenu.test.mjs new file mode 100644 index 00000000000..0c0909f9012 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/contextMenu.test.mjs @@ -0,0 +1,432 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// contextMenu tests — direct coverage for `addContextMenuToHostElem` in `editor/contextMenu.ts`. +// Each test wires a small SVG fixture (a `` with a host shape inside) to a +// minimal `CircuitEvents` stub, dispatches a `contextmenu` MouseEvent, and asserts on the rendered +// `.context-menu` items. +// +// The stub only implements the five members the menu reads: `componentGrid`, `model`, `renderFn`, +// `_startAddingControl`, `_startRemovingControl`. The real class delegates `componentGrid` to +// `model.componentGrid`; tests do the same on the stub. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { addContextMenuToHostElem } from "../../dist/ux/circuit-vis/editor/contextMenu.js"; +import { build, circuit, gate, meas, qubits } from "./_helpers.mjs"; +/** @typedef {import("../../dist/ux/circuit-vis/data/circuitModel.js").CircuitModel} CircuitModel */ + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; + globalThis.MouseEvent = jsdom.window.MouseEvent; +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * Build a stub `CircuitEvents` carrying just the five members `addContextMenuToHostElem` consults, + * plus spies on the two delegating helpers so tests can assert they were invoked. + * + * @param {CircuitModel} model + */ +function makeStubEvents(model) { + const startAddingCalls = /** @type {any[]} */ ([]); + const startRemovingCalls = /** @type {any[]} */ ([]); + const renderCalls = { count: 0 }; + const stub = { + model, + // Mirror `CircuitEvents.componentGrid`'s delegation to `model.componentGrid` so `findOperation` + // resolves correctly. + get componentGrid() { + return model.componentGrid; + }, + renderFn: () => { + renderCalls.count++; + }, + _startAddingControl: (/** @type {any} */ op) => { + startAddingCalls.push(op); + }, + _startRemovingControl: (/** @type {any} */ op) => { + startRemovingCalls.push(op); + }, + }; + return { stub, startAddingCalls, startRemovingCalls, renderCalls }; +} + +/** + * Build a `` wrapper containing a host shape (rect for a gate body, circle + * for a control dot). The wrapper's `data-location` is what `findGateElem` resolves via + * `closest()`. + * + * @param {string} location - "0,0" etc. + * @param {"body" | "control-dot"} hostKind + * @param {number} [wireIdx] - only used for control-dot + */ +function buildGateFixture(location, hostKind, wireIdx) { + const svg = document.createElementNS(SVG_NS, "svg"); + document.body.appendChild(svg); + const wrapper = document.createElementNS(SVG_NS, "g"); + wrapper.setAttribute("data-location", location); + svg.appendChild(wrapper); + + /** @type {SVGGraphicsElement} */ + let host; + if (hostKind === "control-dot") { + host = /** @type {any} */ (document.createElementNS(SVG_NS, "circle")); + host.classList.add("control-dot"); + if (wireIdx != null) host.setAttribute("data-wire", String(wireIdx)); + } else { + host = /** @type {any} */ (document.createElementNS(SVG_NS, "rect")); + host.classList.add("gate-h"); + } + wrapper.appendChild(host); + return { svg, wrapper, host }; +} + +/** + * Dispatch a `contextmenu` event on the host element. The builder reads `ev.clientX` / `ev.clientY` + * for positioning. + * + * @param {SVGGraphicsElement} host + */ +function rightClick(host) { + host.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 50, + clientY: 50, + }), + ); +} + +/** Read the rendered menu's option labels in order, or [] if no menu. */ +function getMenuLabels() { + const menu = document.querySelector(".context-menu"); + if (!menu) return null; + return Array.from(menu.querySelectorAll(".context-menu-option")).map( + (el) => el.textContent ?? "", + ); +} + +// --------------------------------------------------------------------------- +// kind-driven branches +// --------------------------------------------------------------------------- + +test("addContextMenuToHostElem: measurement gate shows ONLY Delete", () => { + // Measurements have no adjoint, controls, or params. The kind === "measurement" branch offers + // only delete. + const model = build(circuit(qubits(1, { 0: 1 }), [[meas(0)]])); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual(getMenuLabels(), ["Delete"]); +}); + +test("addContextMenuToHostElem: ket gate shows ONLY Delete", () => { + // Kets are treated like measurements for menu purposes — no controls, params, or adjoint. + const model = build( + circuit(1, [[{ kind: "ket", gate: "|0〉", targets: [{ qubit: 0 }] }]]), + ); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual(getMenuLabels(), ["Delete"]); +}); + +// --------------------------------------------------------------------------- +// control-dot host +// --------------------------------------------------------------------------- + +test("addContextMenuToHostElem: control-dot on a SIMPLE unitary shows ONLY Remove control", () => { + // Right-clicking the control dot of an ordinary CNOT offers only "Remove control"; gestures + // affecting the whole gate are reached from the gate body. + const model = build(circuit(2, [[gate("X", 1, { ctrls: [0] })]])); + const { stub } = makeStubEvents(model); + // Host is the control dot on wire 0. The wrapper's `data-location` points at the gate's grid + // coords ("0,0"); the dot itself carries `data-wire`. + const { host } = buildGateFixture("0,0", "control-dot", 0); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual(getMenuLabels(), ["Remove control"]); +}); + +test("addContextMenuToHostElem: control-dot on a MULTI-TARGET unitary shows NO menu items", () => { + // Control-dot menu on a body that satisfies `_isMultiTargetOrGroup` mirrors the action layer's + // refusal — no items appended, no fallback to body-style items. The menu element is created but + // empty. + const model = build( + circuit(3, [ + [ + { + kind: "unitary", + gate: "MyMultiTarget", + targets: [{ qubit: 1 }, { qubit: 2 }], + controls: [{ qubit: 0 }], + }, + ], + ]), + ); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "control-dot", 0); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + // Empty menu — no `.context-menu-option` children. + assert.deepEqual( + getMenuLabels(), + [], + "multi-target body + control-dot host yields an empty menu", + ); +}); + +// --------------------------------------------------------------------------- +// X-gate special-case ordering +// --------------------------------------------------------------------------- + +test("addContextMenuToHostElem: X gate WITHOUT controls shows [Add Control, Delete]", () => { + // X is special-cased: no Toggle Adjoint (X† == X) and no Edit Argument (no params). Order is "Add + // Control, [Remove Control,] Delete". + const model = build(circuit(1, [[gate("X", 0)]])); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual(getMenuLabels(), ["Add Control", "Delete"]); +}); + +test("addContextMenuToHostElem: X gate WITH controls shows [Add Control, Remove Control, Delete]", () => { + // Same X branch with an existing control — Remove Control is inserted between Add Control and + // Delete. + const model = build(circuit(2, [[gate("X", 1, { ctrls: [0] })]])); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual(getMenuLabels(), [ + "Add Control", + "Remove Control", + "Delete", + ]); +}); + +// --------------------------------------------------------------------------- +// Multi-target unitaries, parameterized unitaries, general unitary +// --------------------------------------------------------------------------- + +test("addContextMenuToHostElem: multi-target unitary drops Add/Remove Control", () => { + // Any non-X unitary with `targets.length > 1` must not surface control authoring. The body still + // gets Toggle Adjoint (not a group) and Delete. + const model = build( + circuit(2, [ + [ + { + kind: "unitary", + gate: "SWAP", + targets: [{ qubit: 0 }, { qubit: 1 }], + }, + ], + ]), + ); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual( + getMenuLabels(), + ["Toggle Adjoint", "Delete"], + "multi-target unitary has no Add Control / Remove Control", + ); +}); + +test("addContextMenuToHostElem: group drops Toggle Adjoint", () => { + // An op with `children != null` must not surface Toggle Adjoint. Groups also satisfy + // `_isMultiTargetOrGroup`, so control authoring is suppressed too. Net menu for a param-less + // group: just Delete. + const model = build( + circuit(1, [ + [ + { + kind: "unitary", + gate: "MyGroup", + targets: [{ qubit: 0 }], + children: [ + { + components: [ + { kind: "unitary", gate: "H", targets: [{ qubit: 0 }] }, + ], + }, + ], + }, + ], + ]), + ); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual( + getMenuLabels(), + ["Delete"], + "a param-less group has no Toggle Adjoint / Add / Remove Control", + ); +}); + +test("addContextMenuToHostElem: ordinary unitary with params shows [Toggle Adjoint, Add Control, Edit Argument, Delete]", () => { + // General-case body menu without controls. Edit Argument only appears when `params.length > 0`. + const model = build( + circuit(1, [ + [ + { + kind: "unitary", + gate: "Rx", + targets: [{ qubit: 0 }], + params: [{ name: "theta", type: "Double" }], + args: ["0.0"], + }, + ], + ]), + ); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual(getMenuLabels(), [ + "Toggle Adjoint", + "Add Control", + "Edit Argument", + "Delete", + ]); +}); + +test("addContextMenuToHostElem: ordinary unitary with controls + params shows the full menu including Remove Control", () => { + // Adds an existing control to the prior fixture: Remove Control appears between Add Control and + // Edit Argument. + const model = build( + circuit(2, [ + [ + { + kind: "unitary", + gate: "Ry", + targets: [{ qubit: 1 }], + controls: [{ qubit: 0 }], + params: [{ name: "theta", type: "Double" }], + args: ["0.0"], + }, + ], + ]), + ); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + + assert.deepEqual(getMenuLabels(), [ + "Toggle Adjoint", + "Add Control", + "Remove Control", + "Edit Argument", + "Delete", + ]); +}); + +// --------------------------------------------------------------------------- +// Menu lifecycle +// --------------------------------------------------------------------------- + +test("addContextMenuToHostElem: opening a second time replaces the first menu (no DOM duplication)", () => { + // The builder removes any existing `.context-menu` before appending the new one, so a double + // right-click doesn't stack menus. + const model = build(circuit(1, [[gate("H", 0)]])); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + rightClick(host); + + assert.equal( + document.querySelectorAll(".context-menu").length, + 1, + "only one menu should be present after two right-clicks", + ); +}); + +test("addContextMenuToHostElem: outside-click closes the menu", () => { + // The document-level `click` listener is registered with `{ once: true }` and removes the menu on + // the next click anywhere in the page — the same path that closes the menu after the user picks + // an item. + const model = build(circuit(1, [[gate("H", 0)]])); + const { stub } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + assert.ok(document.querySelector(".context-menu"), "menu should be open"); + + // Simulate an outside click — anywhere in the document. + document.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + assert.equal( + document.querySelector(".context-menu"), + null, + "menu should be removed by the outside-click handler", + ); +}); + +test("addContextMenuToHostElem: clicking Add Control invokes _startAddingControl with the selected op", () => { + // The Add Control item's click handler calls + // `circuitEvents._startAddingControl(selectedOperation)`. + const op = { kind: "unitary", gate: "H", targets: [{ qubit: 0 }] }; + const model = build(circuit(1, [[/** @type {any} */ (op)]])); + const { stub, startAddingCalls } = makeStubEvents(model); + const { host } = buildGateFixture("0,0", "body"); + addContextMenuToHostElem(/** @type {any} */ (stub), host); + + rightClick(host); + const menu = document.querySelector(".context-menu"); + assert.ok(menu); + const items = Array.from(menu.querySelectorAll(".context-menu-option")); + const addCtrl = items.find((el) => el.textContent === "Add Control"); + assert.ok(addCtrl, "Add Control item should be present"); + /** @type {HTMLElement} */ (addCtrl).click(); + + assert.equal(startAddingCalls.length, 1); + // findOperation returns the live reference, not a clone, so identity matches the op on the grid. + assert.equal(startAddingCalls[0], model.componentGrid[0].components[0]); +}); diff --git a/source/npm/qsharp/test/circuit-editor/dragController.test.mjs b/source/npm/qsharp/test/circuit-editor/dragController.test.mjs new file mode 100644 index 00000000000..2ccc39d453b --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/dragController.test.mjs @@ -0,0 +1,847 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// DragController tests — exercises the drag-and-drop surface against a hand-built SVG fixture. Each +// test focuses on a contract that doesn't require the full `LayoutMap` / `process` rendering +// pipeline. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { InteractionState } from "../../dist/ux/circuit-vis/actions/interactionState.js"; +import { DragController } from "../../dist/ux/circuit-vis/editor/controllers/dragController.js"; +import { QubitController } from "../../dist/ux/circuit-vis/editor/controllers/qubitController.js"; +import { at, build, circuit, gate, group, meas } from "./_helpers.mjs"; + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; + globalThis.MouseEvent = jsdom.window.MouseEvent; + globalThis.Node = jsdom.window.Node; +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * Build a fixture with: + * - container > svg.qviz[width=200] + * - svg.qviz > g.editor-overlay > g.dropzone-layer + g.ghost-qubit-layer + * - container > a single toolbox item with `[toolbox-item]` and `data-type="H"` + * + * Returns the elements callers most often need to fire events on. + */ +function buildFixture() { + const container = document.createElement("div"); + document.body.appendChild(container); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "qviz"); + svg.setAttribute("width", "200"); + container.appendChild(svg); + + const overlay = document.createElementNS(SVG_NS, "g"); + overlay.setAttribute("class", "editor-overlay"); + svg.appendChild(overlay); + + const dropzoneLayer = document.createElementNS(SVG_NS, "g"); + dropzoneLayer.setAttribute("class", "dropzone-layer"); + overlay.appendChild(dropzoneLayer); + + const ghostQubitLayer = document.createElementNS(SVG_NS, "g"); + ghostQubitLayer.setAttribute("class", "ghost-qubit-layer"); + overlay.appendChild(ghostQubitLayer); + + // Toolbox item for the H gate. `getToolboxElems` selects on `[toolbox-item]`; the controller + // reads `data-type`. + const toolboxItem = document.createElement("div"); + toolboxItem.setAttribute("toolbox-item", ""); + toolboxItem.setAttribute("data-type", "H"); + container.appendChild(toolboxItem); + + return { + container, + svg, + overlay, + dropzoneLayer, + ghostQubitLayer, + toolboxItem, + }; +} + +/** + * Append a dropzone rect to the dropzone-layer at `(location, wire)`. Returns the element so the + * caller can dispatch mouseup on it. + */ +function appendDropzone( + /** @type {SVGElement} */ dropzoneLayer, + /** @type {string} */ location, + /** @type {number} */ wire, + interColumn = false, +) { + const dropzone = document.createElementNS(SVG_NS, "rect"); + dropzone.setAttribute("class", "dropzone"); + dropzone.setAttribute("data-dropzone-location", location); + dropzone.setAttribute("data-dropzone-wire", String(wire)); + if (interColumn) { + dropzone.setAttribute("data-dropzone-inter-column", "true"); + } + dropzoneLayer.appendChild(dropzone); + return dropzone; +} + +/** + * Construct a DragController + its `QubitController` dependency against the fixture. `wireData` is + * filled with stable y-coords so any spawned wire dropzones have somewhere to anchor. + * + * The returned `renderCalls()` accessor reports how many times the controller asked for a re-render + * — most tests assert on this. + */ +function makeController( + /** @type {any} */ fixture, + /** @type {any} */ model, + /** @type {{ renderFn?: () => void }} */ options = {}, +) { + const interaction = new InteractionState(); + let renderCalls = 0; + const userRender = options.renderFn; + const renderFn = () => { + renderCalls++; + userRender?.(); + }; + const wireData = Array.from( + { length: model.qubits.length + 1 }, + (_, i) => 40 + 60 * i, + ); + const ctx = { + model, + interaction, + layoutMap: /** @type {any} */ ({ scopes: new Map() }), + container: fixture.container, + circuitSvg: fixture.svg, + overlayLayer: fixture.overlay, + dropzoneLayer: fixture.dropzoneLayer, + ghostQubitLayer: fixture.ghostQubitLayer, + wireData, + renderFn, + }; + // The drag controller uses the qubit controller for the qubit-label drag-out-delete path. We also + // need a qubit-input-states group so QubitController's constructor doesn't crash. + const labelGroup = document.createElementNS(SVG_NS, "g"); + labelGroup.setAttribute("class", "qubit-input-states"); + fixture.svg.insertBefore(labelGroup, fixture.overlay); + const qubitController = new QubitController(/** @type {any} */ (ctx)); + const dragController = new DragController( + /** @type {any} */ (ctx), + qubitController, + ); + return { + dragController, + qubitController, + ctx, + interaction, + renderCalls: () => renderCalls, + }; +} + +/** + * Recursively stamp `dataAttributes.location` onto every op based on its grid position — + * `"col,row"` at the top level, `"…-col,row"` for nested children. Mirrors the location strings the + * editor computes at render time, so the DSL-built circuits the tests feed in resolve the same way + * the controller expects. + * + * @param {any[]} grid + * @param {string} [prefix] + */ +function assignLocations(grid, prefix = "") { + grid.forEach((/** @type {any} */ col, /** @type {number} */ colIdx) => { + col.components.forEach( + (/** @type {any} */ op, /** @type {number} */ opIdx) => { + const location = `${prefix}${colIdx},${opIdx}`; + op.dataAttributes = { ...(op.dataAttributes ?? {}), location }; + if (op.children) assignLocations(op.children, `${location}-`); + }, + ); + }); +} + +/** + * Build a `CircuitModel` from a DSL circuit literal, stamping grid-position locations onto every op + * first. + * + * @param {any} circuitObj + * @returns {any} + */ +function buildModel(circuitObj) { + assignLocations(circuitObj.componentGrid); + return build(circuitObj); +} + +/** + * One-call test setup: fixture + located model + controller. + * + * `options.beforeController(fixture, model)` runs after the model is built but before the + * controller is constructed — use it to seed dropzones / gate elements that must exist when the + * controller wires its listeners. + * + * @param {any} circuitObj + * @param {{ beforeController?: (fixture: any, model: any) => void }} [options] + */ +function setup(circuitObj, options = {}) { + const fixture = buildFixture(); + const model = buildModel(circuitObj); + options.beforeController?.(fixture, model); + const controller = makeController(fixture, model); + return { fixture, model, ...controller }; +} + +/** + * Count how many ops named `name` appear anywhere in the model's tree (top-level columns and every + * nested child grid). + * + * @param {any} model + * @param {string} name + * @returns {number} + */ +function countGate(model, name) { + let count = 0; + const walk = (/** @type {any[]} */ grid) => { + for (const col of grid) { + for (const op of col.components) { + if (op.gate === name) count++; + if (op.children) walk(op.children); + } + } + }; + walk(model.componentGrid); + return count; +} + +const dispatchMouseDown = ( + /** @type {EventTarget} */ target, + /** @type {MouseEventInit} */ init = {}, +) => + target.dispatchEvent( + new MouseEvent("mousedown", { button: 0, bubbles: true, ...init }), + ); + +const dispatchMouseUp = ( + /** @type {EventTarget} */ target, + /** @type {MouseEventInit} */ init = {}, +) => + target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, ...init })); + +// --------------------------------------------------------------- +// Basic gestures and lifecycle: toolbox drag, dropzone drop, startAddingControl / +// startRemovingControl, off-circuit mouseup, dispose() +// --------------------------------------------------------------- + +test("toolbox mousedown sets selectedOperation to the toolbox prototype", () => { + const { fixture, interaction, dragController } = setup(circuit(2, [])); + + dispatchMouseDown(fixture.toolboxItem); + + assert.ok(interaction.selectedOperation, "selectedOperation should be set"); + assert.equal(/** @type {any} */ (interaction.selectedOperation).gate, "H"); + assert.equal(interaction.dragging, true); + + // Cleanup so test isolation isn't broken by leftover document listeners. + dragController.dispose(); +}); + +test("dropzone mouseup after a toolbox drag adds the operation to the model", () => { + /** @type {any} */ + let dropzone; + const { fixture, model, interaction, dragController, renderCalls } = setup( + circuit(2, []), + { + beforeController: (f) => { + dropzone = appendDropzone(f.dropzoneLayer, "0,0", 0); + }, + }, + ); + + dispatchMouseDown(fixture.toolboxItem); + // Verify drag actually started before we test the drop. + assert.equal(/** @type {any} */ (interaction.selectedOperation).gate, "H"); + + dispatchMouseUp(dropzone); + + // Promise microtasks need to flush — onDropzoneMouseUp is async even when there are no params to + // prompt for. + return Promise.resolve().then(() => { + assert.equal(model.componentGrid.length, 1); + assert.equal(at(model, "0,0").gate, "H"); + assert.equal(at(model, "0,0").targets[0].qubit, 0); + assert.equal(renderCalls(), 1); + // Transient state cleared after commit. + assert.equal(interaction.selectedOperation, null); + assert.equal(interaction.dragging, false); + + dragController.dispose(); + }); +}); + +test("startAddingControl spawns one dropzone per non-target / non-control wire", () => { + const { fixture, model, dragController } = setup( + circuit(4, [[gate("X", 1, { ctrls: [0] })]]), + ); + + const op = at(model, "0,0"); + /** @type {any} */ (dragController).startAddingControl(op, "0,0"); + + // wireData has length n+1 (= 5) including the trailing ghost wire. The controller iterates the + // full wireData length and excludes only target / existing-control wires — wires 0 (control) and + // 1 (target) are skipped; wires 2, 3, and the ghost wire 4 each get a dropzone (3 total). + // Including the ghost wire is intentional: adding a control to it grows the circuit by one qubit. + const dropzones = fixture.overlay.querySelectorAll( + "[data-dropzone-wire]:not(.dropzone)", + ); + assert.equal(dropzones.length, 3); + const wires = Array.from(dropzones) + .map((d) => Number(d.getAttribute("data-dropzone-wire"))) + .sort((a, b) => a - b); + assert.deepEqual(wires, [2, 3, 4]); + + dragController.dispose(); +}); + +test("startRemovingControl spawns one dropzone per existing control", () => { + const { fixture, model, dragController } = setup( + circuit(4, [[gate("X", 3, { ctrls: [0, 2] })]]), + ); + + const op = at(model, "0,0"); + dragController.startRemovingControl(op); + + // One dropzone per control → 2 dropzones. + const dropzones = fixture.overlay.querySelectorAll( + "[data-dropzone-wire]:not(.dropzone)", + ); + assert.equal(dropzones.length, 2); + const wires = Array.from(dropzones) + .map((d) => Number(d.getAttribute("data-dropzone-wire"))) + .sort((a, b) => a - b); + assert.deepEqual(wires, [0, 2]); + + dragController.dispose(); +}); + +test("document mouseup off-circuit during a drag removes the source operation", () => { + const { model, interaction, dragController, renderCalls } = setup( + circuit(2, [[gate("H", 0)]]), + ); + + // Simulate a drag in progress: source op is selected and ghost is out, but the mouseup never + // landed on the circuit surface. + interaction.selectedOperation = at(model, "0,0"); + interaction.dragging = true; + interaction.mouseUpOnCircuit = false; + + dispatchMouseUp(document); + + // Source op was removed via removeOperation → grid is empty. + assert.equal(model.componentGrid.length, 0); + assert.equal(renderCalls(), 1); + // Transient state cleared. + assert.equal(interaction.dragging, false); + + dragController.dispose(); +}); + +test("dispose() removes document listeners so subsequent mouseup is a no-op", () => { + const { model, interaction, dragController, renderCalls } = setup( + circuit(1, [[gate("H", 0)]]), + ); + + dragController.dispose(); + + // After dispose, the document mouseup handler is unregistered, so a drag-out-delete style + // dispatch should NOT mutate the model. + interaction.selectedOperation = at(model, "0,0"); + interaction.dragging = true; + interaction.mouseUpOnCircuit = false; + + dispatchMouseUp(document); + + // Model unchanged; no render fired. + assert.equal(model.componentGrid.length, 1); + assert.equal(renderCalls(), 0); +}); + +// --------------------------------------------------------------- +// onGateMouseDown on an expanded group. An expanded group renders as ``. The early return on `data-expanded === "true"` means a mousedown on the +// group's own box / label must NOT select the whole group — the user edits its children +// individually, not the group as a unit. +// --------------------------------------------------------------- + +test("onGateMouseDown on an expanded group's box / label does not select the group", () => { + // Ordinary clicks on the expanded group's dashed box / label must leave `selectedOperation` + // untouched so the user can't grab the group as a whole when expanded. + /** @type {any} */ + let gateElem; + const { interaction, dragController } = setup( + circuit(2, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + { + beforeController: (fixture) => { + gateElem = document.createElementNS(SVG_NS, "g"); + gateElem.setAttribute("class", "gate"); + gateElem.setAttribute("data-expanded", "true"); + gateElem.setAttribute("data-location", "0,0"); + const dashedBox = document.createElementNS(SVG_NS, "rect"); + dashedBox.setAttribute("class", "gate-unitary"); + gateElem.appendChild(dashedBox); + fixture.svg.appendChild(gateElem); + }, + }, + ); + + interaction.selectedWire = 0; + + dispatchMouseDown(gateElem); + + assert.equal( + interaction.selectedOperation, + null, + "expanded-group mousedown must NOT set selectedOperation", + ); + + dragController.dispose(); +}); + +// --------------------------------------------------------------- +// commitAddControl must NOT duplicate the source op when the new control's wire crosses a +// same-column sibling. The action layer's `_resolveSpanChange` owns the cascade-aware split; the +// dragController must not run a second split of its own. +// --------------------------------------------------------------- + +test("commitAddControl does not duplicate the source op when widening collides with a sibling", () => { + // 4 qubits. Column 0: [H on q0, Z on q3]. Adding a control on q3 to H widens H to span q0..q3 — + // overlaps Z, so the column must split. + const { model, fixture, dragController, renderCalls } = setup( + circuit(4, [[gate("H", 0), gate("Z", 3)]]), + ); + + const hOp = at(model, "0,0"); + dragController.startAddingControl(hOp); + + // Clicking the wire-pick dropzone for q3 widens H to span q0..q3 — collides with Z. + const dropzone = fixture.overlay.querySelector( + '[data-dropzone-wire="3"]:not(.dropzone)', + ); + assert.ok(dropzone, "wire-pick dropzone for q3 must have been spawned"); + + dropzone.dispatchEvent(new MouseEvent("click", { button: 0, bubbles: true })); + + // Action layer must have split the column: H alone in col 0, Z alone in col 1. + assert.equal( + model.componentGrid.length, + 2, + `expected col 0 to split; got ${JSON.stringify( + model.componentGrid.map((/** @type {any} */ c) => + c.components.map((/** @type {any} */ op) => op.gate), + ), + )}`, + ); + assert.deepEqual( + model.componentGrid[0].components.map((/** @type {any} */ op) => op.gate), + ["H"], + "H must occupy col 0 alone", + ); + assert.deepEqual( + model.componentGrid[1].components.map((/** @type {any} */ op) => op.gate), + ["Z"], + "Z must occupy col 1 alone", + ); + + // H must appear exactly once in the grid. Count by gate name to catch any phantom duplicate + // wherever it ended up. + const hCount = countGate(model, "H"); + assert.equal( + hCount, + 1, + `H must appear exactly once in the grid after commitAddControl; got ${hCount}`, + ); + + // The new control landed on q3 as expected. + assert.deepEqual( + hOp.controls.map((/** @type {any} */ c) => c.qubit), + [3], + "H must have exactly one control on q3", + ); + + // Exactly one renderFn call from commitAddControl. + assert.equal(renderCalls(), 1, "expected exactly one render after commit"); + + dragController.dispose(); +}); + +test("commitAddControl on a nested op does not duplicate when widening cascades to split the outer column", () => { + // Nested cousin of the previous test: adding a control to a child of Foo widens Foo's `.targets` + // to enclose the new wire; if that widened span overlaps a top-level sibling, the top-level + // column splits. Pin: no duplicate at either level. + const { model, fixture, dragController } = setup( + circuit(4, [[group("Foo", [[gate("H", 0)]]), gate("X", 3)]]), + ); + + const hOp = at(model, "0,0-0,0"); + dragController.startAddingControl(hOp); + + // q3 is the X's wire — adding a control there widens H to q0..q3 → cascades up to widen Foo to + // q0..q3 → overlaps X → top-level col 0 must split. + const dropzone = fixture.overlay.querySelector( + '[data-dropzone-wire="3"]:not(.dropzone)', + ); + assert.ok(dropzone, "wire-pick dropzone for q3 must have been spawned"); + dropzone.dispatchEvent(new MouseEvent("click", { button: 0, bubbles: true })); + + // Top-level grid: [Foo] in col 0, [X] in col 1. + assert.equal( + model.componentGrid.length, + 2, + `expected top-level column to split; got ${JSON.stringify( + model.componentGrid.map((/** @type {any} */ c) => + c.components.map((/** @type {any} */ op) => op.gate), + ), + )}`, + ); + + // H appears exactly once across the entire tree (no duplicate at the inner OR outer level). + const hCount = countGate(model, "H"); + const fooCount = countGate(model, "Foo"); + const xCount = countGate(model, "X"); + assert.equal(hCount, 1, `H must appear exactly once; got ${hCount}`); + assert.equal(fooCount, 1, `Foo must appear exactly once; got ${fooCount}`); + assert.equal(xCount, 1, `X must appear exactly once; got ${xCount}`); + + dragController.dispose(); +}); + +// --------------------------------------------------------------- +// hideInvalidDropzones — the producer-before-consumer dropzone filter and its reset cycle. +// +// `hideInvalidDropzones(selectedLocation)` prevents dropping a classically-conditional op into a +// column at-or-before its producing measurement. It first calls `showAllDropzones` (the reset half) +// so each drag starts clean; the layer-mouseup teardown calls the same reset so a canceled drag +// doesn't leave stale `display:none` marks for the next drag. `showAllDropzones` has no standalone +// test — it's a trivial one-liner exercised through both of its callers below. +// +// Tests invoke `hideInvalidDropzones` directly via `/** @type {any} */` casts to focus on the +// filter contract; the gate-mousedown tests cover the end-to-end mouse path. +// --------------------------------------------------------------- + +/** Append a `.dropzone` rect with display:none preset (stale-mark fixture). */ +function appendHiddenDropzone( + /** @type {SVGElement} */ dropzoneLayer, + /** @type {string} */ location, + /** @type {number} */ wire, +) { + const dz = appendDropzone(dropzoneLayer, location, wire); + /** @type {any} */ (dz).style.display = "none"; + return dz; +} + +test("hideInvalidDropzones hides dropzones whose location is not strictly after the external producer", () => { + // Circuit: top-level col 0 has measurement M on q0 producing result 0; col 1 has consumer Z on q1 + // with classical control (q0, result 0). Drag the Z (selectedLocation "1,0"); the only external + // producer is M at "0,0". + // + // Dropzones we lay down in the layer: + // "0,0" → producer.col(0) NOT < target.col(0) → HIDE + // "1,0" → producer.col(0) < target.col(1) → keep + // "2,0" → producer.col(0) < target.col(2) → keep + const { fixture, dragController } = setup( + circuit(2, [ + [meas(0, { gate: "Measure" })], + [gate("Z", 1, { ctrls: [{ q: 0, r: 0 }] })], + ]), + ); + + const dzAtZero = appendDropzone(fixture.dropzoneLayer, "0,0", 1); + const dzAtOne = appendDropzone(fixture.dropzoneLayer, "1,0", 1); + const dzAtTwo = appendDropzone(fixture.dropzoneLayer, "2,0", 1); + + /** @type {any} */ (dragController).hideInvalidDropzones("1,0"); + + // The producer at col 0 means anything targeting col 0 is invalid. + assert.equal( + /** @type {any} */ (dzAtZero).style.display, + "none", + "drop at col 0 must be hidden — producer is at col 0", + ); + assert.equal( + /** @type {any} */ (dzAtOne).style.display, + "", + "drop at col 1 must stay visible — producer col 0 < target col 1", + ); + assert.equal( + /** @type {any} */ (dzAtTwo).style.display, + "", + "drop at col 2 must stay visible — producer col 0 < target col 2", + ); + + dragController.dispose(); +}); + +test("hideInvalidDropzones with no external producers leaves every dropzone visible AND clears stale marks", () => { + // Dragging an op with no classical-control dependencies: every dropzone stays visible, and any + // stale display:none marks left over from a prior drag are cleared (belt-and-suspenders reset). + const { fixture, dragController } = setup(circuit(2, [[gate("H", 0)]])); + + // Two stale display:none dropzones simulating leftover state from a prior drag that had external + // producers. + const dzStale1 = appendHiddenDropzone(fixture.dropzoneLayer, "0,0", 0); + const dzStale2 = appendHiddenDropzone(fixture.dropzoneLayer, "1,0", 0); + + /** @type {any} */ (dragController).hideInvalidDropzones("0,0"); + + assert.equal( + /** @type {any} */ (dzStale1).style.display, + "", + "stale display:none from prior drag must be cleared", + ); + assert.equal(/** @type {any} */ (dzStale2).style.display, ""); + + dragController.dispose(); +}); + +test("hideInvalidDropzones skips dropzones missing data-dropzone-location (defensive)", () => { + // The pass reads `data-dropzone-location` off each `.dropzone` and skips entries where the + // attribute is missing. Defensive — the filter shouldn't crash if a stray `.dropzone` snuck into + // the layer some other way. + const { fixture, dragController } = setup( + circuit(2, [ + [meas(0, { gate: "Measure" })], + [gate("Z", 1, { ctrls: [{ q: 0, r: 0 }] })], + ]), + ); + + // A stray .dropzone with no location attr. Should be skipped (untouched) by the filter loop. + const stray = document.createElementNS(SVG_NS, "rect"); + stray.setAttribute("class", "dropzone"); + fixture.dropzoneLayer.appendChild(stray); + + assert.doesNotThrow(() => + /** @type {any} */ (dragController).hideInvalidDropzones("1,0"), + ); + // Stray dropzone untouched (no display mark). + assert.equal(/** @type {any} */ (stray).style.display, ""); + + dragController.dispose(); +}); + +test("container mouseup teardown clears stale per-dropzone display marks", () => { + // Pairs with `showAllDropzones` and `hideInvalidDropzones`: when a drag is canceled or its commit + // doesn't re-render, the layer-level mouseup must wipe any `display:none` marks the filter + // applied — otherwise the next drag inherits them. + /** @type {any} */ + let dzHidden; + /** @type {any} */ + let dzAlsoHidden; + const { fixture, dragController } = setup(circuit(1, []), { + beforeController: (f) => { + dzHidden = appendHiddenDropzone(f.dropzoneLayer, "0,0", 0); + dzAlsoHidden = appendHiddenDropzone(f.dropzoneLayer, "1,0", 0); + }, + }); + + dispatchMouseUp(fixture.container); + + assert.equal( + /** @type {any} */ (dzHidden).style.display, + "", + "container mouseup must clear stale display:none from a previous drag", + ); + assert.equal(/** @type {any} */ (dzAlsoHidden).style.display, ""); + + dragController.dispose(); +}); + +// --------------------------------------------------------------- +// Remaining dragController paths. Each test pins a flow with a distinct model-side contract: +// +// - Ctrl+drag clone: source stays, copy lands at the target. +// - Document mouseup with `!dragging` is a no-op. +// - Qubit-drag-off delegates to the qubit controller. +// - movingControl drag-out removes only the dragged control. +// - Document mousedown clears wire dropzones in the SVG. +// --------------------------------------------------------------- + +test("Ctrl+drag clone of a regular op: source stays, copy lands at the target", () => { + // Source: H on q0 in col 0. Target: an inter-column dropzone at "0,0" ("insert a new column + // before column 0") on wire 0. Copying (Ctrl) routes through `addOperation` instead of + // `moveOperationWithConfirmation`; the source must remain in place and the clone must land in a + // fresh column. + // + // IMPORTANT: append the target dropzone BEFORE constructing the controller. + // `installDropzoneListeners` wires mouseup listeners at construction time; dropzones added later + // are inert. + /** @type {any} */ + let targetDz; + const { model, interaction, dragController, renderCalls } = setup( + circuit(2, [[gate("H", 0)]]), + { + beforeController: (f) => { + targetDz = appendDropzone( + f.dropzoneLayer, + "0,0", + 0, + /* interColumn */ true, + ); + }, + }, + ); + + // Simulate the in-progress drag of the H op. + interaction.selectedOperation = at(model, "0,0"); + interaction.selectedWire = 0; + interaction.dragging = true; + + // ctrlKey on mouseup routes through the copying branch: `addOperation` is called with the + // source's `selectedWire` as the source wire, so the original H stays put and a deep-copy clone + // lands in the newly-inserted col 0. + targetDz.dispatchEvent( + new MouseEvent("mouseup", { ctrlKey: true, bubbles: true }), + ); + + return Promise.resolve().then(() => { + // Two gates now: the original H and the clone. + const hCount = countGate(model, "H"); + assert.equal( + hCount, + 2, + `expected 2 H gates after Ctrl+drag clone, got ${hCount}`, + ); + // renderFn fires from the deepEqual block (grid changed). + assert.equal(renderCalls(), 1); + // Transient cleared. + assert.equal(interaction.selectedOperation, null); + + dragController.dispose(); + }); +}); + +test("document mouseup with !dragging is a no-op (no model change, no render)", () => { + // Mouseup events from unrelated UI must not trigger the drag-out-delete branch. The guard is + // `interaction.dragging`. + const { model, interaction, dragController, renderCalls } = setup( + circuit(1, [[gate("H", 0)]]), + ); + + // Default state: dragging is false; selectedOperation is null. + assert.equal(interaction.dragging, false); + + dispatchMouseUp(document); + + // Model is untouched; renderFn was never called. + assert.equal(model.componentGrid.length, 1); + assert.equal(model.componentGrid[0].components.length, 1); + assert.equal(renderCalls(), 0); + + dragController.dispose(); +}); + +test("qubit-drag-off (only selectedWire, no selectedOperation) removes the qubit line", () => { + // The document-mouseup handler delegates to `qubitController.removeQubitLineWithConfirmation` + // when a drag ends off-circuit with `selectedOperation == null` but `selectedWire != null` — a + // qubit label drag-off. + // + // The qubit controller skips the confirmation prompt when the qubit has zero ops attached, so we + // test against an unused qubit and assert the model shrinks. + const { model, interaction, dragController, renderCalls } = setup( + circuit(2, [[gate("H", 0)]]), + ); + + // Simulate an in-progress qubit-label drag. + interaction.selectedOperation = null; + interaction.selectedWire = 1; // q1 — no ops attached + interaction.dragging = true; + interaction.mouseUpOnCircuit = false; + + dispatchMouseUp(document); + + // q1 removed → only q0 remains. + assert.equal(model.qubits.length, 1); + assert.equal(model.qubits[0].id, 0); + // Render fired (from the doRemove fast-path inside the qubit controller). + assert.equal(renderCalls(), 1); + + dragController.dispose(); +}); + +test("drag-off with movingControl removes just the dragged control via removeControl (not the whole op)", () => { + // The movingControl branch of the document-mouseup drag-out path routes through + // `removeControl(selectedOperation, selectedWire)`, not `deleteOperationWithConfirmation`. The op + // stays in the grid with its `.controls` array shortened by one. The control on q1 is the one + // being dragged off. + const { model, interaction, dragController, renderCalls } = setup( + circuit(2, [[gate("H", 0, { ctrls: [1] })]]), + ); + + interaction.selectedOperation = at(model, "0,0"); + interaction.selectedWire = 1; // the control's wire + interaction.movingControl = true; + interaction.dragging = true; + interaction.mouseUpOnCircuit = false; + + dispatchMouseUp(document); + + // The H op is still there — only the control was removed. + assert.equal(model.componentGrid.length, 1); + const op = at(model, "0,0"); + assert.equal(op.gate, "H"); + // `removeControl` empties or nulls the controls array when the last control is removed; either is + // a "no controls" state. + const remainingControls = op.controls ?? []; + assert.equal( + remainingControls.length, + 0, + `expected zero remaining controls, got ${JSON.stringify(remainingControls)}`, + ); + // One render fired from the removeControl branch. + assert.equal(renderCalls(), 1); + + dragController.dispose(); +}); + +test("document mousedown clears wire dropzones in the SVG", () => { + // The wire-pick UIs (`startAddingControl`, qubit-label drag) drop `.dropzone-full-wire` rects + // into the SVG. The document-mousedown handler clears them so a click elsewhere dismisses the + // flow. + const { fixture, dragController } = setup(circuit(1, [])); + + // Inject two wire dropzones directly, mirroring what `createWireDropzone` produces. + const wireDz1 = document.createElementNS(SVG_NS, "rect"); + wireDz1.setAttribute("class", "dropzone-full-wire"); + fixture.svg.appendChild(wireDz1); + const wireDz2 = document.createElementNS(SVG_NS, "rect"); + wireDz2.setAttribute("class", "dropzone-full-wire"); + fixture.svg.appendChild(wireDz2); + // A regular `.dropzone` for contrast — must NOT be removed. + const regularDz = appendDropzone(fixture.dropzoneLayer, "0,0", 0); + + dispatchMouseDown(document); + + assert.equal( + fixture.svg.querySelectorAll(".dropzone-full-wire").length, + 0, + "wire dropzones must be cleared on document mousedown", + ); + // Regular dropzone left alone. + assert.ok(regularDz.parentNode, "regular .dropzone must remain attached"); + + dragController.dispose(); +}); diff --git a/source/npm/qsharp/test/circuit-editor/draggable.test.mjs b/source/npm/qsharp/test/circuit-editor/draggable.test.mjs new file mode 100644 index 00000000000..83cad29adc8 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/draggable.test.mjs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Pure-helper unit tests for the editor's draggable module (`ux/circuit-vis/editor/draggable.ts`). +// Locks down the geometry and DOM-attribute contracts of the three exported helpers that +// `dragController` and the rendering pipeline lean on: +// +// - `makeDropzoneBox`: inter-column vs on-column geometry, the trailing-append column past the +// rightmost real column, and the `data-dropzone-*` attribute set used by `findParentArray`. +// - `createWireDropzone`: full-width wire-spanning dropzone Y math, the `isBetween` cases that +// target the gaps before the first / after the last wire. +// - `removeAllWireDropzones`: targets `.dropzone-full-wire` only and leaves other overlay +// children alone. +// +// End-to-end behaviour through `draw()` is covered by `dropzones.test.mjs`. Helpers run in +// isolation against a hand-built `LayoutScope` / `wireData` so geometry assertions hold without +// pulling in the layout pass. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { + createWireDropzone, + makeDropzoneBox, + removeAllWireDropzones, +} from "../../dist/ux/circuit-vis/editor/draggable.js"; + +const documentTemplate = ` + + +`; + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(documentTemplate); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.Node = jsdom.window.Node; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +// Geometry constants — hand-mirrored from the product source so the assertions are +// self-documenting and catch the "someone tweaked a padding constant and didn't realize the editor +// math depended on it" regression. These are duplicated, not imported, on purpose: the test is the +// canary that fires when the source drifts. +// +// If a value below stops matching the source, do NOT just edit the number to make the test pass — +// that defeats the guard. Instead: +// 1. Find the source of truth for the constant: +// - GATE_PADDING / GATE_HEIGHT / MIN_GATE_WIDTH mirror the `gatePadding` / `gateHeight` / +// `minGateWidth` exports in `ux/circuit-vis/renderer/constants.ts`. +// - DROPZONE_PADDING_Y mirrors the private `DROPZONE_PADDING_Y` in +// `ux/circuit-vis/editor/draggable.ts`. +// - INTER_COLUMN_HALF_WIDTH / INTER_COLUMN_FULL_WIDTH / REGISTER_HEIGHT are DERIVED (see the +// formulas below); they mirror the same derivations in `draggable.ts`. Update the formula, +// not the literal, if the derivation itself changed. +// 2. Confirm the change to the source constant was intentional (and, for the base constants, that +// the CSS custom properties in `sqore.ts` were updated to match — the renderer reads several +// of these through CSS). +// 3. Update the mirrored value (or formula) here to match, and eyeball the dependent assertions +// in this file that hard-code the resulting pixel offsets. +const GATE_PADDING = 6; +const GATE_HEIGHT = 40; +const MIN_GATE_WIDTH = 40; +const INTER_COLUMN_HALF_WIDTH = GATE_PADDING * 2; // 12 +const INTER_COLUMN_FULL_WIDTH = INTER_COLUMN_HALF_WIDTH * 2; // 24 +const DROPZONE_PADDING_Y = 20; +const REGISTER_HEIGHT = GATE_HEIGHT + GATE_PADDING * 2; // 52 + +/** + * Build a `LayoutScope` with the given column starts/widths. Mirrors the shape + * `LayoutMap.scopes.get(prefix)` returns. + * + * @param {number[]} columnXOffsets + * @param {number[]} columnWidths + */ +function makeScope(columnXOffsets, columnWidths) { + return { columnXOffsets, columnWidths }; +} + +/** + * Read a numeric SVG attribute. Fails the test loudly if the attribute is missing — every helper + * here is expected to set the geometry attrs. + * + * @param {SVGElement} elem + * @param {string} name + */ +function attrNum(elem, name) { + const raw = elem.getAttribute(name); + assert.notEqual(raw, null, `expected attribute "${name}" to be set`); + return Number(raw); +} + +// ─── makeDropzoneBox ──────────────────────────────────────────────── + +test("makeDropzoneBox: inter-column band sits centered on the column's left edge", () => { + // Single column at x=100, width=60, single wire at y=200. Inter-column band straddles the gap to + // the *left* of this column, so its center is at colStartX - gatePadding (the renderer's + // between-columns midpoint), with half-width INTER_COLUMN_HALF_WIDTH. + const scope = makeScope([100], [60]); + const wireData = [200]; + + const dz = makeDropzoneBox( + { scope, wireData }, + { colIndex: 0, opIndex: 0, wireIndex: 0, interColumn: true }, + ); + + assert.equal(dz.getAttribute("class"), "dropzone"); + // Left edge = colStartX - INTER_COLUMN_HALF_WIDTH - gatePadding + // = 100 - 12 - 6 = 82 + assert.equal(attrNum(dz, "x"), 100 - INTER_COLUMN_HALF_WIDTH - GATE_PADDING); + assert.equal(attrNum(dz, "width"), INTER_COLUMN_FULL_WIDTH); + // Vertically padded around the wire Y. + assert.equal(attrNum(dz, "y"), 200 - DROPZONE_PADDING_Y); + assert.equal(attrNum(dz, "height"), DROPZONE_PADDING_Y * 2); +}); + +test("makeDropzoneBox: on-column box spans exactly the column's width", () => { + // Distinct columnWidths value so we can tell a column-width lookup apart from a fallback to + // `minGateWidth`. + const scope = makeScope([100, 200], [60, 90]); + const wireData = [200]; + + const dz = makeDropzoneBox( + { scope, wireData }, + { colIndex: 1, opIndex: 0, wireIndex: 0, interColumn: false }, + ); + + assert.equal(dz.getAttribute("class"), "dropzone"); + assert.equal(attrNum(dz, "x"), 200); + assert.equal(attrNum(dz, "width"), 90); + assert.equal(attrNum(dz, "y"), 200 - DROPZONE_PADDING_Y); + assert.equal(attrNum(dz, "height"), DROPZONE_PADDING_Y * 2); +}); + +test("makeDropzoneBox: trailing-append column synthesizes position past the rightmost real column", () => { + // Two real columns; ask for colIndex 2 (the trailing-append slot). Spacing rule: lastStart + + // lastWidth + gatePadding*2, width = minGateWidth. + const scope = makeScope([100, 200], [60, 90]); + const wireData = [200]; + + const dz = makeDropzoneBox( + { scope, wireData }, + { colIndex: 2, opIndex: 0, wireIndex: 0, interColumn: false }, + ); + + // Synthesized start: 200 + 90 + 12 = 302 + assert.equal(attrNum(dz, "x"), 200 + 90 + GATE_PADDING * 2); + assert.equal(attrNum(dz, "width"), MIN_GATE_WIDTH); +}); + +test("makeDropzoneBox: stamps data-dropzone-location, -wire, and -inter-column attrs", () => { + const scope = makeScope([100, 200], [60, 90]); + const wireData = [100, 200, 300]; + + const dz = makeDropzoneBox( + { scope, wireData }, + { colIndex: 1, opIndex: 2, wireIndex: 1, interColumn: true }, + ); + + // Top-level location → no prefix, format "col,op". + assert.equal(dz.getAttribute("data-dropzone-location"), "1,2"); + assert.equal(dz.getAttribute("data-dropzone-wire"), "1"); + assert.equal(dz.getAttribute("data-dropzone-inter-column"), "true"); +}); + +test("makeDropzoneBox: nested pathPrefix produces hierarchical location string", () => { + // pathPrefix `"0,0"` (children of the top-level op at column 0 / opIndex 0). The location's + // wire-format is `-,`, which `findParentArray` then walks back into the right + // `children` grid. + const scope = makeScope([100], [60]); + const wireData = [200]; + + const dz = makeDropzoneBox( + { scope, wireData, pathPrefix: "0,0" }, + { colIndex: 1, opIndex: 2, wireIndex: 0, interColumn: false }, + ); + + assert.equal(dz.getAttribute("data-dropzone-location"), "0,0-1,2"); + assert.equal(dz.getAttribute("data-dropzone-inter-column"), "false"); +}); + +// ─── createWireDropzone ───────────────────────────────────────────── + +/** Make an SVG element with a `width` attribute that mimics `svg.qviz`. */ +function makeSvg(width = 600) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("width", String(width)); + return /** @type {SVGElement} */ (svg); +} + +test("createWireDropzone: on-wire dropzone is centered on the wire Y and spans the full SVG width", () => { + const svg = makeSvg(800); + const wireData = [100, 200, 300]; + + const dz = createWireDropzone(svg, wireData, 1, /* isBetween */ false); + + assert.equal(dz.getAttribute("class"), "dropzone-full-wire"); + assert.equal(attrNum(dz, "x"), 0); + assert.equal(attrNum(dz, "width"), 800); + assert.equal(attrNum(dz, "y"), 200 - DROPZONE_PADDING_Y); + assert.equal(attrNum(dz, "height"), DROPZONE_PADDING_Y * 2); + assert.equal(dz.getAttribute("data-dropzone-wire"), "1"); +}); + +test("createWireDropzone: between-wires dropzone before the first wire offsets by half a register height", () => { + // isBetween + wireIndex=0 → Y centered at wireData[0] - registerHeight/2, i.e. midway between the + // (nonexistent) wire -1 and wire 0. + const svg = makeSvg(600); + const wireData = [100, 200]; + + const dz = createWireDropzone(svg, wireData, 0, /* isBetween */ true); + + const centerY = 100 - REGISTER_HEIGHT / 2; + assert.equal(attrNum(dz, "y"), centerY - DROPZONE_PADDING_Y); + assert.equal(dz.getAttribute("data-dropzone-wire"), "0"); +}); + +test("createWireDropzone: between-wires dropzone after the last wire is offset past the bottom", () => { + // isBetween + wireIndex == wireData.length → Y centered past the last wire (the "add a qubit + // below" affordance). + const svg = makeSvg(600); + const wireData = [100, 200]; + + const dz = createWireDropzone(svg, wireData, wireData.length, true); + + const centerY = 200 + REGISTER_HEIGHT / 2; + assert.equal(attrNum(dz, "y"), centerY - DROPZONE_PADDING_Y); + assert.equal(dz.getAttribute("data-dropzone-wire"), "2"); +}); + +// ─── removeAllWireDropzones ───────────────────────────────────────── + +test("removeAllWireDropzones: strips every .dropzone-full-wire and leaves other overlay children intact", () => { + // Mixed children: two wire dropzones and a regular `.dropzone` box (the kind `makeDropzoneBox` + // produces). Only the wire dropzones should be cleared. + const svg = makeSvg(600); + const wireData = [100, 200]; + + const wireDz1 = createWireDropzone(svg, wireData, 0, false); + const wireDz2 = createWireDropzone(svg, wireData, 1, false); + const onColumnDz = makeDropzoneBox( + { scope: makeScope([50], [40]), wireData }, + { colIndex: 0, opIndex: 0, wireIndex: 0, interColumn: false }, + ); + + svg.appendChild(wireDz1); + svg.appendChild(onColumnDz); + svg.appendChild(wireDz2); + + removeAllWireDropzones(svg); + + assert.equal(svg.querySelectorAll(".dropzone-full-wire").length, 0); + // The non-wire dropzone is untouched. + const remaining = svg.querySelectorAll(".dropzone"); + assert.equal(remaining.length, 1); + assert.equal(remaining[0], onColumnDz); +}); diff --git a/source/npm/qsharp/test/circuit-editor/dropzones.test.mjs b/source/npm/qsharp/test/circuit-editor/dropzones.test.mjs new file mode 100644 index 00000000000..619156e9469 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/dropzones.test.mjs @@ -0,0 +1,897 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Dropzone-layer tests: locks down the location strings emitted by the circuit editor's drop-target +// generator. Covers the drag/drop surface so positioning regressions don't sneak through. +// +// Tests render a small circuit through `draw()` with editor enabled, then inspect the resulting +// `g.dropzone-layer` for the set of `data-dropzone-location` attributes produced. We assert on +// location strings only — pixel positioning is a visual concern not covered here. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { draw } from "../../dist/ux/circuit-vis/index.js"; +import { Location } from "../../dist/ux/circuit-vis/data/location.js"; +import { circuit, gate, group } from "./_helpers.mjs"; + +const documentTemplate = ` + + +`; + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(documentTemplate); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.Node = jsdom.window.Node; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; + globalThis.XMLSerializer = jsdom.window.XMLSerializer; +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +// No-op editCallback — tests just need the editor branch to run so dropzones are created. Shared +// (read-only) across draw/Sqore calls. +const noopEditor = { editCallback: () => {} }; + +/** + * Create a fresh `.qs-circuit` container attached to the document body. `afterEach` tears down the + * whole JSDOM, so there's nothing to clean up per-container here. + * + * @returns {HTMLDivElement} + */ +function makeContainer() { + const container = document.createElement("div"); + container.className = "qs-circuit"; + document.body.appendChild(container); + return container; +} + +/** + * Scrape every dropzone descriptor out of an already-rendered container. + * + * @param {Element} container + * @returns {{ location: string; wire: number; interColumn: boolean }[]} + */ +function collectDropzones(container) { + const rects = container.querySelectorAll( + "g.dropzone-layer rect.dropzone[data-dropzone-location]", + ); + return Array.from(rects).map((rect) => ({ + location: rect.getAttribute("data-dropzone-location") ?? "", + wire: Number(rect.getAttribute("data-dropzone-wire") ?? "-1"), + interColumn: rect.getAttribute("data-dropzone-inter-column") === "true", + })); +} + +/** + * Render a CircuitGroup with the editor enabled (so `createDropzones` runs) and return the dropzone + * descriptors found in the resulting SVG. `renderDepth: 5` forces any group in the input to render + * expanded (not auto-collapsed). + * + * @param {import("../../dist/ux/circuit-vis/index.js").CircuitGroup} circuitGroup + * @returns {{ location: string; wire: number; interColumn: boolean }[]} + */ +function renderAndCollectDropzones(circuitGroup) { + const container = makeContainer(); + draw(circuitGroup, container, { editor: noopEditor, renderDepth: 5 }); + return collectDropzones(container); +} + +/** + * Filter dropzones — or any `{ location }` descriptors, e.g. rendered hosts — to those nested under + * `prefix`. The default `"0,0-"` matches the body of the single top-level `Foo` group used + * throughout. + * + * @template {{ location: string }} T + * @param {T[]} items + * @param {string} [prefix] + * @returns {T[]} + */ +function nestedUnder(items, prefix = "0,0-") { + return items.filter((d) => d.location.startsWith(prefix)); +} + +/** + * Filter dropzones to the trailing band at `location` — the `!interColumn` "append a new column + * here" rects. Works for both a group's inner band (`"0,0-1,0"`, the default) and the top-level + * band (`"1,0"`). + * + * @param {{ location: string; wire: number; interColumn: boolean }[]} dropzones + * @param {string} [location] + */ +function trailingBand(dropzones, location = "0,0-1,0") { + return dropzones.filter((d) => d.location === location && !d.interColumn); +} + +/** + * Render a CircuitGroup through `Sqore` directly (rather than `draw`) so the test can drive + * `renderCircuit` / `updateCircuit` and inspect `viewState`. Returns the live `Sqore` and its + * container. + * + * @param {import("../../dist/ux/circuit-vis/index.js").CircuitGroup} circuitGroup + */ +async function drawWithSqore(circuitGroup) { + const { Sqore } = await import("../../dist/ux/circuit-vis/sqore.js"); + const container = makeContainer(); + const sqore = new Sqore(circuitGroup, { editor: noopEditor }); + sqore.draw(container); + return { sqore, container }; +} + +/** + * Click the expand button on the group op rendered at `location`, simulating a user expanding a + * collapsed group. Asserts the gate and its expand button exist. Returns the gate element. + * + * @param {Element} container + * @param {string} location + */ +function clickExpandButton(container, location) { + const gate = container.querySelector(`[data-location="${location}"]`); + assert.ok(gate, `expected gate group at location ${location}`); + const expandButton = gate.querySelector(".gate-control.gate-expand"); + assert.ok( + expandButton, + `expected expand button on collapsed gate at ${location}`, + ); + // JSDOM's MouseEvent constructor lives on its window. + const win = /** @type {any} */ (container.ownerDocument.defaultView); + expandButton.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); + return gate; +} + +/** + * Assert no dropzone in `dropzones` lands on a wire at or below `boundary` — i.e. on a wire whose + * index is `>= boundary`. Wires are numbered top-to-bottom (wire 0 at the top), so a higher index + * sits lower on screen. This is the wire-extent clipping contract for nested bands. + * + * @param {{ wire: number }[]} dropzones + * @param {number} boundary + * @param {string} label + */ +function assertNoDropzoneAtOrBelowWire(dropzones, boundary, label) { + const leaked = dropzones.filter((d) => d.wire >= boundary); + assert.deepEqual(leaked, [], `${label}; leaked: ${JSON.stringify(leaked)}`); +} + +/** + * Build a minimal CircuitGroup wrapping a single Circuit. Keeps the test fixtures readable by + * hiding the boilerplate. + * + * @param {{ qubits: import("../../dist/ux/circuit-vis/index.js").Qubit[]; + * componentGrid: import("../../dist/ux/circuit-vis/index.js").ComponentGrid; }} circuitData + * @returns {import("../../dist/ux/circuit-vis/index.js").CircuitGroup} + */ +function singleCircuit(circuitData) { + return { + version: 1, + circuits: [circuitData], + }; +} + +// --------------------------------------------------------------------------- +// Baseline: flat circuits emit only top-level (single-segment) dropzone locations. +// --------------------------------------------------------------------------- + +test("flat circuit emits only top-level dropzones", () => { + // Two qubits, two columns: H on q0, then CNOT (control q0, target q1). No groups. + const cg = singleCircuit( + circuit(2, [[gate("H", 0)], [gate("X", 1, { ctrls: [0] })]]), + ); + + const dropzones = renderAndCollectDropzones(cg); + + // Every emitted dropzone should have a single-segment location (no `-`). + const nested = dropzones.filter((d) => d.location.includes("-")); + assert.deepEqual( + nested, + [], + `flat circuit should not emit nested-location dropzones, got: ${JSON.stringify(nested)}`, + ); + + // Sanity check: at least some top-level dropzones were produced. + assert.ok( + dropzones.length > 0, + "expected at least some dropzones to be produced for a non-empty circuit", + ); +}); + +// --------------------------------------------------------------------------- +// Expanded groups: dropzones inside the body carry nested location strings (the parent location +// followed by `-`). +// --------------------------------------------------------------------------- + +test("expanded group emits nested-location dropzones inside its body", () => { + // Custom gate `Foo` containing one nested `H`. Foo is marked expanded via `dataAttributes` so the + // renderer shows its body. + const cg = singleCircuit( + circuit(2, [ + [group("Foo", [[gate("H", 0)]], { expanded: true, span: [0, 1] })], + ]), + ); + + const dropzones = renderAndCollectDropzones(cg); + + // We expect at least one nested dropzone — one with a location string that starts with the + // parent's "0,0" prefix (the only top-level op). + const nested = nestedUnder(dropzones); + assert.ok( + nested.length > 0, + `expected nested dropzones inside expanded Foo group, got locations: ${JSON.stringify( + dropzones.map((d) => d.location), + )}`, + ); +}); + +// --------------------------------------------------------------------------- +// Wire-extent clipping: an expanded group that spans only some wires must not emit nested dropzones +// on wires outside its extent — the data model can't represent a drop into Foo on a wire Foo +// doesn't already cover without silently widening Foo's targets. +// --------------------------------------------------------------------------- + +test("nested dropzones are clipped to the group's wire extent", () => { + // 3 qubits. Foo only spans wires 0-1; wire 2 has its own X gate sitting alongside (so the + // renderer keeps wire 2 visible). + const cg = singleCircuit( + circuit(3, [ + [ + group("Foo", [[gate("H", 0)]], { expanded: true, span: [0, 1] }), + gate("X", 2), + ], + ]), + ); + + const dropzones = renderAndCollectDropzones(cg); + const nested = nestedUnder(dropzones); + + // Nested dropzones must exist — otherwise the clipping assertion below is vacuously true. + assert.ok( + nested.length > 0, + "expected some nested dropzones inside expanded Foo group", + ); + + // None of them may target wire 2 (outside Foo's [0, 1] extent). + assertNoDropzoneAtOrBelowWire( + nested, + 2, + "nested dropzones must be clipped to Foo's wire extent (wires 0-1)", + ); +}); + +// --------------------------------------------------------------------------- +// Nested dropzones must appear when a group is rendered expanded by the renderer (via `renderDepth` +// or expand-button click) even when the source op has no pre-baked `dataAttributes.expanded` flag. +// +// `Sqore.renderCircuit` deep-copies the circuit and runs `expandOperationsToDepth` / +// `expandIfSingleOperation` on the copy. The dropzone recursion iterates +// `sqore.circuit.componentGrid` (the original), so it must be driven by the LayoutMap (built from +// the copy), not by the source op's flag. +// --------------------------------------------------------------------------- + +test("nested dropzones appear when expansion is render-time only (not pre-baked)", () => { + // Foo has children but no `dataAttributes.expanded`. Render-time expansion comes from + // `renderDepth: 5` in the helper. + const cg = singleCircuit( + circuit(2, [[group("Foo", [[gate("H", 0)]], { span: [0, 1] })]]), + ); + + const dropzones = renderAndCollectDropzones(cg); + + const nested = nestedUnder(dropzones); + assert.ok( + nested.length > 0, + `expected nested dropzones inside render-time-expanded Foo group, got locations: ${JSON.stringify( + dropzones.map((d) => d.location), + )}`, + ); +}); + +// --------------------------------------------------------------------------- +// Persistent view state: a user-initiated expand (expand-button click) must survive subsequent +// re-renders, including those triggered by editor mutations. `ViewState` decouples view preferences +// from the saved circuit so they survive the per-render deep copy. +// --------------------------------------------------------------------------- + +test("user expand choice survives a re-render via ViewState", async () => { + // 2 columns prevents `expandIfSingleOperation` from auto-expanding; Foo starts collapsed. + const cg = singleCircuit( + circuit(3, [ + [group("Foo", [[gate("H", 0)]], { span: [0, 1] })], + [gate("X", 2)], + ]), + ); + + // Construct Sqore directly so the test can call renderCircuit to simulate an editor-mutation + // refresh. + const { sqore, container } = await drawWithSqore(cg); + + const collectNested = () => nestedUnder(collectDropzones(container)); + + // Sanity: Foo starts collapsed. + assert.equal( + collectNested().length, + 0, + "Foo should start collapsed (no auto-expand applies to a multi-column grid)", + ); + + // Find the expand button and click it. The Sqore handler writes to viewState and triggers a + // re-render. + clickExpandButton(container, "0,0"); + + // After click: Foo is expanded; nested dropzones appear. + assert.ok( + collectNested().length > 0, + "Foo should be expanded after expand-button click", + ); + + // Verify the user choice was recorded in viewState. + assert.equal( + sqore.viewState.expanded.get("0,0"), + true, + "viewState should record the user's expand choice", + ); + + // Simulate an editor-mutation refresh — the same path the editor controllers use after every + // Action. + /** @type {any} */ (sqore).renderCircuit(container); + + // ViewState's `applyTo` re-applies the user override across the deep-copy boundary, so Foo stays + // expanded. + assert.ok( + collectNested().length > 0, + "Foo's expand state must survive the editor-mutation re-render", + ); + assert.equal( + sqore.viewState.expanded.get("0,0"), + true, + "viewState entry must remain after re-render", + ); +}); + +// --------------------------------------------------------------------------- +// Drag-causes-shift integration test: dragging a gate (or any edit that splices a new column into +// the top-level grid) shifts an expanded group's location string (e.g. "1,0" → "2,0"). `Sqore` +// rebases viewState keys by object identity at the start of every render, so user expand/collapse +// choices follow their op across position shifts. +// --------------------------------------------------------------------------- + +test("user expand choice survives an upstream column-shift mutation", async () => { + // Top-level grid: [col 0: X on q2] [col 1: Foo group with H inside]. Foo lives at "1,0". The test + // will splice a new column at index 0, shifting Foo to "2,0". + const cg = singleCircuit( + circuit(3, [ + [gate("X", 2)], + [group("Foo", [[gate("H", 0)]], { span: [0, 1] })], + ]), + ); + + const { sqore, container } = await drawWithSqore(cg); + + // User expands Foo (at "1,0") via the expand button. + clickExpandButton(container, "1,0"); + assert.equal( + sqore.viewState.expanded.get("1,0"), + true, + "viewState should record the user's expand choice at 1,0", + ); + + // Simulate an editor mutation that inserts a new column at index 0 of the top-level grid. This is + // the canonical drag-out-of-group shape: a gate dropped into a fresh column ahead of the group + // pushes the group's column index from 1 to 2. + sqore.circuit.componentGrid.splice(0, 0, { + components: [ + { + kind: "unitary", + gate: "Y", + targets: [{ qubit: 0 }], + }, + ], + }); + + // Re-render via the same path the editor controllers use after every Action. + /** @type {any} */ (sqore).renderCircuit(container); + + // The identity-based rebase moves the viewState entry from "1,0" to "2,0" along with the op, so + // Foo stays expanded at its new location. + assert.equal( + sqore.viewState.expanded.has("1,0"), + false, + "stale viewState key at old location must be cleared", + ); + assert.equal( + sqore.viewState.expanded.get("2,0"), + true, + "viewState entry must follow the op to its new location", + ); + + // And the visible side of the contract: Foo is still drawn expanded. + const fooGateAfter = container.querySelector('[data-location="2,0"]'); + assert.ok(fooGateAfter, "expected Foo gate at new location 2,0"); + assert.equal( + fooGateAfter.getAttribute("data-expanded"), + "true", + "Foo must still render as expanded after the column shift", + ); +}); + +// --------------------------------------------------------------------------- +// External circuit update via `Sqore.updateCircuit`: models the VS Code undo/redo path. The host +// parses a new `CircuitGroup` from the reverted text and pushes it down. `updateCircuit` swaps the +// active circuit in place so the same `Sqore` (and therefore `viewState`) survives. +// --------------------------------------------------------------------------- + +test("user expand choice survives an external circuit update via updateCircuit", async () => { + const buildGroup = () => + singleCircuit( + circuit(3, [ + [group("Foo", [[gate("H", 0)]], { span: [0, 1] })], + [gate("X", 2)], + ]), + ); + + const { sqore, container } = await drawWithSqore(buildGroup()); + + const collectNested = () => nestedUnder(collectDropzones(container)); + + // Sanity: Foo starts collapsed. + assert.equal( + collectNested().length, + 0, + "Foo should start collapsed (no auto-expand applies to a multi-column grid)", + ); + + // User expands Foo via the expand button. + clickExpandButton(container, "0,0"); + assert.ok( + collectNested().length > 0, + "Foo should be expanded after expand-button click", + ); + + // Capture the SVG identity to verify `updateCircuit` does an in-place swap (`replaceChild`) + // rather than wiping the container. + const svgBefore = container.querySelector("svg.qviz"); + assert.ok(svgBefore, "expected an svg.qviz element to be rendered"); + + // Simulate the host pushing a new (logically equivalent) circuit down — the shape the VS Code + // editor would build after undo/redo or an external file edit. + sqore.updateCircuit(buildGroup()); + + // Foo's user expand choice must still apply to the new circuit. + assert.ok( + collectNested().length > 0, + "Foo must remain expanded after updateCircuit", + ); + assert.equal( + sqore.viewState.expanded.get("0,0"), + true, + "viewState entry must survive updateCircuit", + ); + + // The container itself is the same DOM node (no innerHTML wipe); the SVG was swapped in via + // replaceChild. + assert.ok( + container.querySelector("svg.qviz"), + "container must still contain an svg.qviz after updateCircuit", + ); +}); + +test("a fresh Sqore does not inherit a prior circuit's expand choice (non-editable host guard)", async () => { + // Circuit A: group `Foo` at "0,0". Circuit B: a DIFFERENT group `Bar` at the same "0,0". + const groupA = () => + singleCircuit( + circuit(3, [ + [group("Foo", [[gate("H", 0)]], { span: [0, 1] })], + [gate("X", 2)], + ]), + ); + const groupB = () => + singleCircuit( + circuit(3, [ + [group("Bar", [[gate("Y", 0)]], { span: [0, 1] })], + [gate("Z", 2)], + ]), + ); + + // Render A and expand Foo at "0,0". + const { container: containerA } = await drawWithSqore(groupA()); + const nestedA = () => nestedUnder(collectDropzones(containerA)); + clickExpandButton(containerA, "0,0"); + assert.ok(nestedA().length > 0, "Foo should be expanded after click"); + + // The non-editable host renders the unrelated circuit B through a brand-new `Sqore` (mirroring + // `circuit.tsx`'s fresh-`draw` path), so B starts from clean view state. + const { sqore: sqoreB, container: containerB } = + await drawWithSqore(groupB()); + const nestedB = () => nestedUnder(collectDropzones(containerB)); + + // Bar shares the "0,0" location with the previously-expanded Foo, but must render collapsed — the + // fresh instance carries none of A's view state. + assert.equal( + nestedB().length, + 0, + "Bar must render collapsed — a fresh Sqore inherits no view state from circuit A", + ); + assert.equal( + sqoreB.viewState.expanded.has("0,0"), + false, + "a fresh Sqore's viewState has no entry for the reused location", + ); +}); + +// --------------------------------------------------------------------------- +// Pixel-coordinate tests: for every rendered gate, the on-column dropzone with the matching +// `data-dropzone-location` must cover the gate's x range. Dropping a gate on top of an existing +// gate lands on a real dropzone. +// --------------------------------------------------------------------------- + +/** + * Render a CircuitGroup with the editor enabled and return both the rendered host elements (the + * gate boxes) and the produced dropzones, each annotated with bounding-box coordinates pulled from + * SVG attrs. + * + * @param {import("../../dist/ux/circuit-vis/index.js").CircuitGroup} circuitGroup + */ +function renderAndCollectGeometry(circuitGroup) { + const container = makeContainer(); + + draw(circuitGroup, container, { editor: noopEditor, renderDepth: 5 }); + + // The gate body box carries `data-width` and `x` in absolute SVG coords. `data-location` lives on + // a parent `` (set via dataAttributes spread). So: find every box, walk up to the + // closest `[data-location]`. Skip control circles / swap markers (those are sibling elements, not + // the canonical body). + const boxSelector = "[data-width][x]"; + const hosts = Array.from(container.querySelectorAll(boxSelector)) + .filter( + (el) => + !el.classList.contains("gate-control") && + !el.classList.contains("gate-swap"), + ) + .map((el) => { + const gateGroup = el.closest("[data-location]"); + return { + location: gateGroup?.getAttribute("data-location") ?? "", + x: Number(el.getAttribute("x") ?? "NaN"), + width: Number(el.getAttribute("data-width") ?? "NaN"), + }; + }) + .filter((h) => h.location !== ""); + + // Dropzones — every on-column rect (interColumn=false) carries a `data-dropzone-location` whose + // value matches a host's location. + const dzSelector = + "g.dropzone-layer rect.dropzone[data-dropzone-location][data-dropzone-inter-column='false']"; + const dropzones = Array.from(container.querySelectorAll(dzSelector)).map( + (el) => ({ + location: el.getAttribute("data-dropzone-location") ?? "", + x: Number(el.getAttribute("x") ?? "NaN"), + width: Number(el.getAttribute("width") ?? "NaN"), + wire: Number(el.getAttribute("data-dropzone-wire") ?? "-1"), + }), + ); + + return { hosts, dropzones }; +} + +/** + * For each rendered gate `host`, assert there is at least one on-column dropzone in the *same + * column* whose x-range overlaps the host's x-range. This is the geometry property that actually + * matters for the editor: dropping near a gate must hit a dropzone in that gate's column. + * + * Note: we deliberately match on the column (parent scope + colIndex) — NOT full location — because + * a column with N ops emits dropzones at opIndex `0..N` (slots above each op + the trailing slot). + * Every dropzone in that column has the same x/width as every other (they're all sized to the + * column), so any one of them is a valid coverage witness. + */ +function assertHostsCoveredByColumnDropzones( + /** @type {{location: string, x: number, width: number}[]} */ hosts, + /** @type {{location: string, x: number, width: number, wire: number}[]} */ dropzones, + /** @type {string} */ label, +) { + // Identify the column a location lives in: its parent scope plus the column index within that + // scope. Two locations share a column iff both fields match. + const columnOf = (/** @type {string} */ loc) => { + const parsed = Location.parse(loc); + const [colIndex] = parsed.last() ?? [0, 0]; + return { scope: parsed.parent().toString(), colIndex }; + }; + + for (const host of hosts) { + const hostCol = columnOf(host.location); + const sameCol = dropzones.filter((d) => { + const c = columnOf(d.location); + return c.scope === hostCol.scope && c.colIndex === hostCol.colIndex; + }); + assert.ok( + sameCol.length > 0, + `${label}: no on-column dropzone in scope "${hostCol.scope}" column ${hostCol.colIndex} for host ${host.location}`, + ); + + const hostLeft = host.x; + const hostRight = host.x + host.width; + const dzWitness = sameCol[0]; // all share the same x/width + const dzLeft = dzWitness.x; + const dzRight = dzWitness.x + dzWitness.width; + assert.ok( + dzRight >= hostLeft && dzLeft <= hostRight, + `${label}: scope "${hostCol.scope}" column ${hostCol.colIndex} dropzone (x=[${dzLeft}, ${dzRight}]) does not overlap host ${host.location} (x=[${hostLeft}, ${hostRight}])`, + ); + } +} + +test("flat circuit: every gate is covered by its on-column dropzone", () => { + // Two 1-qubit gates and one CNOT — three columns, all at top level. + const cg = singleCircuit( + circuit(2, [ + [gate("H", 0)], + [gate("T", 1)], + [gate("X", 1, { ctrls: [0] })], + ]), + ); + + const { hosts, dropzones } = renderAndCollectGeometry(cg); + + assert.ok(hosts.length > 0, "expected at least some host elements"); + assertHostsCoveredByColumnDropzones(hosts, dropzones, "flat circuit"); +}); + +test("expanded group: nested gates are covered by their on-column dropzones", () => { + // Nested gates emit dropzones with locations like `0,0-…`. Each must overlap the gate's x range. + const cg = singleCircuit( + circuit(2, [ + [group("Foo", [[gate("H", 0)], [gate("X", 1)]], { expanded: true })], + ]), + ); + + const { hosts, dropzones } = renderAndCollectGeometry(cg); + + // Filter to nested hosts — the gates inside Foo's body. Their location strings start with "0,0-". + const nestedHosts = nestedUnder(hosts); + assert.ok( + nestedHosts.length > 0, + `expected some nested host elements inside Foo, got hosts: ${JSON.stringify( + hosts.map((h) => h.location), + )}`, + ); + + assertHostsCoveredByColumnDropzones(nestedHosts, dropzones, "expanded group"); +}); + +// --------------------------------------------------------------------------- +// Editor overlay structure: all editor-only DOM (dropzones, ghost qubit row, future overlays) lives +// inside a single `g.editor-overlay` group attached to `svg.qviz`. The renderer never touches the +// overlay; the editor never appends outside it. +// --------------------------------------------------------------------------- + +test("editor-only DOM lives inside the editor-overlay group", () => { + const cg = singleCircuit( + circuit(2, [[gate("H", 0)], [gate("X", 1, { ctrls: [0] })]]), + ); + + const container = makeContainer(); + draw(cg, container, { editor: noopEditor }); + + const svg = container.querySelector("svg.qviz"); + assert.ok(svg, "expected an svg.qviz"); + + // Exactly one editor-overlay group, attached as a direct child of svg.qviz. + const overlays = svg.querySelectorAll("g.editor-overlay"); + assert.equal(overlays.length, 1, "expected exactly one editor-overlay"); + assert.equal( + overlays[0].parentElement, + svg, + "editor-overlay must be a direct child of svg.qviz", + ); + + // Both editor-only sub-layers must live inside the overlay. + const overlay = overlays[0]; + assert.equal( + overlay.querySelectorAll("g.dropzone-layer").length, + 1, + "dropzone-layer must live inside editor-overlay", + ); + assert.equal( + overlay.querySelectorAll("g.ghost-qubit-layer").length, + 1, + "ghost-qubit-layer must live inside editor-overlay", + ); + + // No editor-only layers may exist as direct children of svg.qviz outside the overlay. Walk svg's + // direct children and verify. + const directChildLayers = Array.from(svg.children).filter( + (el) => + el.classList.contains("dropzone-layer") || + el.classList.contains("ghost-qubit-layer"), + ); + assert.deepEqual( + directChildLayers, + [], + "editor-only layers must not be direct children of svg.qviz", + ); +}); + +test("trailing-append column lands past the rightmost gate", () => { + // Locks down the synthesized "past-end" position used for the append-new-column dropzones at top + // level. If makeDropzoneBox's out-of-bounds colIndex synthesis ever drifts, this catches it. + const cg = singleCircuit(circuit(2, [[gate("H", 0)], [gate("T", 1)]])); + + const { hosts, dropzones } = renderAndCollectGeometry(cg); + + // Locate the rightmost host (top-level only — nested gates would start with "0,0-" etc., so a + // simple substring filter excludes them). + const topLevelHosts = hosts.filter((h) => !h.location.includes("-")); + assert.ok(topLevelHosts.length > 0, "expected at least one top-level host"); + const rightmostHostRight = Math.max( + ...topLevelHosts.map((h) => h.x + h.width), + ); + + // Trailing-append dropzones are tagged inter-column='false' (they act as on-column drops for a + // brand-new column) but their location colIndex is one past the last actual column. Filter to + // those. + const lastTopLevelCol = Math.max( + ...topLevelHosts.map((h) => Number(h.location.split(",")[0])), + ); + const trailing = dropzones.filter((d) => { + const [colStr] = d.location.split(","); + return Number(colStr) === lastTopLevelCol + 1; + }); + assert.ok( + trailing.length > 0, + `expected trailing-append dropzones at colIndex ${lastTopLevelCol + 1}`, + ); + + // Every trailing dropzone must be centered past the rightmost gate — the band straddles the gap + // to a hypothetical next column, so its left edge sits roughly `gatePadding` inside the right + // edge of the last column, but its center (and most of its body) is past it. + for (const dz of trailing) { + const dzCenter = dz.x + dz.width / 2; + assert.ok( + dzCenter >= rightmostHostRight, + `trailing dropzone center x=${dzCenter} should be past rightmost gate edge ${rightmostHostRight}`, + ); + // And it should not extend so far left that it covers most of the last column — its left edge + // should be at or right of the column's midpoint. + const lastColMid = topLevelHosts.reduce( + (max, h) => Math.max(max, h.x + h.width / 2), + 0, + ); + assert.ok( + dz.x >= lastColMid, + `trailing dropzone left x=${dz.x} should not extend left of last column midpoint ${lastColMid}`, + ); + } +}); + +// --------------------------------------------------------------------------- +// Expanded groups: trailing inner-column dropzone band on the right edge of the group's body — the +// "extend the group sideways to swallow one more column" gesture. The dropzone's location string +// identifies the new column as belonging to the group's own scope. +// --------------------------------------------------------------------------- + +test("expanded group emits a trailing inner-column dropzone band on its right edge", () => { + // `Foo` spans wires 0-1 with two children in column 0. The trailing inner-column band sits at the + // synthesized past-end column index `1`, prefixed by Foo's location `0,0` — location string + // `0,0-1,0`. + const cg = singleCircuit( + circuit(2, [ + [group("Foo", [[gate("H", 0), gate("Y", 1)]], { expanded: true })], + ]), + ); + + const dropzones = renderAndCollectDropzones(cg); + + // Trailing inner-column dropzones are tagged `data-dropzone-inter-column="false"` (drop, don't + // insert-between) and live at colIndex == childrenColumnCount inside Foo's scope. + const innerTrailing = trailingBand(dropzones); + assert.equal( + innerTrailing.length, + 2, + `expected one trailing inner-column dropzone per wire Foo spans (2),` + + ` got locations: ${JSON.stringify( + nestedUnder(dropzones).map((d) => `${d.location}@w${d.wire}`), + )}`, + ); + + // Wires must be exactly Foo's span (0 and 1), no leakage to wire 2 or above (defensive — no wire + // 2 in this fixture, but the clamp contract should hold). + const wires = innerTrailing.map((d) => d.wire).sort(); + assert.deepEqual( + wires, + [0, 1], + "trailing inner-column dropzones must cover exactly Foo's wire span", + ); +}); + +test("trailing inner-column dropzones are clipped to the group's wire extent", () => { + // Foo spans wires 0-1 only; a sibling X on wire 2 keeps that wire visible. Foo's trailing + // inner-column band must not leak onto wire 2. + const cg = singleCircuit( + circuit(3, [ + [ + group("Foo", [[gate("H", 0)]], { expanded: true, span: [0, 1] }), + gate("X", 2), + ], + ]), + ); + + const dropzones = renderAndCollectDropzones(cg); + + // Inner-trailing band: `!interColumn` at location `0,0-1,0`. + const innerTrailing = trailingBand(dropzones); + + // Must exist — otherwise the clipping assertion below is vacuously true. + assert.ok( + innerTrailing.length > 0, + "expected trailing inner-column dropzones inside Foo to be emitted", + ); + + assertNoDropzoneAtOrBelowWire( + innerTrailing, + 2, + "trailing inner-column dropzones must be clipped to Foo's wire span", + ); +}); + +test("collapsed group emits no inner dropzones", () => { + // A collapsed group has no `LayoutMap` scope entry, so the dropzone recursion never enters it and + // the trailing-column helper never runs. No inner-scope dropzones should leak out. + // + // The sibling top-level op pins Foo collapsed: without it, `expandIfSingleOperation` would + // auto-expand Foo when it's the only op at the top level. + const cg = singleCircuit( + circuit(2, [ + [group("Foo", [[gate("H", 0)]], { span: [0, 1] })], + [gate("X", 0)], + ]), + ); + + // Render with renderDepth: 0 so Foo stays collapsed. We can't use the helper (it forces + // renderDepth: 5), so inline the draw call. + const container = makeContainer(); + draw(cg, container, { editor: noopEditor, renderDepth: 0 }); + + const nested = collectDropzones(container).filter((d) => + d.location.includes("-"), + ); + assert.deepEqual( + nested, + [], + `collapsed Foo should not emit nested-location dropzones (trailing` + + ` band included), got: ${JSON.stringify(nested.map((d) => d.location))}`, + ); +}); + +test("top-level trailing-column band is not clipped to any group's wire span", () => { + // The top-level trailing band must cover every wire, not just the wires of any group inside it. A + // wire-clamp regression would restrict the band to a subset of `[0, wireData.length)`. + const cg = singleCircuit( + circuit(3, [[gate("H", 0), gate("X", 1), gate("Y", 2)]]), + ); + + const dropzones = renderAndCollectDropzones(cg); + + // One column at top level, so trailing colIndex is 1. Location "1,0" is the trailing band's + // location (no prefix). The editor also renders a ghost-qubit row at wire index `wireData.length` + // for the add-a-qubit affordance, so the top-level trailing band covers more wires than the + // circuit declares. Assert wires {0, 1, 2} are present rather than nailing the exact count. + const topTrailing = trailingBand(dropzones, "1,0"); + const wires = new Set(topTrailing.map((d) => d.wire)); + for (const w of [0, 1, 2]) { + assert.ok( + wires.has(w), + `top-level trailing band must cover wire ${w}; got wires ${JSON.stringify( + [...wires].sort(), + )}`, + ); + } +}); diff --git a/source/npm/qsharp/test/circuit-editor/findOperation.test.mjs b/source/npm/qsharp/test/circuit-editor/findOperation.test.mjs new file mode 100644 index 00000000000..4306cdd85dd --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/findOperation.test.mjs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// findOperation / findParentArray / findParentOperation tests. +// +// These are the location-walking helpers in `ux/circuit-vis/utils.ts`. They share a single private +// bounds-checked walker, so the tests focus on the contract every public helper must honor: +// +// - null / empty location → null +// - in-bounds location → the addressed thing +// - out-of-bounds location (top-level or nested) → null, never throw +// +// "Out of bounds" matters in practice because event handlers read `data-location` attributes from +// the DOM, and those attributes can outlive the model state they were written against (re-render +// races, stale selection after an undo, hand-constructed locations, etc.). + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + findOperation, + findParentArray, + findParentOperation, +} from "../../dist/ux/circuit-vis/utils.js"; + +/** @typedef {import("../../dist/ux/circuit-vis/data/circuit.js").ComponentGrid} ComponentGrid */ + +/** + * Build a 1-column grid with a single H@0. + * @returns {ComponentGrid} + */ +function flatGrid() { + return [ + { + components: [{ kind: "unitary", gate: "H", targets: [{ qubit: 0 }] }], + }, + ]; +} + +/** + * Build a grid with one outer op (a "group") whose `children` is itself a 1-column grid containing + * an inner X@0. The outer op lives at "0,0"; the inner op lives at "0,0-0,0". + * @returns {ComponentGrid} + */ +function nestedGrid() { + return [ + { + components: [ + { + kind: "unitary", + gate: "Group", + targets: [{ qubit: 0 }], + children: [ + { + components: [ + { kind: "unitary", gate: "X", targets: [{ qubit: 0 }] }, + ], + }, + ], + }, + ], + }, + ]; +} + +// ---------- shared contract ---------- + +test("all three helpers return null for a null/empty location", () => { + const grid = nestedGrid(); + for (const loc of [null, ""]) { + assert.equal(findOperation(grid, loc), null); + assert.equal(findParentArray(grid, loc), null); + assert.equal(findParentOperation(grid, loc), null); + } +}); + +test("findOperation returns null for out-of-bounds op indices", () => { + const grid = nestedGrid(); + // Out-of-bounds top-level column / op, out-of-bounds nested column / op, and a path below a + // missing ancestor. + for (const loc of ["9,0", "0,9", "0,0-9,0", "0,0-0,9", "9,0-0,0"]) { + assert.equal(findOperation(grid, loc), null, loc); + } +}); + +test("findParentArray/findParentOperation return null when an ancestor is missing", () => { + const grid = nestedGrid(); + // The parent PATH is valid, so the containing grid is returned even when the addressed op index + // itself is out of bounds. + assert.ok(findParentArray(grid, "9,0")); // root grid + assert.ok(findParentArray(grid, "0,0-9,0")); // children grid + // An ancestor in the path is missing → null. + assert.equal(findParentArray(grid, "9,0-0,0"), null); + assert.equal(findParentOperation(grid, "9,0-0,0"), null); +}); + +// ---------- findOperation ---------- + +test("findOperation returns the op at an in-bounds top-level location", () => { + const grid = flatGrid(); + const op = findOperation(grid, "0,0"); + assert.ok(op); + assert.equal(op.gate, "H"); +}); + +test("findOperation returns the op at an in-bounds nested location", () => { + const grid = nestedGrid(); + const op = findOperation(grid, "0,0-0,0"); + assert.ok(op); + assert.equal(op.gate, "X"); +}); + +// ---------- findParentArray ---------- + +test("findParentArray returns the root grid for a top-level location", () => { + const grid = flatGrid(); + const parent = findParentArray(grid, "0,0"); + assert.equal(parent, grid); +}); + +test("findParentArray returns the children grid for a nested location", () => { + const grid = nestedGrid(); + const parent = findParentArray(grid, "0,0-0,0"); + assert.ok(parent); + // The children grid has exactly one column with the X gate. + assert.equal(parent.length, 1); + assert.equal(parent[0].components[0].gate, "X"); +}); + +// ---------- findParentOperation ---------- + +test("findParentOperation returns null at top level and the group at a nested location", () => { + const grid = nestedGrid(); + // Top-level locations have no parent op. + assert.equal(findParentOperation(grid, "0,0"), null); + // Nested locations return the immediate parent op. + const parent = findParentOperation(grid, "0,0-0,0"); + assert.ok(parent); + assert.equal(parent.gate, "Group"); +}); diff --git a/source/npm/qsharp/test/circuit-editor/gateFormatter.test.mjs b/source/npm/qsharp/test/circuit-editor/gateFormatter.test.mjs new file mode 100644 index 00000000000..b2802fd1201 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/gateFormatter.test.mjs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// gateFormatter unit tests — covers the pure-logic islands inside +// `renderer/formatters/gateFormatter.ts` that the snapshot suite catches only indirectly: +// +// - `_getQuantumControlYs`: routing for mixed classical+quantum control arrays. +// - `_zoomButton`: the expand/collapse decision tree and the classical-control x-offset +// alignment. +// - `_classicalControls`: marker emission for classical controls on groups. +// - `_createGate`: the `classically-controlled-group` CSS-class hook the editor relies on. +// +// The bulk of the formatter (SVG primitives, `_unitary`, `_swap`, `_oplus`) is covered by the +// snapshot suite in `test/circuits.js`; duplicating that here would just re-spell the +// implementation. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { + _createGate, + _zoomButton, + _classicalControls, + _getQuantumControlYs, +} from "../../dist/ux/circuit-vis/renderer/formatters/gateFormatter.js"; +import { GateType } from "../../dist/ux/circuit-vis/renderer/gateRenderData.js"; +import { controlCircleOffset } from "../../dist/ux/circuit-vis/renderer/constants.js"; + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +// --------------------------------------------------------------------------- +// Test fixture helper +// --------------------------------------------------------------------------- + +/** + * Build a minimal `GateRenderData` for tests. Defaults cover the fields every code path reads; + * overrides on top. + * + * @param {Partial} overrides + */ +function makeRenderData(overrides = {}) { + return { + type: GateType.Unitary, + isExpanded: false, + x: 100, + controlsY: [], + targetsY: [[40]], + label: "H", + width: 40, + topPadding: 0, + bottomPadding: 0, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// _getQuantumControlYs — pure-data filter (no JSDOM needed, but the `beforeEach` setup is harmless) +// --------------------------------------------------------------------------- + +test("_getQuantumControlYs: keeps every quantum entry (no classical ids)", () => { + // classicalControlIds omitted entirely, or present with every slot undefined — either way all + // controls are quantum. + assert.deepEqual( + _getQuantumControlYs(makeRenderData({ controlsY: [40, 80, 120] })), + [40, 80, 120], + ); + assert.deepEqual( + _getQuantumControlYs( + makeRenderData({ + controlsY: [40, 80], + classicalControlIds: [undefined, undefined], + }), + ), + [40, 80], + ); +}); + +test("_getQuantumControlYs: filters out every classical entry (numeric id)", () => { + const data = makeRenderData({ + controlsY: [40, 80, 120], + classicalControlIds: [0, 1, 2], + }); + + assert.deepEqual(_getQuantumControlYs(data), []); +}); + +test("_getQuantumControlYs: in mixed arrays keeps quantum, drops classical (numeric id or null)", () => { + // Only entries whose classicalControlIds slot is `undefined` are quantum. A numeric id is a + // resolved classical ref; `null` is an unresolved one. Both route through the classical render + // path (a quantum entry would draw a stray dot on the qubit wire). + assert.deepEqual( + _getQuantumControlYs( + makeRenderData({ + controlsY: [40, 120], + classicalControlIds: [0, undefined], + }), + ), + [120], + ); + assert.deepEqual( + _getQuantumControlYs( + makeRenderData({ + controlsY: [40, 120], + classicalControlIds: [null, undefined], + }), + ), + [120], + ); +}); + +// --------------------------------------------------------------------------- +// _zoomButton — expand/collapse decision tree + classical-control offset +// --------------------------------------------------------------------------- + +test("_zoomButton: collapsed group returns an expand button", () => { + const btn = _zoomButton( + makeRenderData({ type: GateType.Group, isExpanded: false }), + ); + + assert.notEqual(btn, null); + assert.equal(btn?.getAttribute("class"), "gate-control gate-expand"); + // Expand = plus sign = path with vertical and horizontal strokes. + const path = btn?.querySelector("path")?.getAttribute("d") ?? ""; + assert.match(path, /v14/); + assert.match(path, /h14/); +}); + +test("_zoomButton: expanded group returns a collapse button", () => { + const btn = _zoomButton( + makeRenderData({ type: GateType.Group, isExpanded: true }), + ); + + assert.notEqual(btn, null); + assert.equal(btn?.getAttribute("class"), "gate-control gate-collapse"); + // Collapse = minus sign = horizontal stroke only. + const path = btn?.querySelector("path")?.getAttribute("d") ?? ""; + assert.match(path, /h14/); + assert.doesNotMatch(path, /v14/); +}); + +test("_zoomButton: expanded non-group (Unitary) returns a collapse button", () => { + // The `expanded` branch fires for any op type, not just groups — expanded ControlledUnitary / + // extracted-gate bodies also render a collapse chevron. + const btn = _zoomButton( + makeRenderData({ type: GateType.Unitary, isExpanded: true }), + ); + + assert.notEqual(btn, null); + assert.equal(btn?.getAttribute("class"), "gate-control gate-collapse"); +}); + +test("_zoomButton: collapsed non-group returns null", () => { + // A plain non-group leaf has nothing to expand into, so no chevron is offered. + assert.equal( + _zoomButton(makeRenderData({ type: GateType.Unitary, isExpanded: false })), + null, + ); + assert.equal( + _zoomButton(makeRenderData({ type: GateType.X, isExpanded: false })), + null, + ); +}); + +test("_zoomButton: classical-control op shifts the button right by controlCircleOffset", () => { + // When an op carries classical controls, the bounding box extends LEFT to make room for the + // dashed control circles. The chevron must align with the gate body's left edge (where the dashed + // box draws), not the bounding box's left edge. + const baseline = _zoomButton( + makeRenderData({ + type: GateType.Group, + isExpanded: true, + }), + ); + + const withClassicalControls = _zoomButton( + makeRenderData({ + type: GateType.Group, + isExpanded: true, + controlsY: [200], + classicalControlIds: [0], + }), + ); + + const baselineCx = Number( + baseline?.querySelector("circle")?.getAttribute("cx"), + ); + const offsetCx = Number( + withClassicalControls?.querySelector("circle")?.getAttribute("cx"), + ); + + // The bounding-box's left edge sits at `centerX - width/2` in both cases; the offset case adds + // `controlCircleOffset` to nudge the chevron into the body's column. + assert.equal(offsetCx - baselineCx, controlCircleOffset); +}); + +// --------------------------------------------------------------------------- +// _classicalControls — emission count + filter + unresolved-id fallback +// --------------------------------------------------------------------------- + +test("_classicalControls: emits one circle + connector per classical entry", () => { + // Each classical entry emits a dashed circle, a vertical dashed line, and a horizontal dashed + // line — three elements. + const elems = _classicalControls( + 50, + makeRenderData({ + controlsY: [120, 200], + classicalControlIds: [0, 1], + }), + ); + + // _classicalControls pushes [horLine, vertLine, controlCircle] per entry, so 2 classical refs → 6 + // elements. + assert.equal(elems.length, 6); + + // Each control circle is a ``. + const btns = elems.filter( + (e) => e.getAttribute("class") === "classically-controlled-btn", + ); + assert.equal(btns.length, 2); +}); + +test("_classicalControls: skips undefined (quantum) entries in a mixed-control op", () => { + // Quantum entries (`undefined`) must NOT be drawn here — otherwise the qubit wire gets a stray + // dashed circle. + const elems = _classicalControls( + 50, + makeRenderData({ + controlsY: [120, 200, 280], + classicalControlIds: [0, undefined, 2], + }), + ); + + // Two classical entries → 6 elements; the undefined slot adds nothing. + assert.equal(elems.length, 6); + const btns = elems.filter( + (e) => e.getAttribute("class") === "classically-controlled-btn", + ); + assert.equal(btns.length, 2); +}); + +test("_classicalControls: renders null id (unresolved) without crashing", () => { + // `null` marks a classical ref whose global id couldn't be resolved (e.g. a `.qsc` file missing + // `controlResultIds` metadata). The render path still draws the dashed circle with a literal + // "null" subscript label — the user needs to see something on the control wire. + const elems = _classicalControls( + 50, + makeRenderData({ + controlsY: [120], + classicalControlIds: [null], + }), + ); + + assert.equal(elems.length, 3); + const btn = elems.find( + (e) => e.getAttribute("class") === "classically-controlled-btn", + ); + assert.notEqual(btn, undefined); + // The tspan child carries the id-or-"null" subscript inside the `c` label. + const tspan = btn?.querySelector("tspan"); + assert.equal(tspan?.textContent, "null"); +}); + +// --------------------------------------------------------------------------- +// _createGate — CSS-class hook for classically-controlled wrappers +// --------------------------------------------------------------------------- + +test("_createGate: toggles classically-controlled-group class on presence of classical controls", () => { + // The editor scopes CSS and selects wrappers via this class, so it must appear exactly when the + // op carries classical controls. + const withClassical = _createGate( + [], + makeRenderData({ + type: GateType.Group, + isExpanded: true, + controlsY: [200], + classicalControlIds: [0], + targetsY: [[60]], + topPadding: 30, + bottomPadding: 10, + }), + ); + assert.equal( + withClassical.classList.contains("classically-controlled-group"), + true, + ); + + const withoutClassical = _createGate( + [], + makeRenderData({ + type: GateType.Group, + isExpanded: true, + controlsY: [], + targetsY: [[60]], + topPadding: 30, + bottomPadding: 10, + }), + ); + assert.equal( + withoutClassical.classList.contains("classically-controlled-group"), + false, + ); +}); diff --git a/source/npm/qsharp/test/circuit-editor/interactionActions.test.mjs b/source/npm/qsharp/test/circuit-editor/interactionActions.test.mjs new file mode 100644 index 00000000000..bc3e3b0d500 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/interactionActions.test.mjs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// interactionActions tests — exercises the Action layer for the editor's ephemeral session state +// (`InteractionState`) directly, with **no JSDOM** for the pure-data helpers and a tiny stub +// `parentNode` for the one DOM-touching helper. Together with the `circuitActions` tests, this +// means the only editor logic that still needs JSDOM is the actual event-listener wiring in +// `CircuitEvents`. +// + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { InteractionState } from "../../dist/ux/circuit-vis/actions/interactionState.js"; +import { + beginToolboxDrag, + clearSelection, + clearTemporaryDropzones, + markDragging, + markMouseUpOnCircuit, + markMovingControl, + markSelected, + resetTransient, + trackTemporaryDropzone, +} from "../../dist/ux/circuit-vis/actions/interactionActions.js"; + +/** Minimal stub matching the shape `clearTemporaryDropzones` looks at. */ +function fakeDropzone() { + const removed = { value: false }; + const elem = /** @type {any} */ ({ + parentNode: { + removeChild: () => { + removed.value = true; + }, + }, + /** Test-only flag for assertions. */ + _removed: removed, + }); + return elem; +} + +test("resetTransient clears every transient flag but preserves selectedOperation", () => { + const s = new InteractionState(); + // Persistent — must survive the reset. + const op = /** @type {any} */ ({ kind: "unitary", gate: "H" }); + s.selectedOperation = op; + // Transient — must be cleared. + s.selectedWire = 2; + s.movingControl = true; + s.mouseUpOnCircuit = true; + s.dragging = true; + s.disableLeftAutoScroll = true; + s.temporaryDropzones.push(fakeDropzone()); + + resetTransient(s); + + // Persistent selection survives — that's the contract callers rely on so the context menu can + // still find its target after a reset. + assert.equal(s.selectedOperation, op); + // Everything else cleared. + assert.equal(s.selectedWire, null); + assert.equal(s.movingControl, false); + assert.equal(s.mouseUpOnCircuit, false); + assert.equal(s.dragging, false); + assert.equal(s.disableLeftAutoScroll, false); + assert.deepEqual(s.temporaryDropzones, []); +}); + +test("clearSelection drops only selectedOperation", () => { + const s = new InteractionState(); + s.selectedOperation = /** @type {any} */ ({ kind: "unitary" }); + s.selectedWire = 1; + s.dragging = true; + + clearSelection(s); + + assert.equal(s.selectedOperation, null); + // clearSelection is targeted; transient flags untouched. + assert.equal(s.selectedWire, 1); + assert.equal(s.dragging, true); +}); + +test("markSelected sets selectedOperation; null is allowed", () => { + const s = new InteractionState(); + const op = /** @type {any} */ ({ kind: "unitary", gate: "X" }); + + markSelected(s, op); + assert.equal(s.selectedOperation, op); + + markSelected(s, null); + assert.equal(s.selectedOperation, null); +}); + +test("beginToolboxDrag sets selection AND suppresses left auto-scroll", () => { + const s = new InteractionState(); + const template = /** @type {any} */ ({ kind: "unitary", gate: "T" }); + + beginToolboxDrag(s, template); + + assert.equal(s.selectedOperation, template); + // The whole point of the helper: these two have to move together, because forgetting the + // suppress-flag causes a runaway scroll bug. + assert.equal(s.disableLeftAutoScroll, true); +}); + +test("markMovingControl / markMouseUpOnCircuit / markDragging set their respective flags", () => { + const s = new InteractionState(); + + markMovingControl(s); + markMouseUpOnCircuit(s); + markDragging(s); + + assert.equal(s.movingControl, true); + assert.equal(s.mouseUpOnCircuit, true); + assert.equal(s.dragging, true); +}); + +// --------------------------------------------------------------------------- +// Temporary-dropzone bookkeeping +// --------------------------------------------------------------------------- + +test("trackTemporaryDropzone appends to the list without removing existing entries", () => { + const s = new InteractionState(); + const a = fakeDropzone(); + const b = fakeDropzone(); + + trackTemporaryDropzone(s, a); + trackTemporaryDropzone(s, b); + + assert.equal(s.temporaryDropzones.length, 2); + assert.equal(s.temporaryDropzones[0], a); + assert.equal(s.temporaryDropzones[1], b); +}); + +test("clearTemporaryDropzones removes each element from its parent and clears the list", () => { + const s = new InteractionState(); + const a = fakeDropzone(); + const b = fakeDropzone(); + trackTemporaryDropzone(s, a); + trackTemporaryDropzone(s, b); + + clearTemporaryDropzones(s); + + assert.equal(s.temporaryDropzones.length, 0); + assert.equal(a._removed.value, true); + assert.equal(b._removed.value, true); +}); + +test("clearTemporaryDropzones is safe on dropzones with no parentNode", () => { + const s = new InteractionState(); + // Simulates an element that's already been removed by something else — `clearTemporaryDropzones` + // must not throw on it. + s.temporaryDropzones.push(/** @type {any} */ ({ parentNode: null })); + + clearTemporaryDropzones(s); + + assert.equal(s.temporaryDropzones.length, 0); +}); diff --git a/source/npm/qsharp/test/circuit-editor/jsdom.d.ts b/source/npm/qsharp/test/circuit-editor/jsdom.d.ts new file mode 100644 index 00000000000..cf00406d335 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/jsdom.d.ts @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Minimal ambient declaration for the `jsdom` import used by the circuit-editor tests. +// `@types/jsdom` is not installed in this repo, so without this shim every test file that imports +// `JSDOM` would trigger `TS7016: Could not find a declaration file for module 'jsdom'` under +// `// @ts-check`. +// +// The tests only consult `new JSDOM(html).window` (and accessors on that window) and then close it. +// Typing those as `any` is intentional: the test bodies hand the JSDOM window into globals typed by +// the DOM lib, and we don't want every assignment to require a suppression. +declare module "jsdom" { + export class JSDOM { + constructor(html?: string, options?: unknown); + readonly window: any; + } +} diff --git a/source/npm/qsharp/test/circuit-editor/keyboardController.test.mjs b/source/npm/qsharp/test/circuit-editor/keyboardController.test.mjs new file mode 100644 index 00000000000..375d4849faa --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/keyboardController.test.mjs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// KeyboardController tests — exercises the smallest controller in isolation against a stub +// `InteractionContext`. Demonstrates the controller-layer testability: a controller can be +// instantiated against a hand-rolled context and asserted directly, without spinning up the full +// editor (no Sqore, no LayoutMap, no actual circuit). +// +// JSDOM is required because the controller installs document-level keydown/keyup listeners. Other +// dependencies (`model`, `layoutMap`, `circuitSvg`, etc.) are unused by this controller and stubbed +// minimally. +// + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { InteractionState } from "../../dist/ux/circuit-vis/actions/interactionState.js"; +import { KeyboardController } from "../../dist/ux/circuit-vis/editor/controllers/keyboardController.js"; + +/** @type {JSDOM | null} */ +let jsdom = null; +/** @type {KeyboardController | null} */ +let controller = null; + +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.KeyboardEvent = jsdom.window.KeyboardEvent; +}); + +afterEach(() => { + controller?.dispose(); + controller = null; + jsdom = null; +}); + +/** Build a KeyboardController with a minimal stub context. */ +const makeController = (interaction = new InteractionState()) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const ctx = { + model: /** @type {any} */ ({}), + interaction, + layoutMap: /** @type {any} */ ({}), + container, + circuitSvg: /** @type {any} */ ({}), + dropzoneLayer: /** @type {any} */ ({}), + ghostQubitLayer: /** @type {any} */ ({}), + wireData: [], + renderFn: () => {}, + }; + controller = new KeyboardController(/** @type {any} */ (ctx)); + return { ctx, container }; +}; + +const dispatchCtrlKey = (/** @type {string} */ type) => { + document.dispatchEvent( + new KeyboardEvent(type, { ctrlKey: true, bubbles: true }), + ); +}; + +const dispatchPlainKey = (/** @type {string} */ type) => { + document.dispatchEvent( + new KeyboardEvent(type, { ctrlKey: false, bubbles: true }), + ); +}; + +/** + * A placed gate: `dataAttributes.location` is what getGateLocationString reads to distinguish a + * real placement from a toolbox drag. + * + * @param {string} [location] + */ +const placedGate = (location = "0,1") => ({ + kind: "unitary", + dataAttributes: { location }, +}); + +/** + * A toolbox prototype: a selectedOperation with no location yet (it gets one once dropped). + */ +const toolboxProto = () => ({ kind: "unitary" }); + +/** + * Build an InteractionState whose `selectedOperation` is `op`. + * + * @param {any} op + */ +const withSelection = (op) => { + const interaction = new InteractionState(); + interaction.selectedOperation = /** @type {any} */ (op); + return interaction; +}; + +test("Ctrl-down with no selection is a no-op", () => { + const { container } = makeController(); + // No selectedOperation set → no class change. + dispatchCtrlKey("keydown"); + assert.equal(container.classList.contains("copying"), false); + assert.equal(container.classList.contains("moving"), false); +}); + +test("Ctrl-down on a placed gate switches moving → copying", () => { + const { container } = makeController(withSelection(placedGate())); + container.classList.add("moving"); + + dispatchCtrlKey("keydown"); + + assert.equal(container.classList.contains("moving"), false); + assert.equal(container.classList.contains("copying"), true); +}); + +test("Ctrl-up flips copying → moving", () => { + const { container } = makeController(withSelection(placedGate())); + container.classList.add("copying"); + + dispatchCtrlKey("keyup"); + + assert.equal(container.classList.contains("copying"), false); + assert.equal(container.classList.contains("moving"), true); +}); + +test("Non-Ctrl keys are ignored", () => { + const { container } = makeController(withSelection(placedGate())); + container.classList.add("moving"); + + dispatchPlainKey("keydown"); + dispatchPlainKey("keyup"); + + // Neither class was added/removed. + assert.equal(container.classList.contains("moving"), true); + assert.equal(container.classList.contains("copying"), false); +}); + +test("Toolbox-drag (op without location) is treated as no-selection", () => { + const { container } = makeController(withSelection(toolboxProto())); + + dispatchCtrlKey("keydown"); + + assert.equal(container.classList.contains("copying"), false); +}); + +test("dispose() removes document listeners", () => { + const { container } = makeController(withSelection(placedGate())); + container.classList.add("moving"); + + controller?.dispose(); + controller = null; + + dispatchCtrlKey("keydown"); + // No state change after dispose. + assert.equal(container.classList.contains("copying"), false); + assert.equal(container.classList.contains("moving"), true); +}); diff --git a/source/npm/qsharp/test/circuit-editor/location.test.mjs b/source/npm/qsharp/test/circuit-editor/location.test.mjs new file mode 100644 index 00000000000..b3aaec21ff5 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/location.test.mjs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Location value-type tests — exercises the immutable `Location` value type that owns +// hierarchical-address parse/compose for the circuit editor. Pure-data, no JSDOM. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { Location } from "../../dist/ux/circuit-vis/data/location.js"; + +test("Location.root() exposes the root invariants", () => { + const root = Location.root(); + assert.equal(root.isRoot, true); + assert.equal(root.depth, 0); + assert.equal(root.toString(), ""); + assert.equal(root.last(), null); + // parse("") resolves to the same empty address. + assert.equal(Location.parse("").equals(root), true); +}); + +test("Location.parse yields correctly-structured addresses and round-trips", () => { + const one = Location.parse("0,1"); + assert.equal(one.depth, 1); + assert.equal(one.isRoot, false); + assert.deepEqual(one.last(), [0, 1]); + + const two = Location.parse("0,1-2,3"); + assert.equal(two.depth, 2); + assert.deepEqual(two.last(), [2, 3]); + + for (const s of ["", "0,0", "5,7", "0,1-2,3", "1,2-3,4-5,6"]) { + assert.equal(Location.parse(s).toString(), s); + } +}); + +test("Location.parse throws on malformed input", () => { + for (const bad of [ + "abc", + "1", + "1,2,3", + "1,", + ",1", + "1,2-", + "-1,2", + "1,2--3,4", + ]) { + assert.throws(() => Location.parse(bad), /Invalid location/, bad); + } +}); + +test("Location.parent drops the last segment and saturates at root", () => { + assert.equal(Location.root().parent().toString(), ""); + assert.equal(Location.parse("0,1").parent().toString(), ""); + assert.equal(Location.parse("0,1-2,3").parent().toString(), "0,1"); + assert.equal(Location.parse("1,2-3,4-5,6").parent().toString(), "1,2-3,4"); +}); + +test("Location.child appends a segment", () => { + assert.equal(Location.root().child(0, 1).toString(), "0,1"); + assert.equal(Location.parse("0,0").child(1, 2).toString(), "0,0-1,2"); + assert.equal(Location.parse("0,1-2,3").child(4, 5).toString(), "0,1-2,3-4,5"); +}); + +test("Location.child + parent round-trips", () => { + const base = Location.parse("0,1-2,3"); + assert.equal(base.child(4, 5).parent().toString(), base.toString()); +}); + +test("Location.equals compares by structural value", () => { + assert.equal(Location.parse("0,1").equals(Location.parse("0,1")), true); + assert.equal(Location.parse("0,1").equals(Location.parse("0,2")), false); + assert.equal(Location.parse("0,1").equals(Location.parse("0,1-2,3")), false); + assert.equal(Location.root().equals(Location.parse("")), true); + assert.equal(Location.root().equals(Location.parse("0,1")), false); +}); + +test("Location.of(...) matches Location.parse", () => { + assert.equal(Location.of().toString(), ""); + assert.equal(Location.of([0, 1]).toString(), "0,1"); + assert.equal(Location.of([0, 1], [2, 3]).toString(), "0,1-2,3"); +}); diff --git a/source/npm/qsharp/test/circuit-editor/prompts.test.mjs b/source/npm/qsharp/test/circuit-editor/prompts.test.mjs new file mode 100644 index 00000000000..a3f171ab568 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/prompts.test.mjs @@ -0,0 +1,580 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// prompts tests — covers the confirm-dialog side of `editor/prompts.ts`: the generic confirm-dialog +// primitive `createConfirmPrompt`, plus the operation-specific delete/move flows built on it. The +// text-input primitive `_createInputPrompt` and the `promptForArguments` flow also live in that +// file but are not yet unit-tested. +// +// `createConfirmPrompt`: +// - DOM shape: `.prompt-overlay > .prompt-container > .prompt-message + .prompt-buttons > [OK, +// Cancel]`. The widget classes are load-bearing — the host page's CSS styles by them and the +// operation-flow tests locate buttons by them. +// - Click semantics: OK → `callback(true)` + overlay removed; Cancel → `callback(false)` + +// overlay removed. +// - Keyboard semantics: Enter → OK, Escape → Cancel, wired through a document-level capture-phase +// keydown listener so the prompt wins over any descendant input handler. +// - Listener lifecycle: the keydown listener is removed when the prompt closes (clicking OK or +// Cancel — including via Enter or Escape), so a subsequent key press doesn't re-invoke the +// callback. +// +// `deleteOperationWithConfirmation`: fast paths (non-M, M with no classical consumers) skip the +// prompt and mutate + render immediately; the M-with-consumers path opens a confirm dialog whose +// message singularizes / pluralizes the consumer count, and only commits the cascade on OK. +// +// `moveOperationWithConfirmation`: same fast-path / prompt split, plus the three message-shape +// branches in `_buildMoveMConsumerMessage` (pure survivors, pure invalidated, mixed). +// `movingControl` is threaded through to `moveOperation` unchanged on the fast path. +// +// Tests run under JSDOM and drive the dialog by querying for `.prompt-button` elements. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { + createConfirmPrompt, + deleteOperationWithConfirmation, + moveOperationWithConfirmation, +} from "../../dist/ux/circuit-vis/editor/prompts.js"; +import { at, build, circuit, gate, meas, qubits } from "./_helpers.mjs"; + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.KeyboardEvent = jsdom.window.KeyboardEvent; +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +/** Locate the open prompt's structural pieces by class. */ +function getPrompt() { + const overlay = /** @type {HTMLElement | null} */ ( + document.querySelector(".prompt-overlay") + ); + if (!overlay) return null; + const container = overlay.querySelector(".prompt-container"); + const message = overlay.querySelector(".prompt-message"); + const buttons = overlay.querySelectorAll(".prompt-button"); + return { + overlay, + container, + message, + okButton: /** @type {HTMLButtonElement} */ (buttons[0]), + cancelButton: /** @type {HTMLButtonElement} */ (buttons[1]), + }; +} + +/** + * Open a confirm prompt over the current document and return a handle to the located DOM parts plus + * two accessors: + * - `result()` — the value the callback last received (null until fired) + * - `callCount()` — how many times the callback has fired + */ +function openPrompt(message = "ok?") { + /** @type {boolean | null} */ + let captured = null; + let callCount = 0; + createConfirmPrompt(message, (c) => { + captured = c; + callCount++; + }); + const parts = getPrompt(); + assert.ok(parts, "prompt overlay should be open after createConfirmPrompt"); + return { ...parts, result: () => captured, callCount: () => callCount }; +} + +/** Dispatch a document-level keydown — the path the prompt listens on. */ +function pressKey(/** @type {string} */ key) { + document.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); +} + +/** Assert the prompt overlay has been removed from the DOM. */ +function assertPromptClosed(label = "prompt overlay must be removed") { + assert.equal(document.querySelector(".prompt-overlay"), null, label); +} + +/** Assert the prompt overlay is still present in the DOM. */ +function assertPromptOpen(label = "prompt overlay must still be open") { + assert.ok(document.querySelector(".prompt-overlay"), label); +} + +test("createConfirmPrompt: builds the expected DOM subtree under document.body", () => { + // Pinning the DOM shape because both the host page's CSS and the delete/move flow tests below + // rely on these specific class names and the button ordering (OK first, Cancel second). + const p = openPrompt("Confirm something?"); + + assert.equal(p.overlay.parentNode, document.body); + assert.ok(p.container, "container should exist inside overlay"); + assert.equal( + p.message?.textContent, + "Confirm something?", + "message element should carry the caller's text verbatim", + ); + assert.equal(p.okButton.textContent, "OK"); + assert.equal(p.cancelButton.textContent, "Cancel"); + // Callback shouldn't have fired yet — just the construction. + assert.equal(p.result(), null); +}); + +test("createConfirmPrompt: OK button click fires callback(true) and removes the overlay", () => { + const p = openPrompt(); + p.okButton.click(); + + assert.equal(p.result(), true, "OK click must pass true to callback"); + assertPromptClosed("overlay must be removed from the DOM after OK"); +}); + +test("createConfirmPrompt: Cancel button click fires callback(false) and removes the overlay", () => { + const p = openPrompt(); + p.cancelButton.click(); + + assert.equal(p.result(), false, "Cancel click must pass false to callback"); + assertPromptClosed("overlay must be removed from the DOM after Cancel"); +}); + +test("createConfirmPrompt: Enter key commits as if OK was clicked", () => { + // The document-level keydown listener is registered in capture phase, so dispatching a `keydown` + // from `document` directly exercises the same path real key events take in the browser. + const p = openPrompt(); + pressKey("Enter"); + + assert.equal(p.result(), true, "Enter must commit (callback(true))"); + assertPromptClosed("Enter must close the prompt"); +}); + +test("createConfirmPrompt: Escape key cancels as if Cancel was clicked", () => { + const p = openPrompt(); + pressKey("Escape"); + + assert.equal(p.result(), false, "Escape must cancel (callback(false))"); + assertPromptClosed("Escape must close the prompt"); +}); + +test("createConfirmPrompt: keydown listener is removed after close — subsequent keys do not fire callback again", () => { + // After OK closes the prompt, the document-level handler MUST be uninstalled — otherwise a stray + // Enter elsewhere on the page would try to click a now-detached button and (worse) could + // double-fire the callback if a second prompt has since opened. The implementation uses + // `removeEventListener` with matching capture flag inside both click handlers; here we pin that + // contract. + const p = openPrompt(); + + // First Enter → OK → callback fires once, prompt closes. + pressKey("Enter"); + assert.equal(p.callCount(), 1); + assertPromptClosed(); + + // Subsequent Enter must NOT fire the now-closed prompt's callback again. + pressKey("Enter"); + assert.equal( + p.callCount(), + 1, + "callback must NOT fire after the prompt is closed", + ); +}); + +test("createConfirmPrompt: keys other than Enter/Escape are ignored", () => { + // Defense-in-depth: typing inside the prompt (e.g. someone accidentally hitting a letter key) + // must not close it. Only Enter and Escape are honored. + const p = openPrompt(); + + pressKey("a"); + pressKey(" "); + pressKey("Tab"); + + assert.equal( + p.result(), + null, + "callback must not fire for non-Enter/Escape keys", + ); + assertPromptOpen("prompt must still be open after stray keypresses"); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Operation flows — deleteOperationWithConfirmation / moveOperationWithConfirmation +// ═══════════════════════════════════════════════════════════════════ + +/** + * Query the currently-rendered confirm prompt. Returns null when none is open. The first button is + * OK, the second is Cancel. + */ +function getOpenPrompt() { + const overlay = document.querySelector(".prompt-overlay"); + if (!overlay) return null; + const messageElem = overlay.querySelector(".prompt-message"); + const buttons = overlay.querySelectorAll(".prompt-button"); + return { + overlay, + message: messageElem?.textContent ?? "", + okButton: /** @type {HTMLButtonElement} */ (buttons[0]), + cancelButton: /** @type {HTMLButtonElement} */ (buttons[1]), + }; +} + +/** Make a render-callback spy that counts invocations. */ +function makeRenderSpy() { + const spy = /** @type {{ count: number; fn: () => void }} */ ({ count: 0 }); + spy.fn = () => { + spy.count++; + }; + return spy; +} + +/** + * A unitary classically controlled by the measurement at "0,0" (result register `(qubit 0, result + * 0)`). Every consumer in these tests reads that same register, so this captures the shared shape. + * + * @param {string} name gate name + * @param {number} target target wire + */ +const consumer = (name, target) => gate(name, target, { ctrls: [{ q: 0 }] }); + +/** + * Thin wrapper over `moveOperationWithConfirmation` that names its positional argument soup. + * Defaults cover the common case (wires unchanged, not moving a control, no new column). + * + * @param {any} model + * @param {{ from: string, to: string, fromWire?: number, toWire?: number, movingControl?: boolean, + * insertNewColumn?: boolean }} opts + * @param {() => void} renderFn + */ +function moveWithConfirm(model, opts, renderFn) { + moveOperationWithConfirmation( + model, + opts.from, + opts.to, + opts.fromWire ?? 0, + opts.toWire ?? 0, + opts.movingControl ?? false, + opts.insertNewColumn ?? false, + renderFn, + ); +} + +/** Serialize a model's grid + qubits for byte-for-byte equality checks. */ +function snapshot(/** @type {any} */ model) { + return JSON.stringify({ grid: model.componentGrid, qubits: model.qubits }); +} + +/** Flatten every op across all columns into a single array. */ +function flattenOps(/** @type {any} */ model) { + const ops = []; + for (const col of model.componentGrid) { + for (const op of col.components) ops.push(op); + } + return ops; +} + +// --------------------------------------------------------------------------- +// deleteOperationWithConfirmation +// --------------------------------------------------------------------------- + +test("deleteOperationWithConfirmation: non-measurement op deletes immediately, no prompt", () => { + // Fast path: any non-M op bypasses the consumer-collection branch and dispatches straight to + // `removeOperation` + `renderFn`. + const model = build(circuit(1, [[gate("H", 0)]])); + const render = makeRenderSpy(); + + deleteOperationWithConfirmation(model, "0,0", render.fn); + + assert.equal(getOpenPrompt(), null, "no confirm prompt should be opened"); + assert.equal( + model.componentGrid.length, + 0, + "the H should have been removed and the empty column collapsed", + ); + assert.equal(render.count, 1, "renderFn must run exactly once on success"); +}); + +test("deleteOperationWithConfirmation: measurement with NO classical consumers deletes immediately", () => { + // Second fast path: an M whose `collectMeasurementConsumers` returns `[]` (no consumer reads its + // result) also skips the prompt. + const model = build(circuit(qubits(1, { 0: 1 }), [[meas(0)]])); + const render = makeRenderSpy(); + + deleteOperationWithConfirmation(model, "0,0", render.fn); + + assert.equal(getOpenPrompt(), null, "no prompt for an unread measurement"); + assert.equal(model.componentGrid.length, 0, "M should be removed"); + assert.equal(render.count, 1); +}); + +test("deleteOperationWithConfirmation: M with 1 consumer opens a SINGULAR prompt; OK cascades", () => { + // M produces (qubit=0, result=0); one classically-controlled X consumes it. Message must use the + // singular form; OK must cascade both ops away. + const model = build( + circuit(qubits(2, { 0: 1 }), [[meas(0)], [consumer("X", 1)]]), + ); + const render = makeRenderSpy(); + + deleteOperationWithConfirmation(model, "0,0", render.fn); + + const prompt = getOpenPrompt(); + assert.ok(prompt, "prompt should be open"); + assert.match( + prompt.message, + /1 dependent operation that references/, + "message must use the singular 'operation' form", + ); + assert.equal( + render.count, + 0, + "renderFn must NOT run until the user confirms", + ); + + prompt.okButton.click(); + + assert.equal(getOpenPrompt(), null, "prompt should close on OK"); + assert.equal( + model.componentGrid.length, + 0, + "both the M and its consumer should be cascade-deleted", + ); + assert.equal(render.count, 1, "renderFn fires exactly once after cascade"); +}); + +test("deleteOperationWithConfirmation: M with 3 consumers opens a PLURAL prompt", () => { + // Pluralization branch: three consumers reading the same (qubit=0, result=0) register. OK-cascade + // behavior matches the singular case; this test asserts only on the message form. + const model = build( + circuit(qubits(4, { 0: 1 }), [ + [meas(0)], + [consumer("X", 1), consumer("Y", 2), consumer("Z", 3)], + ]), + ); + const render = makeRenderSpy(); + + deleteOperationWithConfirmation(model, "0,0", render.fn); + + const prompt = getOpenPrompt(); + assert.ok(prompt); + assert.match( + prompt.message, + /3 dependent operations that reference/, + "message must use the plural 'operations' form and the literal count", + ); +}); + +test("deleteOperationWithConfirmation: M-with-consumers Cancel makes NO mutations and does NOT render", () => { + // Pins the cancel path: model state byte-for-byte identical before and after, and `renderFn` was + // never called. + const model = build( + circuit(qubits(2, { 0: 1 }), [[meas(0)], [consumer("X", 1)]]), + ); + const beforeJSON = snapshot(model); + const render = makeRenderSpy(); + + deleteOperationWithConfirmation(model, "0,0", render.fn); + + const prompt = getOpenPrompt(); + assert.ok(prompt); + prompt.cancelButton.click(); + + assert.equal(getOpenPrompt(), null, "prompt should close on Cancel"); + assert.equal(render.count, 0, "Cancel must NOT trigger a re-render"); + assert.equal( + snapshot(model), + beforeJSON, + "model must be unchanged after Cancel", + ); +}); + +// --------------------------------------------------------------------------- +// moveOperationWithConfirmation +// --------------------------------------------------------------------------- + +test("moveOperationWithConfirmation: non-measurement op moves immediately, no prompt", () => { + // Fast path: ordinary unitary, no consumers to consider. The wrapper passes through to + // `moveOperation` with `movingControl` threaded as-is. + const model = build(circuit(2, [[gate("H", 0)], [gate("X", 1)]])); + const render = makeRenderSpy(); + + // Swap H from wire 0 → wire 1 (no consumers involved). + moveWithConfirm(model, { from: "0,0", to: "0,0", toWire: 1 }, render.fn); + + assert.equal(getOpenPrompt(), null, "no prompt for a non-M move"); + // H landed on wire 1; X is still in column 1 (no insertNewColumn). + const movedH = at(model, "0,0"); + assert.equal(movedH.gate, "H"); + assert.equal(movedH.targets[0].qubit, 1); + assert.equal(render.count, 1); +}); + +test("moveOperationWithConfirmation: M with NO consumers moves immediately, no prompt", () => { + // Second fast path: an M with no classical consumers can move freely. Same passthrough as the + // non-M case. + const model = build( + circuit(qubits(2, { 0: 1 }), [[meas(0)], [gate("H", 1)]]), + ); + const render = makeRenderSpy(); + + // Move M to column 1 (it'd swap with H there); no consumers, no prompt. + moveWithConfirm(model, { from: "0,0", to: "1,0" }, render.fn); + + assert.equal(getOpenPrompt(), null); + assert.equal(render.count, 1); +}); + +test("moveOperationWithConfirmation: M with pure-SURVIVORS consumers shows the update-only message", () => { + // Survivors-only partition: target column < every consumer's column. The M moves forward (or + // stays) so every consumer still comes after it; nothing gets deleted. + const model = build( + circuit(qubits(3, { 0: 1 }), [ + [meas(0)], // column 0: the M + [consumer("X", 1)], // column 1: a consumer + [consumer("Y", 2)], // column 2: another consumer + ]), + ); + const render = makeRenderSpy(); + + // Move the M to column 0 (its current spot) — still strictly before columns 1 and 2. Both + // consumers partition into survivors. + moveWithConfirm(model, { from: "0,0", to: "0,0" }, render.fn); + + const prompt = getOpenPrompt(); + assert.ok(prompt); + assert.match( + prompt.message, + /2 dependent operations will be updated to reference this measurement's new wire/, + "must surface the survivors-update clause with the plural count", + ); + assert.doesNotMatch( + prompt.message, + /will be deleted/, + "pure-survivors message must NOT mention deletion", + ); +}); + +test("moveOperationWithConfirmation: M with pure-INVALIDATED consumers shows the delete-only message", () => { + // Invalidated-only partition: target column >= every consumer's column — the M moves past all its + // consumers. Every consumer flips into the "will be deleted" bucket. + const model = build( + circuit(qubits(3, { 0: 1 }), [ + [meas(0)], // column 0: the M + [consumer("X", 1)], // column 1: only consumer + ]), + ); + const render = makeRenderSpy(); + + // Move M into column 1 (the consumer's column). Target column == consumer's column → + // `inEarlierColumnThan` is false → consumer is invalidated. + moveWithConfirm(model, { from: "0,0", to: "1,0" }, render.fn); + + const prompt = getOpenPrompt(); + assert.ok(prompt); + assert.match( + prompt.message, + /1 dependent operation would end up before this measurement in document order and will be deleted/, + "must surface the invalidated-delete clause in singular form", + ); + assert.doesNotMatch( + prompt.message, + /will be updated/, + "pure-invalidated message must NOT mention updates", + ); +}); + +test("moveOperationWithConfirmation: M with MIXED consumers shows both clauses joined with '; '", () => { + // Mixed partition: target column splits the consumer list — some stay after (survivors → + // updated), some end up at-or-before (invalidated → deleted). Message must include BOTH clauses + // and the explicit '; ' separator. + const model = build( + circuit(qubits(3, { 0: 1 }), [ + [meas(0)], // column 0: the M + [consumer("X", 1)], // column 1: invalidated (column == target) + [consumer("Y", 2)], // column 2: survives (column > target) + ]), + ); + const render = makeRenderSpy(); + + // Target column 1 → consumer at "1,0" invalidates, consumer at "2,0" survives. + moveWithConfirm(model, { from: "0,0", to: "1,0" }, render.fn); + + const prompt = getOpenPrompt(); + assert.ok(prompt); + assert.match( + prompt.message, + /will be updated to reference this measurement's new wire/, + "must include the survivors clause", + ); + assert.match( + prompt.message, + /will be deleted/, + "must include the invalidated clause", + ); + assert.match( + prompt.message, + /; /, + "the two clauses must be joined with '; '", + ); +}); + +test("moveOperationWithConfirmation: M-with-consumers Cancel makes NO mutations and does NOT render", () => { + // Cancel-path symmetry with the delete wrapper: model frozen, renderFn untouched. + const model = build( + circuit(qubits(2, { 0: 1 }), [[meas(0)], [consumer("X", 1)]]), + ); + const beforeJSON = snapshot(model); + const render = makeRenderSpy(); + + moveWithConfirm(model, { from: "0,0", to: "1,0" }, render.fn); + + const prompt = getOpenPrompt(); + assert.ok(prompt); + prompt.cancelButton.click(); + + assert.equal(render.count, 0); + assert.equal( + snapshot(model), + beforeJSON, + "model must be unchanged after Cancel on a move prompt", + ); +}); + +test("moveOperationWithConfirmation: M-with-consumers OK cascades through moveMeasurementWithDependents", () => { + // Sanity check on the OK branch with a mixed partition. After commit: the M moved to the target + // column, the survivor's classical control was remapped to the M's new wire, and the invalidated + // consumer is gone. + const model = build( + circuit(qubits(3, { 0: 1 }), [ + [meas(0)], // column 0: the M on wire 0 + [consumer("X", 1)], // column 1: invalidated consumer + [consumer("Y", 2)], // column 2: survivor consumer + ]), + ); + const render = makeRenderSpy(); + + // Move M from (0,0) on wire 0 → target column 1 on wire 0 (no wire change). Consumer at "1,0" is + // invalidated; consumer at "2,0" survives. + moveWithConfirm(model, { from: "0,0", to: "1,0" }, render.fn); + + const prompt = getOpenPrompt(); + assert.ok(prompt); + prompt.okButton.click(); + + assert.equal(render.count, 1, "OK must trigger exactly one re-render"); + + // The X (invalidated) must be gone. + const allOps = flattenOps(model); + assert.equal( + allOps.find((o) => /** @type {any} */ (o).gate === "X"), + undefined, + "invalidated X consumer must have been cascade-deleted", + ); + // The Y (survivor) must still exist. The exact remap is the contract of + // `moveMeasurementWithDependents`, covered in the circuit-actions/ suite + // (measurementCascade.test.mjs). + assert.ok( + allOps.find((o) => /** @type {any} */ (o).gate === "Y"), + "survivor Y consumer must remain", + ); +}); diff --git a/source/npm/qsharp/test/circuit-editor/qubitController.test.mjs b/source/npm/qsharp/test/circuit-editor/qubitController.test.mjs new file mode 100644 index 00000000000..d36827185da --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/qubitController.test.mjs @@ -0,0 +1,320 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// QubitController tests — exercises the qubit-line interaction surface against a hand-built SVG +// fixture. Covers: +// +// - Direct invocation of `removeQubitLineWithConfirmation` on a wire with zero operations (the +// no-prompt fast path). +// - Mousedown on a qubit label, which spawns the swap and insert-between dropzones, sets +// `selectedWire` / `dragging`, and creates the drag-ghost element. +// - Mouseup on a swap dropzone dispatches `moveQubit` and the render callback. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { InteractionState } from "../../dist/ux/circuit-vis/actions/interactionState.js"; +import { QubitController } from "../../dist/ux/circuit-vis/editor/controllers/qubitController.js"; +import { build, circuit, expectGrid, gate } from "./_helpers.mjs"; + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; + globalThis.MouseEvent = jsdom.window.MouseEvent; + globalThis.Node = jsdom.window.Node; +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * Build a fixture with an `svg.qviz` containing `n` qubit-label `` elements (data-wire + * 0..n-1) inside a `g.qubit-input-states` group, plus the editor overlay layer that QubitController + * appends dropzones into. + */ +function buildFixture(/** @type {number} */ n) { + const container = document.createElement("div"); + document.body.appendChild(container); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "qviz"); + svg.setAttribute("width", "200"); + container.appendChild(svg); + + const labelGroup = document.createElementNS(SVG_NS, "g"); + labelGroup.setAttribute("class", "qubit-input-states"); + svg.appendChild(labelGroup); + + /** @type {SVGTextElement[]} */ + const labels = []; + for (let i = 0; i < n; i++) { + const text = /** @type {SVGTextElement} */ ( + document.createElementNS(SVG_NS, "text") + ); + text.setAttribute("data-wire", String(i)); + text.textContent = `q${i}`; + labelGroup.appendChild(text); + labels.push(text); + } + + const overlay = document.createElementNS(SVG_NS, "g"); + overlay.setAttribute("class", "editor-overlay"); + svg.appendChild(overlay); + + return { container, svg, labelGroup, labels, overlay }; +} + +/** + * Construct a QubitController against the given fixture and a fresh model. `wireData[i]` is set to + * a stable y-coordinate so the dropzone layout math has stable inputs. + */ +function makeController( + /** @type {any} */ container, + /** @type {any} */ model, + /** @type {{ renderFn?: () => void }} */ options = {}, +) { + const interaction = new InteractionState(); + let renderCalls = 0; + const userRender = options.renderFn; + const renderFn = () => { + renderCalls++; + userRender?.(); + }; + const wireData = Array.from( + { length: model.qubits.length + 1 }, + (_, i) => 40 + 60 * i, + ); + const ctx = { + model, + interaction, + layoutMap: /** @type {any} */ ({}), + container, + circuitSvg: container.querySelector("svg.qviz"), + overlayLayer: container.querySelector("g.editor-overlay"), + dropzoneLayer: /** @type {any} */ ({}), + ghostQubitLayer: /** @type {any} */ ({}), + wireData, + renderFn, + }; + const controller = new QubitController(/** @type {any} */ (ctx)); + return { controller, ctx, interaction, renderCalls: () => renderCalls }; +} + +/** + * One-call setup: build a model from a DSL circuit literal, a fixture with one qubit label per + * wire, and a QubitController wired to both. Returns the fixture pieces plus the controller handles + * (including a `renderCalls()` accessor). + * + * @param {any} circuitObj + * @param {{ renderFn?: () => void }} [options] + */ +function setup(circuitObj, options = {}) { + const model = build(circuitObj); + const fixture = buildFixture(model.qubits.length); + const controller = makeController(fixture.container, model, options); + return { model, ...fixture, ...controller }; +} + +const dispatchMouseDown = (/** @type {EventTarget} */ target) => + target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + +test("removeQubitLineWithConfirmation removes an empty wire without prompting", () => { + // Pre-populate wire 0 with an op so `removeTrailingUnusedQubits` doesn't trim every wire after + // the target removal. + const { model, controller, ctx, renderCalls } = setup( + circuit(3, [[gate("H", 0)]]), + ); + + // Wire 1 has zero use count → no prompt. + controller.removeQubitLineWithConfirmation(1); + + // Wire 1 dropped; wire 2 (also zero-use) drops via the trailing trim that fires after each + // removal. Wire 0 (use count 1) stays. + assert.equal(model.qubits.length, 1); + assert.equal(model.qubits[0].id, 0); + assert.equal(renderCalls(), 1); + // wireData was spliced in step with the model. + assert.equal(ctx.wireData.length, 3); + // No prompt was added to the document. + assert.equal(document.querySelectorAll(".prompt-overlay").length, 0); +}); + +test("removeQubitLineWithConfirmation prompts when the wire has operations", () => { + const { model, controller, renderCalls } = setup( + circuit(2, [[gate("H", 1)]]), + ); + + controller.removeQubitLineWithConfirmation(1); + + // The prompt is attached to the document — the actual remove waits for user confirmation, so the + // model is unchanged at this point. + assert.equal(document.querySelectorAll(".prompt-overlay").length, 1); + assert.equal(model.qubits.length, 2); + assert.equal(renderCalls(), 0); +}); + +/** + * Find a prompt button by its visible text. The prompt renders exactly two buttons ("OK" and + * "Cancel") both with class `prompt-button`, so text is the disambiguator. + */ +const findPromptButton = (/** @type {string} */ label) => + /** @type {HTMLButtonElement | undefined} */ ( + Array.from(document.querySelectorAll("button.prompt-button")).find( + (b) => b.textContent === label, + ) + ); + +test("removeQubitLineWithConfirmation prompt message reflects operation count (singular vs plural)", () => { + const { controller } = setup(circuit(2, [[gate("H", 1)]])); + + controller.removeQubitLineWithConfirmation(1); + + // Singular wording for exactly one associated operation. + const singularMsg = document.querySelector(".prompt-message")?.textContent; + assert.match(singularMsg ?? "", /1 operation associated/); + assert.doesNotMatch(singularMsg ?? "", /operations associated/); + + // Cancel to dismiss before the plural-case fixture. + findPromptButton("Cancel")?.click(); + + const { controller: controller2 } = setup( + circuit(2, [[gate("H", 1), gate("X", 1)]]), + ); + + controller2.removeQubitLineWithConfirmation(1); + + // Plural wording for >1 associated operation. + const pluralMsg = document.querySelector(".prompt-message")?.textContent; + assert.match(pluralMsg ?? "", /2 operations associated/); +}); + +test("removeQubitLineWithConfirmation OK click cascades removeQubitWithDependents + render", () => { + const { model, controller, ctx, renderCalls } = setup( + circuit(3, [[gate("H", 1), gate("X", 0)]]), + ); + + controller.removeQubitLineWithConfirmation(1); + // Pre-click: model unchanged. + assert.equal(model.qubits.length, 3); + assert.equal(renderCalls(), 0); + + // Simulate the user clicking OK. + const okButton = findPromptButton("OK"); + assert.ok(okButton, "expected OK button on prompt"); + okButton.click(); + + // The H on wire 1 was removed via removeQubitWithDependents; only the X on wire 0 survives. Wire + // 1 itself was removed (trailing wire 2 was also unused so it was trimmed by + // removeTrailingUnusedQubits, leaving just wire 0). + assert.equal(model.qubits.length, 1); + assert.equal(model.qubits[0].id, 0); + // Surviving op is the X; renumbering may or may not shift its qubit index depending on + // removeQubit's behavior — assert only that the H is gone and the X remains. + expectGrid(model, [["X"]]); + + // wireData was spliced in step with the model removal. + assert.equal(ctx.wireData.length, 3); + + // One render call from doRemove. + assert.equal(renderCalls(), 1); + + // Prompt was torn down after the click. + assert.equal(document.querySelectorAll(".prompt-overlay").length, 0); +}); + +test("removeQubitLineWithConfirmation Cancel click leaves the model untouched and does not render", () => { + const { model, controller, ctx, renderCalls } = setup( + circuit(2, [[gate("H", 1)]]), + ); + + controller.removeQubitLineWithConfirmation(1); + + const cancelButton = findPromptButton("Cancel"); + assert.ok(cancelButton, "expected Cancel button on prompt"); + cancelButton.click(); + + // Cancel must NOT mutate the model, NOT splice wireData, and NOT trigger a re-render. + assert.equal(model.qubits.length, 2); + assert.equal(ctx.wireData.length, 3); + assert.equal(renderCalls(), 0); + // The op on wire 1 is still in the grid. + expectGrid(model, [[{ H: 1 }]]); + + // Prompt was torn down after the click. + assert.equal(document.querySelectorAll(".prompt-overlay").length, 0); +}); + +// --------------------------------------------------------------------------- +// Pointer interactions: label mousedown spawns dropzones; mouseup on a swap dropzone dispatches +// moveQubit +// --------------------------------------------------------------------------- + +test("mousedown on a qubit label sets selectedWire and dragging", () => { + const { labels, interaction } = setup(circuit(3, [])); + + dispatchMouseDown(labels[1]); + + assert.equal(interaction.selectedWire, 1); + assert.equal(interaction.dragging, true); + // Pre-existing selection is cleared at the start of a label drag. + assert.equal(interaction.selectedOperation, null); +}); + +test("mousedown on a label spawns swap and insert-between dropzones along OTHER wires", () => { + const { labels, overlay } = setup(circuit(3, [])); + + dispatchMouseDown(labels[1]); + + // wireData has length n+1 (= 4) to account for the trailing ghost wire. + // + // Swap loop: targetWire = 0..wireData.length-2 (= 0, 1, 2), skip sourceWire (1) -> 2 dropzones at + // wires 0 and 2. + // + // Between loop: i = 0..wireData.length-1 (= 0..3), skip sourceWire (1) and sourceWire+1 (2) -> 2 + // dropzones at i=0 and i=3. + // + // Total = 4. + const dropzones = overlay.querySelectorAll("[data-dropzone-wire]"); + assert.equal(dropzones.length, 4); + + // Drop targets: wires 0, 2 (swap) plus 0, 3 (between). + const wires = Array.from(dropzones) + .map((d) => Number(d.getAttribute("data-dropzone-wire"))) + .sort((a, b) => a - b); + assert.deepEqual(wires, [0, 0, 2, 3]); +}); + +test("mouseup on a spawned swap dropzone calls moveQubit and renderFn", () => { + const { model, labels, overlay, renderCalls } = setup( + circuit(3, [[gate("X", 0), gate("H", 2)]]), + ); + + dispatchMouseDown(labels[0]); + + // Pick the swap dropzone targeting wire 2 (i.e. swap wires 0 and 2). + const dropzone = Array.from( + overlay.querySelectorAll("[data-dropzone-wire]"), + ).find((d) => d.getAttribute("data-dropzone-wire") === "2"); + assert.ok(dropzone, "expected a swap dropzone for wire 2"); + + /** @type {Element} */ (dropzone).dispatchEvent( + new MouseEvent("mouseup", { bubbles: true }), + ); + + // After the swap the H originally on wire 2 now lives on wire 0, and the X originally on wire 0 + // now lives on wire 2. + expectGrid(model, [[{ H: 0 }, { X: 2 }]]); + assert.equal(renderCalls(), 1); +}); diff --git a/source/npm/qsharp/test/circuit-editor/scrollController.test.mjs b/source/npm/qsharp/test/circuit-editor/scrollController.test.mjs new file mode 100644 index 00000000000..aa5d40ff05f --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/scrollController.test.mjs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// ScrollController tests — exercises the `enableAutoScroll` helper against a JSDOM scrollable +// container. Verifies that mousemove near each edge scrolls the appropriate axis, that mouseup +// removes both document listeners, and that the `disableLeftAutoScroll` flag is honored and lifted +// at the right threshold. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { InteractionState } from "../../dist/ux/circuit-vis/actions/interactionState.js"; +import { enableAutoScroll } from "../../dist/ux/circuit-vis/editor/controllers/scrollController.js"; + +/** @type {JSDOM | null} */ +let jsdom = null; +/** @type {HTMLElement | null} */ +let scrollable = null; +/** @type {SVGElement | null} */ +let circuitSvg = null; + +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; + globalThis.MouseEvent = jsdom.window.MouseEvent; + + // Build a scrollable container at a known viewport position so the mousemove handler's + // edge-distance math has stable inputs. + scrollable = document.createElement("div"); + // The controller's `getScrollableAncestor` checks computed overflowY/overflowX individually, not + // the shorthand. + scrollable.style.overflowY = "auto"; + scrollable.style.overflowX = "auto"; + scrollable.style.width = "200px"; + scrollable.style.height = "200px"; + // Stub getBoundingClientRect so the controller sees a fixed viewport rectangle for the scrollable + // ancestor. JSDOM's default returns zeros, which would put every cursor "near every edge." + scrollable.getBoundingClientRect = () => + /** @type {DOMRect} */ ({ + top: 100, + bottom: 300, + left: 100, + right: 300, + width: 200, + height: 200, + x: 100, + y: 100, + toJSON: () => ({}), + }); + document.body.appendChild(scrollable); + + // The "circuitSvg" only matters as the starting point for the scrollable-ancestor walk. Append it + // to the scrollable so the walk terminates at our stub. + circuitSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + scrollable.appendChild(circuitSvg); +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; + scrollable = null; + circuitSvg = null; +}); + +const move = (/** @type {number} */ clientX, /** @type {number} */ clientY) => + document.dispatchEvent( + new MouseEvent("mousemove", { clientX, clientY, bubbles: true }), + ); + +const releaseMouse = () => + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + +/** + * Create an InteractionState (with optional field overrides) and arm `enableAutoScroll` against the + * fixture's circuitSvg. Returns the interaction so tests can assert on flag state. + * + * @param {Partial} [overrides] + */ +const arm = (overrides = {}) => { + const interaction = new InteractionState(); + Object.assign(interaction, overrides); + enableAutoScroll(/** @type {SVGElement} */ (circuitSvg), interaction); + return interaction; +}; + +/** The scrollable fixture, narrowed to HTMLElement. */ +const el = () => /** @type {HTMLElement} */ (scrollable); + +test("mousemove in the middle of the area does not scroll", () => { + arm(); + + move(200, 200); // center of the 100..300 / 100..300 rect + + assert.equal(el().scrollTop, 0); + assert.equal(el().scrollLeft, 0); +}); + +test("mousemove near the top edge scrolls up (negative scrollTop)", () => { + arm(); + // Pre-seed scrollTop so the negative delta is observable. + el().scrollTop = 100; + + // edgeThreshold = 50 → anything within 50px of top (i.e. clientY < 150). + move(200, 120); + + // Scroll moved up by scrollSpeed (10). + assert.equal(el().scrollTop, 90); +}); + +test("mousemove near the bottom edge scrolls down", () => { + arm(); + + move(200, 280); // within 50px of bottom (300) + + assert.equal(el().scrollTop, 10); +}); + +test("mousemove near the left edge scrolls left", () => { + arm(); + el().scrollLeft = 100; + + move(120, 200); // within 50px of left (100) + + assert.equal(el().scrollLeft, 90); +}); + +test("mousemove near the right edge scrolls right", () => { + arm(); + + move(280, 200); // within 50px of right (300) + + assert.equal(el().scrollLeft, 10); +}); + +test("disableLeftAutoScroll suppresses the left-edge scroll trigger", () => { + arm({ disableLeftAutoScroll: true }); + el().scrollLeft = 100; + + move(120, 200); // would normally scroll left + + // Left-scroll suppressed by the flag. + assert.equal(el().scrollLeft, 100); +}); + +test("disableLeftAutoScroll is lifted once the cursor moves far enough right", () => { + const interaction = arm({ disableLeftAutoScroll: true }); + + // Threshold for lifting: clientX > leftBoundary + 3 * edgeThreshold = 100 + 150 = 250. A move + // past that releases the flag. + move(260, 200); + + assert.equal(interaction.disableLeftAutoScroll, false); +}); + +test("mouseup removes both document listeners", () => { + arm(); + + // Establish that the controller was active (a move scrolls). + move(200, 280); + assert.equal(el().scrollTop, 10); + + releaseMouse(); + + // After mouseup the listener is gone; subsequent moves are no-ops. + el().scrollTop = 0; + move(200, 280); + assert.equal(el().scrollTop, 0); +}); diff --git a/source/npm/qsharp/test/circuit-editor/selectionController.test.mjs b/source/npm/qsharp/test/circuit-editor/selectionController.test.mjs new file mode 100644 index 00000000000..1459f98e5e1 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/selectionController.test.mjs @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// SelectionController tests — exercises the host-element mousedown path against a hand-built SVG +// fixture and a stub `CircuitEvents`. Verifies the controller captures `selectedWire` from +// `data-wire` and sets `movingControl` only when the host is a control dot. +// +// The context-menu attachment side effect installs a `contextmenu` listener on each host but does +// not run any code until the menu is opened — these tests do not dispatch `contextmenu`, so the +// stub `CircuitEvents` is never read. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { InteractionState } from "../../dist/ux/circuit-vis/actions/interactionState.js"; +import { SelectionController } from "../../dist/ux/circuit-vis/editor/controllers/selectionController.js"; + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; + globalThis.MouseEvent = jsdom.window.MouseEvent; + // JSDOM doesn't implement DOMPoint or DOMMatrix at all. The `pickSelectedWire` closest-wire path + // uses both: new DOMPoint(clientX, clientY).matrixTransform(ctm.inverse()) We stub a minimal + // *identity* DOMPoint so clientY flows through to SVG-space Y unchanged — the CTM stub in + // `buildMultiWireFixture` is wired to match (it returns an identity matrix whose `.inverse()` is + // also identity, and `matrixTransform` on our stub just returns the point itself). This is enough + // surface area to exercise the controller's coord-projection wiring without dragging an + // SVG-layout shim into the test deps. Cast the assignment because our stub doesn't implement the + // full DOMPoint static surface (e.g. `fromPoint`). + globalThis.DOMPoint = /** @type {any} */ ( + class { + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } + // Identity transform: the fixture's CTM stub is also identity, so this is correct for all our + // tests. A real layout-aware run would apply the matrix; we don't need that fidelity. + matrixTransform() { + return { x: this.x, y: this.y }; + } + } + ); +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * Build an `svg.qviz` host with: + * - one rect.gate-h on wire 0 + * - one circle.control-dot on wire 1 + * - one rect with no `data-wire` attribute on wire ? + * Returns the container plus a map of the host elements for direct event dispatch. + */ +function buildFixture() { + const container = document.createElement("div"); + document.body.appendChild(container); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "qviz"); + container.appendChild(svg); + + const gateH = document.createElementNS(SVG_NS, "rect"); + gateH.setAttribute("class", "gate-h"); + gateH.setAttribute("data-wire", "0"); + svg.appendChild(gateH); + + const controlDot = document.createElementNS(SVG_NS, "circle"); + controlDot.setAttribute("class", "control-dot"); + controlDot.setAttribute("data-wire", "1"); + svg.appendChild(controlDot); + + const orphan = document.createElementNS(SVG_NS, "rect"); + // Picked up by `[class^="gate-"]` but has no data-wire. + orphan.setAttribute("class", "gate-x"); + svg.appendChild(orphan); + + return { container, svg, gateH, controlDot, orphan }; +} + +/** + * Construct a SelectionController with the minimum viable `InteractionContext` and a stub + * `CircuitEvents`. Pass `wireData` (matching a multi-wire fixture) to exercise the closest-wire + * path. + * + * @param {HTMLElement} container + * @param {{ interaction?: InteractionState, wireData?: number[] }} [options] + */ +function makeController( + container, + { interaction = new InteractionState(), wireData = [] } = {}, +) { + const ctx = { + model: /** @type {any} */ ({}), + interaction, + layoutMap: /** @type {any} */ ({}), + container, + circuitSvg: container.querySelector("svg.qviz"), + overlayLayer: /** @type {any} */ ({}), + dropzoneLayer: /** @type {any} */ ({}), + ghostQubitLayer: /** @type {any} */ ({}), + wireData, + renderFn: () => {}, + }; + // Stub CircuitEvents — only used as a closure capture by addContextMenuToHostElem (never invoked + // in these tests). + const stubEvents = /** @type {any} */ ({ + componentGrid: [], + model: ctx.model, + renderFn: ctx.renderFn, + }); + new SelectionController(/** @type {any} */ (ctx), stubEvents); + return { ctx, interaction }; +} + +const dispatchMouseDown = (/** @type {EventTarget} */ target) => + target.dispatchEvent( + new MouseEvent("mousedown", { button: 0, bubbles: true }), + ); + +test("mousedown on a gate host sets selectedWire from data-wire", () => { + const { container, gateH } = buildFixture(); + const { interaction } = makeController(container); + + dispatchMouseDown(gateH); + + assert.equal(interaction.selectedWire, 0); + assert.equal(interaction.movingControl, false); +}); + +test("mousedown on a control-dot sets selectedWire AND movingControl", () => { + const { container, controlDot } = buildFixture(); + const { interaction } = makeController(container); + + dispatchMouseDown(controlDot); + + assert.equal(interaction.selectedWire, 1); + assert.equal(interaction.movingControl, true); +}); + +test("mousedown on a host without data-wire sets selectedWire to null", () => { + const { container, orphan } = buildFixture(); + const interaction = new InteractionState(); + // Pre-seed with a non-null value so we can see the explicit clear. + interaction.selectedWire = 7; + makeController(container, { interaction }); + + dispatchMouseDown(orphan); + + assert.equal(interaction.selectedWire, null); +}); + +// ============================================================ +// Closest-wire-to-click resolution for multi-wire host elems +// ============================================================ + +/** + * Build a multi-wire fixture: a group body that spans wires 0..2, with `data-wire-ys = [40, 100, + * 160]` and a (stale) static `data-wire = 0`. The closest-wire path should override the static + * attribute and pick the wire closest to the click's SVG-space Y. + * + * Stubs `circuitSvg.getScreenCTM()` to the identity transform so `clientY` flows through to + * SVG-space Y unchanged. JSDOM doesn't implement layout, so a real CTM call would return null and + * the controller would fall back to the static `data-wire` — masking the closest-wire behavior + * we're trying to test. + */ +function buildMultiWireFixture(wireYs = [40, 100, 160]) { + const container = document.createElement("div"); + document.body.appendChild(container); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "qviz"); + container.appendChild(svg); + + // Identity CTM hand-stub. JSDOM doesn't ship a DOMMatrix at all, and our DOMPoint stub's + // `matrixTransform()` already returns the point unchanged — so all this needs is something + // non-null with an `.inverse()` method the controller can call before passing it to + // `matrixTransform`. The returned value never has to be a real matrix. + const identityCtm = /** @type {any} */ ({ inverse: () => identityCtm }); + svg.getScreenCTM = () => identityCtm; + + const groupBody = document.createElementNS(SVG_NS, "rect"); + groupBody.setAttribute("class", "gate-group"); + groupBody.setAttribute("data-wire-ys", JSON.stringify(wireYs)); + // Static attr is the topmost-wire fallback, which the closest-wire path must override. + groupBody.setAttribute("data-wire", "0"); + svg.appendChild(groupBody); + + return { container, svg, groupBody, wireYs }; +} + +/** + * Dispatch a primary-button mousedown at a given client Y so the closest-wire resolution has a + * coordinate to project. + */ +const dispatchMouseDownAt = ( + /** @type {EventTarget} */ target, + /** @type {number} */ clientY, + button = 0, +) => + target.dispatchEvent( + new MouseEvent("mousedown", { + button, + clientX: 0, + clientY, + bubbles: true, + }), + ); + +test("multi-wire host: click near top wire picks top wire (overrides data-wire shortcut)", () => { + const { container, groupBody } = buildMultiWireFixture(); + const { interaction } = makeController(container, { + wireData: [40, 100, 160], + }); + + dispatchMouseDownAt(groupBody, 42); // closest to 40 + + assert.equal(interaction.selectedWire, 0); +}); + +test("multi-wire host: click near middle wire picks middle wire", () => { + const { container, groupBody } = buildMultiWireFixture(); + const { interaction } = makeController(container, { + wireData: [40, 100, 160], + }); + + dispatchMouseDownAt(groupBody, 95); + + assert.equal( + interaction.selectedWire, + 1, + "static data-wire was 0 (topmost) — closest-wire path must override", + ); +}); + +test("multi-wire host: click near bottom wire picks bottom wire", () => { + const { container, groupBody } = buildMultiWireFixture(); + const { interaction } = makeController(container, { + wireData: [40, 100, 160], + }); + + dispatchMouseDownAt(groupBody, 158); + + assert.equal(interaction.selectedWire, 2); +}); + +test("multi-wire host: click far above the span clamps to topmost wire", () => { + const { container, groupBody } = buildMultiWireFixture(); + const { interaction } = makeController(container, { + wireData: [40, 100, 160], + }); + + dispatchMouseDownAt(groupBody, -200); + + assert.equal(interaction.selectedWire, 0); +}); + +test("multi-wire host: click far below the span clamps to bottommost wire", () => { + const { container, groupBody } = buildMultiWireFixture(); + const { interaction } = makeController(container, { + wireData: [40, 100, 160], + }); + + dispatchMouseDownAt(groupBody, 1000); + + assert.equal(interaction.selectedWire, 2); +}); + +test("multi-wire host: falls back to data-wire when getScreenCTM returns null", () => { + // Detached SVG / pre-mount edge case. The controller must not throw; it should fall back to the + // static `data-wire` attribute so the click still resolves *some* wire. + const { container, groupBody } = buildMultiWireFixture(); + const svg = /** @type {any} */ (container.querySelector("svg.qviz")); + svg.getScreenCTM = () => null; + const { interaction } = makeController(container, { + wireData: [40, 100, 160], + }); + + dispatchMouseDownAt(groupBody, 95); + + assert.equal( + interaction.selectedWire, + 0, + "fallback path returns the static data-wire value (0)", + ); +}); + +test("multi-wire host: falls back to data-wire if closest wireY is not in wireData", () => { + // wireYs claims [40, 100, 160] but the editor's wireData is [200, 300] — table mismatch. + // pickClosestWireIndex returns -1, the controller falls back to the static data-wire. + const { container, groupBody } = buildMultiWireFixture(); + const { interaction } = makeController(container, { wireData: [200, 300] }); + + dispatchMouseDownAt(groupBody, 95); + + assert.equal(interaction.selectedWire, 0, "fallback to static data-wire"); +}); + +test("single-wire host: closest-wire path is skipped, data-wire still wins", () => { + // Smoke-check: the multi-wire branch must not engage for single-wire spans (control dots, target + // circles, etc.). Use a host with data-wire-ys=[100] and data-wire=1 — controller must resolve to + // wire 1 regardless of clickY. + const container = document.createElement("div"); + document.body.appendChild(container); + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("class", "qviz"); + container.appendChild(svg); + // No CTM stub — single-wire path doesn't call getScreenCTM. + + const dot = document.createElementNS(SVG_NS, "circle"); + dot.setAttribute("class", "control-dot"); + dot.setAttribute("data-wire-ys", "[100]"); + dot.setAttribute("data-wire", "1"); + svg.appendChild(dot); + + const { interaction } = makeController(container, { wireData: [40, 100] }); + + dispatchMouseDownAt(dot, -999); + + assert.equal(interaction.selectedWire, 1); +}); diff --git a/source/npm/qsharp/test/circuit-editor/sqore.test.mjs b/source/npm/qsharp/test/circuit-editor/sqore.test.mjs new file mode 100644 index 00000000000..3759bc91420 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/sqore.test.mjs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Sqore lifecycle tests — direct unit coverage of `rebaseViewState` and `updateCircuit`. These +// methods are JSDOM-free (they operate on `this.circuit`, `this.lastLocationMap`, and +// `this.viewState`), so tests drive them directly via `/** @type {any} */` casts to reach +// non-public members. + +// @ts-check + +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { Sqore } from "../../dist/ux/circuit-vis/sqore.js"; +import { circuit, gate, group } from "./_helpers.mjs"; + +/** + * Wrap one or more circuit literals into a `CircuitGroup`, shaped the way `qsharp-lang`'s `draw()` + * entrypoint (and `updateCircuit`) expect. + * + * @param {...any} circuits + */ +const circuitGroup = (...circuits) => ({ version: 1, circuits }); + +/** + * Build a fresh `Sqore` over a tiny single-circuit group with two qubits and the given grid (an + * array of columns, each column an array of ops built with the `gate` / `group` DSL). + * + * @param {any[][]} grid + */ +const makeSqore = (grid) => new Sqore(circuitGroup(circuit(2, grid))); + +/** + * Snapshot a `lastLocationMap`-shaped Map from a list of `[op, location]` pairs. Mirrors what + * `buildLiveLocationMap` produces at the end of a render. + * + * @param {Array<[any, string]>} pairs + */ +function snapshot(pairs) { + return new Map(pairs); +} + +/** @type {any} */ +let sqore; + +afterEach(() => { + sqore = null; +}); + +// --------------------------------------------------------------------------- +// rebaseViewState: per-render key migration +// --------------------------------------------------------------------------- + +test("rebaseViewState: no-op on the first render when lastLocationMap is null", () => { + // No prior snapshot → short-circuit and leave `viewState` alone. + const opA = gate("H", 0); + sqore = makeSqore([[opA]]); + // `lastLocationMap` defaults to null; pre-seed an entry the short-circuit must not touch. + sqore.viewState.setExpanded("0,0", true); + + sqore.rebaseViewState(); + + assert.equal(sqore.viewState.expanded.size, 1); + assert.equal(sqore.viewState.expanded.get("0,0"), true); +}); + +test("rebaseViewState: identity-preserved op moved to a new column is rekeyed via the live identity lookup", () => { + // The op's object identity survives an upstream edit that shifts its column. `next.get(op)` hits + // and the entry is rekeyed to the new location. + const opA = gate("H", 0); + const filler = gate("X", 0); + // Grid AFTER the edit: filler took column 0, opA shifted to column 1. + sqore = makeSqore([[filler], [opA]]); + // Snapshot from BEFORE the edit: opA was at "0,0". + sqore.lastLocationMap = snapshot([[opA, "0,0"]]); + sqore.viewState.setExpanded("0,0", true); + + sqore.rebaseViewState(); + + // Entry rekeyed from "0,0" → "1,0"; original key is gone. + assert.equal(sqore.viewState.expanded.size, 1); + assert.equal(sqore.viewState.expanded.get("1,0"), true); + assert.equal(sqore.viewState.expanded.has("0,0"), false); +}); + +test("rebaseViewState: identity-lost op with sqore-prev-location stamp is rekeyed AND the stamp is consumed", () => { + // When `moveOperation` deep-clones an op, the live grid holds a new object reference. The + // identity lookup misses; the clone's `dataAttributes["sqore-prev-location"]` stamp recovers the + // entry by pre-move location. The stamp must then be deleted so it doesn't leak into the rendered + // SVG or re-trigger next rebase. + const oldOp = gate("H", 0); + // Distinct object reference, carrying the stamp `moveOperation` writes onto the clone. + const clonedOp = { + ...gate("H", 1), + dataAttributes: { "sqore-prev-location": "0,0" }, + }; + // Grid AFTER the move: clone landed at "1,0". + sqore = makeSqore([[gate("X", 0)], [clonedOp]]); + // Snapshot from BEFORE the move: original oldOp was at "0,0". + sqore.lastLocationMap = snapshot([[oldOp, "0,0"]]); + sqore.viewState.setExpanded("0,0", true); + + sqore.rebaseViewState(); + + // Entry follows the stamp from "0,0" → "1,0". + assert.equal(sqore.viewState.expanded.size, 1); + assert.equal(sqore.viewState.expanded.get("1,0"), true); + assert.equal(sqore.viewState.expanded.has("0,0"), false); + // Stamp consumed: must not survive to the next render. + assert.equal( + clonedOp.dataAttributes["sqore-prev-location"], + undefined, + "stamp must be deleted from dataAttributes after consumption", + ); +}); + +test("rebaseViewState: identity-lost op with no stamp drops the entry", () => { + // A tracked op is gone from the live grid and no replacement carries a stamp pointing at its + // prior location → drop the entry. + const goneOp = gate("H", 0); + // The replacement op has its own identity AND no stamp. + const replacement = gate("X", 0); + sqore = makeSqore([[replacement]]); + sqore.lastLocationMap = snapshot([[goneOp, "0,0"]]); + sqore.viewState.setExpanded("0,0", true); + + sqore.rebaseViewState(); + + // No survivors — entry was dropped. + assert.equal(sqore.viewState.expanded.size, 0); +}); + +test("rebaseViewState: untracked entries are left alone (ViewState.rebase no-touch contract)", () => { + // A `viewState` entry whose key isn't in the snapshot must survive — the rebase only mutates keys + // it has information about. + const opA = gate("H", 0); + sqore = makeSqore([[opA]]); + sqore.lastLocationMap = snapshot([[opA, "0,0"]]); + sqore.viewState.setExpanded("0,0", true); + // Stray entry not in the snapshot — must survive unchanged. + sqore.viewState.setExpanded("9,9", false); + + sqore.rebaseViewState(); + + // Tracked entry stays at "0,0" (op didn't move); stray entry stays at "9,9" untouched. + assert.equal(sqore.viewState.expanded.size, 2); + assert.equal(sqore.viewState.expanded.get("0,0"), true); + assert.equal(sqore.viewState.expanded.get("9,9"), false); +}); + +test("rebaseViewState: handles nested ops — identity preserved at depth 2", () => { + // The rebase walks the grid recursively, so a group's child keeps its viewState entry when an + // upstream edit shifts the group's column. + const childH = gate("H", 0); + const groupOp = group("Foo", [[childH]], { span: [0, 1] }); + const filler = gate("X", 0); + // Grid AFTER the edit: filler took column 0, group + child shifted to column 1. + sqore = makeSqore([[filler], [groupOp]]); + // Snapshot from BEFORE the edit: group at "0,0", child at "0,0-0,0". + sqore.lastLocationMap = snapshot([ + [groupOp, "0,0"], + [childH, "0,0-0,0"], + ]); + sqore.viewState.setExpanded("0,0", true); + sqore.viewState.setExpanded("0,0-0,0", false); + + sqore.rebaseViewState(); + + // Both entries rekeyed under the shifted prefix. + assert.equal(sqore.viewState.expanded.size, 2); + assert.equal(sqore.viewState.expanded.get("1,0"), true); + assert.equal(sqore.viewState.expanded.get("1,0-0,0"), false); + assert.equal(sqore.viewState.expanded.has("0,0"), false); + assert.equal(sqore.viewState.expanded.has("0,0-0,0"), false); +}); + +// --------------------------------------------------------------------------- +// updateCircuit: the escape hatch for external circuit updates. Swaps `circuit` + `circuitGroup`, +// preserves `viewState`, and nulls `lastLocationMap` so the next rebase treats it as a first +// render. +// --------------------------------------------------------------------------- + +test("updateCircuit: swaps circuit + circuitGroup while preserving viewState", () => { + // Pre-seed viewState; the central guarantee is that these entries survive the swap unchanged. + sqore = makeSqore([[gate("H", 0)]]); + sqore.viewState.setExpanded("0,0", true); + sqore.viewState.setExpanded("1,2-0,0", false); + + /** @type {any} */ + const newGroup = circuitGroup(circuit(3, [[gate("X", 0), gate("Y", 1)]])); + + sqore.updateCircuit(newGroup); + + // circuitGroup swapped wholesale. + assert.equal(sqore.circuitGroup, newGroup); + // circuit is the FIRST circuit in the group (matches constructor). + assert.equal(sqore.circuit, newGroup.circuits[0]); + assert.equal(sqore.circuit.qubits.length, 3); + + // viewState entries survived intact — same keys, same values. + assert.equal(sqore.viewState.expanded.size, 2); + assert.equal(sqore.viewState.expanded.get("0,0"), true); + assert.equal(sqore.viewState.expanded.get("1,2-0,0"), false); +}); + +test("updateCircuit: nullifies lastLocationMap so the next rebase short-circuits as first-render", () => { + // The new circuit's op identities have no relation to the prior snapshot. Nulling the map is the + // explicit signal to treat the next render as a fresh first render. + const opA = gate("H", 0); + sqore = makeSqore([[opA]]); + // Simulate a prior render having populated the location map. + sqore.lastLocationMap = snapshot([[opA, "0,0"]]); + sqore.viewState.setExpanded("0,0", true); + + /** @type {any} */ + const newGroup = circuitGroup(circuit(1, [[gate("X", 0)]])); + + sqore.updateCircuit(newGroup); + + assert.equal(sqore.lastLocationMap, null); + + // With the snapshot null, rebase must short-circuit and leave viewState untouched. + sqore.rebaseViewState(); + assert.equal(sqore.viewState.expanded.size, 1); + assert.equal(sqore.viewState.expanded.get("0,0"), true); +}); + +test("updateCircuit: throws on null circuitGroup", () => { + sqore = makeSqore([[gate("H", 0)]]); + + // Host-side fumbles must surface as exceptions, not silent broken renders. + assert.throws(() => sqore.updateCircuit(null), /No circuit found/); +}); + +test("updateCircuit: throws on circuitGroup with empty circuits array", () => { + sqore = makeSqore([[gate("H", 0)]]); + + // Empty `circuits` is treated the same as null — nothing to render. + assert.throws( + () => sqore.updateCircuit(/** @type {any} */ ({ circuits: [] })), + /No circuit found/, + ); + + // Also null `circuits`. + assert.throws( + () => + sqore.updateCircuit( + /** @type {any} */ ({ circuits: /** @type {any} */ (null) }), + ), + /No circuit found/, + ); +}); + +test("updateCircuit: when circuitGroup has multiple circuits, only the first becomes active", () => { + // Matches the constructor: `Sqore` renders one circuit at a time. + sqore = makeSqore([[gate("H", 0)]]); + + /** @type {any} */ + const newGroup = circuitGroup( + circuit(1, [[gate("X", 0)]]), + circuit(2, [[gate("Y", 0)]]), + ); + + sqore.updateCircuit(newGroup); + + assert.equal(sqore.circuitGroup, newGroup); + assert.equal(sqore.circuit, newGroup.circuits[0]); + // The first circuit had 1 qubit, not 2. + assert.equal(sqore.circuit.qubits.length, 1); +}); diff --git a/source/npm/qsharp/test/circuit-editor/toolbox.test.mjs b/source/npm/qsharp/test/circuit-editor/toolbox.test.mjs new file mode 100644 index 00000000000..5edda001ca9 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/toolbox.test.mjs @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Toolbox editor-contract tests — cover the things interaction code depends on: +// +// - Each rendered toolbox item exposes a `[toolbox-item]` attribute and a `data-type` the +// dragController uses to look up the prototype op. +// - The optional Run button: present (and wired) only when a callback is provided, absent +// otherwise. +// +// Panel layout, title, and gate positions are visual concerns covered by the snapshot suite in +// `test/circuits.js`. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { createToolboxElement } from "../../dist/ux/circuit-vis/editor/toolbox.js"; +import { toolboxGateDictionary } from "../../dist/ux/circuit-vis/editor/toolboxGates.js"; + +const documentTemplate = ` + + +`; + +/** @type {JSDOM | null} */ +let jsdom = null; + +beforeEach(() => { + jsdom = new JSDOM(documentTemplate); + globalThis.window = jsdom.window; + globalThis.document = jsdom.window.document; + globalThis.Node = jsdom.window.Node; + globalThis.HTMLElement = jsdom.window.HTMLElement; + globalThis.SVGElement = jsdom.window.SVGElement; +}); + +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +test("each toolbox item exposes a [toolbox-item] flag and a data-type matching its dictionary key", () => { + // `dragController.onToolboxMouseDown` selects on `[toolbox-item]` and reads `data-type` to look + // up the prototype op: + // + // const gateType = elem.getAttribute("data-type")!; const proto = + // toolboxGateDictionary[gateType]; + // + // Keying every assertion off `toolboxGateDictionary` means editing the toolbox gates updates this + // test automatically. + const toolbox = createToolboxElement(); + const items = toolbox.querySelectorAll("[toolbox-item]"); + + // `dragController` checks for attribute presence; locking down "true" catches an accidental falsy + // swap. + for (const item of Array.from(items)) { + assert.equal(item.getAttribute("toolbox-item"), "true"); + } + + const renderedTypes = Array.from(items) + .map((item) => item.getAttribute("data-type")) + .filter(/** @returns {x is string} */ (x) => x != null) + .sort(); + const expectedTypes = Object.keys(toolboxGateDictionary).sort(); + + assert.deepEqual(renderedTypes, expectedTypes); +}); + +// --------------------------------------------------------------------------- +// Run button — the only optional piece of the toolbox. Hosts that can't run circuits (e.g. +// read-only previews) pass no callback and should get no button at all, not a hidden one taking up +// space. +// --------------------------------------------------------------------------- + +test("toolbox without runCallback renders no Run button", () => { + const toolbox = createToolboxElement(); + + assert.equal( + toolbox.querySelectorAll(".svg-run-button").length, + 0, + "Run button must not be rendered when no callback is provided", + ); + // The toolbox itself still renders — only the button is suppressed. + assert.ok( + toolbox.querySelector(".toolbox-panel-svg"), + "toolbox SVG should still be present", + ); +}); + +test("toolbox with runCallback renders exactly one Run button", () => { + const toolbox = createToolboxElement(() => {}); + + const buttons = toolbox.querySelectorAll(".svg-run-button"); + assert.equal(buttons.length, 1, "exactly one Run button expected"); + + const button = buttons[0]; + // Accessibility wiring set by `_createRunButton` — guards against a future refactor silently + // dropping the role/tabindex. + assert.equal(button.getAttribute("role"), "button"); + assert.equal(button.getAttribute("tabindex"), "0"); + assert.equal( + button.querySelector(".svg-run-button-text")?.textContent, + "Run", + ); +}); + +test("clicking the Run button invokes the callback", () => { + let callCount = 0; + const toolbox = createToolboxElement(() => { + callCount += 1; + }); + + const button = toolbox.querySelector(".svg-run-button"); + assert.ok(button, "Run button should be present"); + + button.dispatchEvent( + new /** @type {any} */ (jsdom).window.Event("click", { bubbles: true }), + ); + assert.equal(callCount, 1, "clicking the button must invoke the callback"); +}); diff --git a/source/npm/qsharp/test/circuit-editor/utils.test.mjs b/source/npm/qsharp/test/circuit-editor/utils.test.mjs new file mode 100644 index 00000000000..2c825dc5dc8 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/utils.test.mjs @@ -0,0 +1,604 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Tests for the lower-level helpers in `utils.ts` — the ones that can be exercised without a +// `CircuitModel` or a rendered SVG tree. Most are pure data (`pickClosestWireIndex`, +// `getChildTargets`, the column-sibling helpers); `parseWireYs` just reads an attribute off an +// Element, so a minimal JSDOM is spun up for those few tests. The heavier paths that walk the +// rendered SVG (host-element lookup, wire-Y resolution from a real circuit DOM) live in the +// controller-level suites where a full render fixture exists. + +// @ts-check + +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { + getChildTargets, + getAncestorColumnSiblingWires, + getOuterColumnSiblingWires, + getWireRange, + pickClosestWireIndex, +} from "../../dist/ux/circuit-vis/utils.js"; +import { parseWireYs } from "../../dist/ux/circuit-vis/editor/domUtils.js"; + +// ============================================================ +// pickClosestWireIndex +// ============================================================ + +test("pickClosestWireIndex: degenerate inputs return the sentinel / first-match contract", () => { + // Empty span -> -1. + assert.equal(pickClosestWireIndex(0, [], [40, 100, 160]), -1); + // Closest wireY has no matching entry in wireData -> -1. + assert.equal(pickClosestWireIndex(50, [40, 100], [200, 300]), -1); + // Duplicate Y in wireData resolves to the FIRST index (indexOf). + assert.equal(pickClosestWireIndex(45, [40], [40, 40, 40]), 0); +}); + +test("pickClosestWireIndex: single-wire span ignores clickY", () => { + // Single-wire host elements (control dots, target circles, ket boxes) trivially resolve to their + // one wire-Y regardless of where the click landed. + assert.equal( + pickClosestWireIndex(999, [100], [40, 100, 160]), + 1, + "clickY far below wire 100 must still resolve to that wire", + ); + assert.equal( + pickClosestWireIndex(-999, [40], [40, 100, 160]), + 0, + "clickY far above wire 40 must still resolve to that wire", + ); +}); + +test("pickClosestWireIndex: multi-wire picks the closest by absolute distance", () => { + const wireYs = [40, 100, 160]; + const wireData = [40, 100, 160]; + assert.equal(pickClosestWireIndex(45, wireYs, wireData), 0, "near top"); + assert.equal(pickClosestWireIndex(95, wireYs, wireData), 1, "near middle"); + assert.equal(pickClosestWireIndex(155, wireYs, wireData), 2, "near bottom"); + assert.equal( + pickClosestWireIndex(70, wireYs, wireData), + 0, + "exactly equidistant -> smaller wireY wins (deterministic)", + ); + assert.equal( + pickClosestWireIndex(72, wireYs, wireData), + 1, + "tilt one px toward the middle wire -> middle wins", + ); +}); + +test("pickClosestWireIndex: clicks outside the span clamp to the nearest end", () => { + const wireYs = [40, 100, 160]; + const wireData = [40, 100, 160]; + assert.equal( + pickClosestWireIndex(-50, wireYs, wireData), + 0, + "click far above clamps to topmost wire", + ); + assert.equal( + pickClosestWireIndex(500, wireYs, wireData), + 2, + "click far below clamps to bottommost wire", + ); +}); + +test("pickClosestWireIndex: wireYs ordering does not affect the result", () => { + const wireData = [40, 100, 160]; + // Span listed in non-sorted order — algorithm is comparison-based, not order-dependent. + assert.equal(pickClosestWireIndex(45, [160, 40, 100], wireData), 0); + assert.equal(pickClosestWireIndex(155, [100, 40, 160], wireData), 2); +}); + +// ============================================================ +// parseWireYs +// ============================================================ + +/** @type {JSDOM | null} */ +let jsdom = null; +beforeEach(() => { + jsdom = new JSDOM(``); + globalThis.document = jsdom.window.document; +}); +afterEach(() => { + jsdom?.window.close(); + jsdom = null; +}); + +const makeElem = (/** @type {string | null} */ attr) => { + const el = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + if (attr != null) el.setAttribute("data-wire-ys", attr); + return el; +}; + +test("parseWireYs: absent or invalid attribute returns []", () => { + // Missing attribute, malformed JSON, non-number entries (whole array rejected), and non-array + // JSON all yield []. + assert.deepEqual(parseWireYs(makeElem(null)), []); + assert.deepEqual(parseWireYs(makeElem("not json")), []); + assert.deepEqual(parseWireYs(makeElem('[40, "100", 160]')), []); + assert.deepEqual(parseWireYs(makeElem("42")), []); + assert.deepEqual(parseWireYs(makeElem('"40"')), []); + assert.deepEqual(parseWireYs(makeElem("{}")), []); +}); + +test("parseWireYs: valid number-array round-trips", () => { + assert.deepEqual(parseWireYs(makeElem("[40, 100, 160]")), [40, 100, 160]); + assert.deepEqual(parseWireYs(makeElem("[40]")), [40]); +}); + +// ============================================================ +// getChildTargets +// ============================================================ + +// Helper: wrap a list of children into the single-column shape `Operation.children` expects. +const group = ( + /** @type {string} */ gate, + /** @type {any[]} */ targets, + /** @type {any[]} */ children, +) => + /** @type {import("../../dist/ux/circuit-vis/index.js").Operation} */ ({ + kind: "unitary", + gate, + targets, + children: [{ components: children }], + }); +const u = ( + /** @type {string} */ gate, + /** @type {any[]} */ targets, + /** @type {any[] | undefined} */ controls = undefined, +) => { + /** @type {any} */ + const op = { kind: "unitary", gate, targets }; + if (controls != null) op.controls = controls; + return /** @type {import("../../dist/ux/circuit-vis/index.js").Operation} */ ( + op + ); +}; +const m = (/** @type {any[]} */ qubits, /** @type {any[]} */ results) => + /** @type {import("../../dist/ux/circuit-vis/index.js").Operation} */ ({ + kind: "measurement", + gate: "Measure", + qubits, + results, + }); + +test("getChildTargets: returns [] when op has no children", () => { + // Leaf ops aren't groups; the action-layer cascade only calls `getChildTargets` on ops that have + // a `children` grid. The `[]` return models that contract. + const leaf = u("H", [{ qubit: 0 }]); + assert.deepEqual(getChildTargets(leaf), []); +}); + +test("getChildTargets: dedupes overlapping bare-qubit refs", () => { + // Foo contains H on wire 1 and RX on wires 1, 2. The union is {1, 2} — wire 1 must appear exactly + // once, not twice. + const foo = group( + "Foo", + [{ qubit: 1 }, { qubit: 2 }], + [u("H", [{ qubit: 1 }]), u("RX", [{ qubit: 1 }, { qubit: 2 }])], + ); + assert.deepEqual(getChildTargets(foo), [{ qubit: 1 }, { qubit: 2 }]); +}); + +test("getChildTargets: walks into nested groups", () => { + // Wire union must cross group boundaries — the cascade refresh assigns `getChildTargets(outer)` + // straight into `outer.targets`, and the outer span has to enclose every descendant wire no + // matter how deep. + const inner = group("Inner", [{ qubit: 2 }], [u("H", [{ qubit: 2 }])]); + const outer = group( + "Outer", + [{ qubit: 0 }, { qubit: 1 }, { qubit: 2 }], + [u("H", [{ qubit: 0 }]), inner], + ); + assert.deepEqual(getChildTargets(outer), [{ qubit: 0 }, { qubit: 2 }]); +}); + +test("getChildTargets: preserves measurement result registers as distinct entries", () => { + // A child measurement on wire 0 produces classical result 0; the measurement contributes BOTH + // `{qubit:0}` (the quantum input, pushed from `operation.qubits`) AND `{qubit:0, result:0}` (the + // classical output, pushed from `operation.results`). The dedup pass keys on `(qubit, result)` so + // the two distinct registers survive as separate entries. + const foo = group( + "Foo", + [{ qubit: 0 }], + [m([{ qubit: 0 }], [{ qubit: 0, result: 0 }])], + ); + const out = getChildTargets(foo); + // Order: measurement.qubits comes before measurement.results in the recursion's push order, so + // the bare-qubit entry comes first. + assert.deepEqual(out, [{ qubit: 0 }, { qubit: 0, result: 0 }]); +}); + +test("getChildTargets: preserves classical-control refs from classically-conditional unitaries", () => { + // Classically-conditional unitaries record their classical dependency in BOTH `controls` and + // `targets` (the `targets` entries are visual-extent claims that draw the line down to the + // classical register box — see `_shiftAllRegisters` in circuitActions.ts). If a group contains + // such a unitary, the group's refreshed `.targets` MUST carry the classical ref through, or the + // renderer drops the line. + const cond = u( + "X", + [{ qubit: 1 }, { qubit: 0, result: 0 }], + [{ qubit: 0, result: 0 }], + ); + const foo = group("Foo", [{ qubit: 0 }, { qubit: 1 }], [cond]); + const out = getChildTargets(foo); + // The bare-qubit target `{qubit:1}` and the classical ref `{qubit:0, result:0}` are both present. + // The classical ref appears once even though it was pushed twice (from `targets` and from + // `controls`). + assert.ok( + out.some((r) => r.qubit === 1 && r.result === undefined), + `expected bare-qubit {qubit:1}, got ${JSON.stringify(out)}`, + ); + assert.ok( + out.some((r) => r.qubit === 0 && r.result === 0), + `expected classical-ref {qubit:0, result:0}, got ${JSON.stringify(out)}`, + ); + assert.equal( + out.filter((r) => r.qubit === 0 && r.result === 0).length, + 1, + "classical ref should be deduped to a single entry", + ); +}); + +test("getChildTargets: returns fresh register objects, not aliases of child registers", () => { + // Callers assign the returned array straight into `parent.targets` / `parent.results`. If the + // entries aliased the child's own register objects, a later in-place edit on the child's register + // (e.g. `_shiftAllRegisters` bumping `qubit`) would silently mutate the parent's cached extent + // too. + const childTargets = [{ qubit: 0 }]; + const foo = group("Foo", [{ qubit: 0 }], [u("H", childTargets)]); + const out = getChildTargets(foo); + assert.notEqual( + out[0], + childTargets[0], + "returned register must be a fresh object, not a reference to the child's register", + ); + // Belt-and-suspenders: mutate the returned entry and confirm the child's register is unchanged. + out[0].qubit = 999; + assert.equal(childTargets[0].qubit, 0, "child register must be untouched"); +}); + +// ============================================================ +// getWireRange +// ============================================================ +// +// Vertical extent of an op as a pair of `Register` endpoints. Either endpoint may be a qubit row +// (no `.result`) or a classical-result row (`.result` set). Classical rows sit IMMEDIATELY BELOW +// their owning qubit row \u2014 the stack on a qubit `q_c` with results `r0..rN` reads, top to +// bottom: q_c, q_c.r0, q_c.r1, ..., q_c.rN, q_(c+1), ... + +test("getWireRange: single-qubit unitary returns the qubit row at both endpoints", () => { + const op = u("H", [{ qubit: 2 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 2 }, { qubit: 2 }]); +}); + +test("getWireRange: multi-qubit unitary spans min..max", () => { + // SWAP-like op on q3 and q5 \u2014 endpoints are the outer qubits. + const op = u("SWAP", [{ qubit: 3 }, { qubit: 5 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 3 }, { qubit: 5 }]); +}); + +test("getWireRange: classically-controlled gate with low classical ref \u2014 ref is the MIN endpoint", () => { + // Z @ q3 cref q0r0 \u2014 the classical row sits below q0, so it's the lowest visual position. + // The bare qubit q3 is the max. + const op = u("Z", [{ qubit: 3 }], [{ qubit: 0, result: 0 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 0, result: 0 }, { qubit: 3 }]); +}); + +test("getWireRange: classically-controlled gate with high classical ref \u2014 ref is the MAX endpoint", () => { + // Z @ q0 cref q3r0 \u2014 the classical row sits below q3, so it's the highest visual position. + const op = u("Z", [{ qubit: 0 }], [{ qubit: 3, result: 0 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 0 }, { qubit: 3, result: 0 }]); +}); + +test("getWireRange: classical refs on the SAME qubit \u2014 lowest-numbered result is the topmost", () => { + // Multiple classical refs to q0's result rows. r0 sits above r1 (lower-numbered results are drawn + // topmost). So between q0.r0 and q0.r1, q0.r1 is geometrically lower. + const op = u( + "Z", + [{ qubit: 5 }], + [ + { qubit: 0, result: 0 }, + { qubit: 0, result: 1 }, + ], + ); + // Max is q5 (a qubit row well below q0's classical rows). Min among the classical refs is r0 (it + // sits above r1). + assert.deepEqual(getWireRange(op), [{ qubit: 0, result: 0 }, { qubit: 5 }]); +}); + +test("getWireRange: bare qubit row sorts ABOVE its own classical-result rows", () => { + // Measurement on q0 producing r0: endpoints are the bare q0 (top) and q0.r0 (immediately below + // it). + const op = m([{ qubit: 0 }], [{ qubit: 0, result: 0 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 0 }, { qubit: 0, result: 0 }]); +}); + +test("getWireRange: quantum control BELOW target \u2014 control is the MAX endpoint", () => { + // CX with target on q0 and control on q3 \u2014 the control wire is the geometric bottom, the + // target the top. + const op = u("X", [{ qubit: 0 }], [{ qubit: 3 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 0 }, { qubit: 3 }]); +}); + +test("getWireRange: quantum control ABOVE target \u2014 control is the MIN endpoint", () => { + // CX with target on q3 and control on q0 \u2014 the control wire is the geometric top, the target + // the bottom. + const op = u("X", [{ qubit: 3 }], [{ qubit: 0 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 0 }, { qubit: 3 }]); +}); + +test("getWireRange: multiple quantum controls bracketing the target", () => { + // CCX-like with target on q3 and controls on q0 and q5 \u2014 endpoints are the outermost + // controls, not the target. + const op = u("X", [{ qubit: 3 }], [{ qubit: 0 }, { qubit: 5 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 0 }, { qubit: 5 }]); +}); + +test("getWireRange: mixed quantum + classical controls \u2014 each contributes its own row", () => { + // X @ q3 with a quantum control on q1 and a classical ref to q5r0. Geometric stack top-to-bottom: + // q1 (control), q3 (target), q5 (a quantum wire we cross), q5.r0 (classical row, BELOW q5). So + // min = q1, max = q5.r0. + const op = u("X", [{ qubit: 3 }], [{ qubit: 1 }, { qubit: 5, result: 0 }]); + assert.deepEqual(getWireRange(op), [{ qubit: 1 }, { qubit: 5, result: 0 }]); +}); + +test("getWireRange: endpoints are fresh objects, not aliases of op's registers", () => { + // Same hazard as `getChildTargets`: callers shouldn't be able to mutate the op's own register + // state via the return value. + const opTargets = [{ qubit: 3 }]; + const op = u("H", opTargets); + const range = getWireRange(op); + assert.ok(range); + assert.notEqual( + range[0], + opTargets[0], + "min endpoint must be a fresh object", + ); + assert.notEqual( + range[1], + opTargets[0], + "max endpoint must be a fresh object", + ); + range[0].qubit = 999; + assert.equal(opTargets[0].qubit, 3, "op's register must be untouched"); +}); + +// ============================================================ +// getOuterColumnSiblingWires +// ============================================================ +// +// Used by the shift-extend dropzone filter to identify wires that an op cannot directly extend onto +// because an external sibling in the op's outer column already occupies them. The "cross-over" case +// (extending past an in-between sibling) is intentionally NOT covered here — that's a property of +// the action-layer overlap resolver and is tested in the circuit-actions/ suite +// (producerOrdering.test.mjs). + +// Helper: build a single-component-grid from a component list. +const grid = (/** @type {any[][]} */ componentLists) => + componentLists.map((/** @type {any[]} */ components) => ({ components })); + +test("getOuterColumnSiblingWires: null / empty / unresolvable location returns empty set", () => { + const componentGrid = grid([[u("H", [{ qubit: 0 }])]]); + assert.equal(getOuterColumnSiblingWires(componentGrid, null).size, 0); + assert.equal(getOuterColumnSiblingWires(componentGrid, "").size, 0); + // A location whose ancestor is out of bounds resolves to no parent array; the helper returns + // empty rather than throwing. + assert.equal(getOuterColumnSiblingWires(componentGrid, "5,0-0,0").size, 0); +}); + +test("getOuterColumnSiblingWires: op with no co-resident siblings returns empty set", () => { + // Top-level op alone in its column — no siblings to enumerate. + const componentGrid = grid([[u("Foo", [{ qubit: 0 }, { qubit: 1 }])]]); + const blocked = getOuterColumnSiblingWires(componentGrid, "0,0"); + assert.equal(blocked.size, 0); +}); + +test("getOuterColumnSiblingWires: returns every wire an external sibling occupies", () => { + // Column 0 holds Foo @ wires [0,1] alongside Z @ wire 3 and W @ wire 4 — both Z and W are + // external siblings of Foo. From Foo's perspective, wires 3 and 4 are blocked. (Wires 0 and 1 — + // Foo's own — are not in the set; this helper is strictly about SIBLINGS, leaving the "in-span" + // filtering to the caller.) + const componentGrid = grid([ + [ + u("Foo", [{ qubit: 0 }, { qubit: 1 }]), + u("Z", [{ qubit: 3 }]), + u("W", [{ qubit: 4 }]), + ], + ]); + const blocked = getOuterColumnSiblingWires(componentGrid, "0,0"); + assert.deepEqual( + [...blocked].sort((a, b) => a - b), + [3, 4], + ); +}); + +test("getOuterColumnSiblingWires: sibling spans expand into a wire RANGE", () => { + // A multi-wire sibling (e.g. another group / SWAP) occupies every wire from min to max. Foo @ + // [0,1] + Bar @ [3,5] → wires 3, 4, 5 all blocked from Foo's perspective. + const componentGrid = grid([ + [ + u("Foo", [{ qubit: 0 }, { qubit: 1 }]), + u("Bar", [{ qubit: 3 }, { qubit: 5 }]), + ], + ]); + const blocked = getOuterColumnSiblingWires(componentGrid, "0,0"); + assert.deepEqual( + [...blocked].sort((a, b) => a - b), + [3, 4, 5], + ); +}); + +test("getOuterColumnSiblingWires: a sibling's classical-ref endpoint extends its covered range", () => { + // Z @ q3 with a classical ref to q0r0 visually spans q1..q3 in its column — a box on q3, a + // connector descending through q1 and q2, ending at the q0r0 classical row (which sits between q0 + // and q1). The connector does NOT cross q0. The helper reports geometric coverage; deciding + // whether a drop onto a classical-connector wire is acceptable is the caller's call. + const componentGrid = grid([ + [ + u("Foo", [{ qubit: 1 }]), + u("Z", [{ qubit: 3 }], [{ qubit: 0, result: 0 }]), + ], + ]); + const blocked = getOuterColumnSiblingWires(componentGrid, "0,0"); + // q0 is ABOVE the classical row's pass-through endpoint, so it is NOT in the set. q1..q3 are. + assert.equal( + blocked.has(0), + false, + "q0 sits above the classical-ref endpoint and is not covered", + ); + assert.equal( + blocked.has(1), + true, + "q1 is crossed by the descending connector", + ); + assert.equal( + blocked.has(2), + true, + "q2 is crossed by the descending connector", + ); + assert.equal(blocked.has(3), true, "q3 is the gate body's row"); +}); + +test("getOuterColumnSiblingWires: ops in OTHER columns of the parent array do NOT block", () => { + // The helper is per-column. Foo lives in column 0; X is alone in column 1. From Foo's + // perspective, wire 3 is free (it's in a different column, not vertically adjacent). + const componentGrid = grid([ + [u("Foo", [{ qubit: 0 }, { qubit: 1 }])], + [u("X", [{ qubit: 3 }])], + ]); + const blocked = getOuterColumnSiblingWires(componentGrid, "0,0"); + assert.equal(blocked.size, 0); +}); + +test("getOuterColumnSiblingWires: nested op uses its OWN containing grid, not the top-level grid", () => { + // Foo (top-level, col 0) contains Inner (a group) at inner col 0; Inner has a sibling InnerSib at + // inner col 0 too on a different wire. From Inner's perspective, InnerSib's wire is blocked. The + // top-level X (col 0 of the outer grid, wire 5) is NOT counted — it's not Inner's co-resident + // sibling. + const inner = group("Inner", [{ qubit: 0 }], [u("H", [{ qubit: 0 }])]); + const innerSib = u("InnerSib", [{ qubit: 2 }]); + const foo = { + kind: "unitary", + gate: "Foo", + targets: [{ qubit: 0 }, { qubit: 2 }], + children: [{ components: [inner, innerSib] }], + }; + const componentGrid = grid([[foo, u("X", [{ qubit: 5 }])]]); + // Inner's location is "0,0-0,0" → outer col 0, inner col 0, opIdx 0. + const blocked = getOuterColumnSiblingWires(componentGrid, "0,0-0,0"); + assert.equal( + blocked.has(2), + true, + "InnerSib's wire (co-resident in Inner's inner column) must block", + ); + assert.equal( + blocked.has(5), + false, + "X's wire (top-level, NOT in Inner's containing grid) must not block", + ); +}); + +// ============================================================ +// getAncestorColumnSiblingWires +// ============================================================ +// +// Composes `getOuterColumnSiblingWires` across the location's full ancestor chain. Used by the +// shift-extend dropzone filter because the cascade widens every ancestor whose span doesn't already +// enclose the drop wire — collisions can show up at ANY level, not just the immediate parent's. + +test("getAncestorColumnSiblingWires: null / empty / unresolvable location returns empty set", () => { + const componentGrid = grid([[u("H", [{ qubit: 0 }])]]); + assert.equal(getAncestorColumnSiblingWires(componentGrid, null).size, 0); + assert.equal(getAncestorColumnSiblingWires(componentGrid, "").size, 0); + assert.equal(getAncestorColumnSiblingWires(componentGrid, "5,0-0,0").size, 0); +}); + +test("getAncestorColumnSiblingWires: unions sibling wires from EVERY level of the chain", () => { + // Deeply-nested op `H` at "0,0-0,0-0,0": + // - Its immediate parent `Middle` lives inside `Outer`'s inner column 0 alongside sibling + // `MidSib` @ q2 → wire 2 blocked at the Middle level. + // - `Outer` lives at top-level column 0 alongside `OuterSib` @ q5 → wire 5 blocked at the Outer + // level. + // - The chain walk must surface BOTH. + // + // This is the regression the immediate-parent-only filter misses: H's own outer-column siblings + // (none at Inner's level because Middle is the only child here) tell you nothing about wires + // Outer can extend onto. + const h = u("H", [{ qubit: 0 }]); + const middle = { + kind: "unitary", + gate: "Middle", + targets: [{ qubit: 0 }], + children: [{ components: [h] }], + }; + const midSib = u("MidSib", [{ qubit: 2 }]); + const outer = { + kind: "unitary", + gate: "Outer", + targets: [{ qubit: 0 }, { qubit: 2 }], + children: [{ components: [middle, midSib] }], + }; + const outerSib = u("OuterSib", [{ qubit: 5 }]); + const componentGrid = grid([[outer, outerSib]]); + + // H's location: outer "0,0", middle "0,0-0,0", H "0,0-0,0-0,0". + const blocked = getAncestorColumnSiblingWires(componentGrid, "0,0-0,0-0,0"); + assert.equal( + blocked.has(2), + true, + "MidSib's wire (sibling of Middle inside Outer) must block", + ); + assert.equal( + blocked.has(5), + true, + "OuterSib's wire (sibling of Outer at top level) must block", + ); + // H's own ancestor chain has no other co-resident siblings at H's own level — confirm the set is + // exactly {2, 5}. + assert.deepEqual( + [...blocked].sort((a, b) => a - b), + [2, 5], + ); +}); + +test("getAncestorColumnSiblingWires: classical-ref endpoints on ancestor-level siblings extend coverage geometrically", () => { + // Outer-level sibling Z @ q3 with a classical ref to q0r0 spans q1..q3 (connector descends + // through q1, q2 to the q0r0 classical row sitting between q0 and q1). q0 is above the endpoint + // and not covered. Same geometry rule as the single-level helper, propagated through the chain + // walk. + const h = u("H", [{ qubit: 0 }]); + const middle = { + kind: "unitary", + gate: "Middle", + targets: [{ qubit: 0 }], + children: [{ components: [h] }], + }; + const outer = { + kind: "unitary", + gate: "Outer", + targets: [{ qubit: 0 }], + children: [{ components: [middle] }], + }; + const outerSib = u("Z", [{ qubit: 3 }], [{ qubit: 0, result: 0 }]); + const componentGrid = grid([[outer, outerSib]]); + + const blocked = getAncestorColumnSiblingWires(componentGrid, "0,0-0,0-0,0"); + assert.equal( + blocked.has(0), + false, + "q0 sits above the classical-ref endpoint and is not covered", + ); + assert.equal( + blocked.has(1), + true, + "q1 is crossed by the descending connector", + ); + assert.equal( + blocked.has(2), + true, + "q2 is crossed by the descending connector", + ); + assert.equal(blocked.has(3), true, "q3 is the gate body's row"); +}); diff --git a/source/npm/qsharp/test/circuit-editor/viewState.test.mjs b/source/npm/qsharp/test/circuit-editor/viewState.test.mjs new file mode 100644 index 00000000000..c119abfcd40 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/viewState.test.mjs @@ -0,0 +1,369 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Unit tests for `ViewState` — the per-session view-preference layer that survives +// `Sqore.renderCircuit()` but is not persisted to the `.qsc` file. See +// [viewState.ts](../../ux/circuit-vis/data/viewState.ts). +// +// These tests are pure data — no JSDOM. They lock down: +// - Default state is empty. +// - setExpanded(true) and setExpanded(false) both record overrides. +// - Collapsing a parent prunes user overrides on its descendants so re-expanding doesn't +// resurface stale child choices. +// - applyTo writes overrides into a component grid, leaves non-overridden ops alone, and recurses +// into children. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { ViewState } from "../../dist/ux/circuit-vis/data/viewState.js"; + +// --------------------------------------------------------------------------- +// Storage primitives: setExpanded / clearExpanded +// --------------------------------------------------------------------------- + +test("ViewState: setExpanded records expand and collapse choices", () => { + const v = new ViewState(); + v.setExpanded("0,0", true); + v.setExpanded("1,0", false); + assert.equal(v.expanded.get("0,0"), true); + assert.equal(v.expanded.get("1,0"), false); + assert.equal(v.expanded.size, 2); +}); + +test("ViewState: setExpanded(true) does NOT clear descendants", () => { + // Re-expanding a parent leaves descendant choices alone — the user's prior choices on the body of + // the group resurface when the group is shown again. + const v = new ViewState(); + v.setExpanded("0,0", true); + v.setExpanded("0,0-1,0", false); + v.setExpanded("0,0-2,1", true); + + v.setExpanded("0,0", true); // idempotent re-expand + + assert.equal(v.expanded.get("0,0-1,0"), false); + assert.equal(v.expanded.get("0,0-2,1"), true); +}); + +test("ViewState: setExpanded(false) prunes descendant overrides", () => { + // Collapsing a parent forgets descendant choices so re-expanding doesn't auto-spring + // previously-expanded children back open. + const v = new ViewState(); + v.setExpanded("0,0", true); + v.setExpanded("0,0-1,0", true); + v.setExpanded("0,0-2,1", false); + v.setExpanded("1,0", true); // sibling, must NOT be pruned + + v.setExpanded("0,0", false); + + assert.equal(v.expanded.get("0,0"), false); + assert.equal( + v.expanded.has("0,0-1,0"), + false, + "descendant override should be pruned", + ); + assert.equal( + v.expanded.has("0,0-2,1"), + false, + "descendant override should be pruned", + ); + assert.equal( + v.expanded.get("1,0"), + true, + "sibling override must survive (not a descendant)", + ); +}); + +test("ViewState: setExpanded(false) does not match prefix of unrelated location", () => { + // "0,10" is NOT a descendant of "0,1". The prune logic uses the `-` separator explicitly so + // location-string substrings can't accidentally match. + const v = new ViewState(); + v.setExpanded("0,1", true); + v.setExpanded("0,10", true); + + v.setExpanded("0,1", false); + + assert.equal(v.expanded.get("0,10"), true, "0,10 is a sibling, not a child"); +}); + +test("ViewState: clearExpanded drops the entry, falling back to defaults", () => { + const v = new ViewState(); + v.setExpanded("0,0", true); + v.clearExpanded("0,0"); + assert.equal(v.expanded.has("0,0"), false); +}); + +// --------------------------------------------------------------------------- +// applyTo: write overrides into a rendered component grid +// --------------------------------------------------------------------------- + +test("ViewState: applyTo writes overrides into a component grid", () => { + const v = new ViewState(); + v.setExpanded("0,0", true); + v.setExpanded("1,0", false); + + /** @type {any} */ + const grid = [ + { + components: [ + { + kind: "unitary", + gate: "Foo", + targets: [{ qubit: 0 }], + dataAttributes: { location: "0,0" }, + children: [], + }, + ], + }, + { + components: [ + { + kind: "unitary", + gate: "Bar", + targets: [{ qubit: 0 }], + dataAttributes: { location: "1,0", expanded: "true" }, // pre-set, will be overridden + children: [], + }, + ], + }, + ]; + + v.applyTo(grid); + + assert.equal(grid[0].components[0].dataAttributes.expanded, "true"); + assert.equal( + grid[1].components[0].dataAttributes.expanded, + "false", + "user collapse must override pre-existing default-expanded flag", + ); +}); + +test("ViewState: applyTo leaves non-overridden ops untouched", () => { + const v = new ViewState(); + // No overrides at all. + + /** @type {any} */ + const grid = [ + { + components: [ + { + kind: "unitary", + gate: "Foo", + targets: [{ qubit: 0 }], + dataAttributes: { location: "0,0", expanded: "true" }, // default-expanded + }, + { + kind: "unitary", + gate: "Bar", + targets: [{ qubit: 0 }], + dataAttributes: { location: "0,1" }, // no flag + }, + ], + }, + ]; + + v.applyTo(grid); + + assert.equal( + grid[0].components[0].dataAttributes.expanded, + "true", + "no override → preserve existing flag", + ); + assert.equal( + grid[0].components[1].dataAttributes.expanded, + undefined, + "no override → preserve absence of flag", + ); +}); + +test("ViewState: applyTo recurses into children grids", () => { + const v = new ViewState(); + v.setExpanded("0,0-1,0", true); + + /** @type {any} */ + const grid = [ + { + components: [ + { + kind: "unitary", + gate: "Outer", + targets: [{ qubit: 0 }], + dataAttributes: { location: "0,0" }, + children: [ + { components: [] }, + { + components: [ + { + kind: "unitary", + gate: "Inner", + targets: [{ qubit: 0 }], + dataAttributes: { location: "0,0-1,0" }, + children: [], + }, + ], + }, + ], + }, + ], + }, + ]; + + v.applyTo(grid); + + assert.equal( + grid[0].components[0].children[1].components[0].dataAttributes.expanded, + "true", + ); +}); + +test("ViewState: applyTo skips ops without a location attribute", () => { + // Defensive: ops without a location can't be addressed by viewState entries anyway. applyTo must + // not crash on them. + const v = new ViewState(); + v.setExpanded("0,0", true); + + /** @type {any} */ + const grid = [ + { + components: [ + { + kind: "unitary", + gate: "Foo", + targets: [{ qubit: 0 }], + // no dataAttributes + }, + { + kind: "unitary", + gate: "Bar", + targets: [{ qubit: 0 }], + dataAttributes: { location: "0,0" }, + }, + ], + }, + ]; + + v.applyTo(grid); + + // Op without location: untouched. + assert.equal(grid[0].components[0].dataAttributes, undefined); + // Op with location and matching override: flag set. + assert.equal(grid[0].components[1].dataAttributes.expanded, "true"); +}); + +// --------------------------------------------------------------------------- +// rebase: key-migration across editor mutations. +// +// `Sqore` snapshots an op → location map after every render and calls `rebase` at the start of the +// next render with the (oldLoc → newLoc | null) derived from object identity. These tests pin down +// the pure-data rewrite semantics that `Sqore.rebaseViewState()` relies on. +// --------------------------------------------------------------------------- + +test("ViewState: rebase rekeys entries to their new locations", () => { + // User expanded "0,1"; the op then shifted to "0,2" because a sibling was inserted ahead of it. + // The expanded state must follow the op. + const v = new ViewState(); + v.setExpanded("0,1", true); + + v.rebase(new Map([["0,1", "0,2"]])); + + assert.equal(v.expanded.has("0,1"), false, "old key removed"); + assert.equal(v.expanded.get("0,2"), true, "new key carries the value"); + assert.equal(v.expanded.size, 1); +}); + +test("ViewState: rebase drops entries when the op is gone", () => { + // User expanded "1,0"; the op was then deleted (drag-out-delete). `null` in the remap signals "op + // no longer in the grid" and the entry must be dropped. + const v = new ViewState(); + v.setExpanded("1,0", true); + v.setExpanded("0,0", false); + + v.rebase( + new Map([ + ["1,0", null], + ["0,0", "0,0"], // unchanged + ]), + ); + + assert.equal(v.expanded.has("1,0"), false, "removed op's entry dropped"); + assert.equal(v.expanded.get("0,0"), false, "unchanged entry preserved"); +}); + +test("ViewState: rebase leaves untracked keys untouched", () => { + // If the caller has no information about an old key (key absent from the remap), the entry must + // stay. This is the "safe default" path — Sqore exercises it on the very first render (no prior + // snapshot) and the rebase becomes a no-op. + const v = new ViewState(); + v.setExpanded("0,0", true); + v.setExpanded("1,0", false); + + v.rebase(new Map()); // empty remap + + assert.equal(v.expanded.get("0,0"), true); + assert.equal(v.expanded.get("1,0"), false); + assert.equal(v.expanded.size, 2); +}); + +test("ViewState: rebase preserves the recorded value (true vs false)", () => { + // Migration must carry the user's collapse choice forward just as it carries an expand choice. + // Tests both polarities in one shot. + const v = new ViewState(); + v.setExpanded("0,0", true); + v.setExpanded("0,1", false); + + v.rebase( + new Map([ + ["0,0", "0,1"], + ["0,1", "0,2"], + ]), + ); + + assert.equal(v.expanded.get("0,1"), true, "expand carried forward"); + assert.equal(v.expanded.get("0,2"), false, "collapse carried forward"); +}); + +test("ViewState: rebase is a no-op when newKey === oldKey", () => { + // Identity remap entries should not perturb the underlying map — they happen in droves on renders + // that don't shift any ops. + const v = new ViewState(); + v.setExpanded("0,0", true); + + v.rebase(new Map([["0,0", "0,0"]])); + + assert.equal(v.expanded.get("0,0"), true); + assert.equal(v.expanded.size, 1); +}); + +test("ViewState: rebase handles a key swap correctly", () => { + // Two ops swap positions. The remap names both. Each entry should end up at the other's old + // location. The order matters internally (snapshot pairs before mutating) but the visible result + // must be a clean swap regardless. + const v = new ViewState(); + v.setExpanded("0,0", true); + v.setExpanded("0,1", false); + + v.rebase( + new Map([ + ["0,0", "0,1"], + ["0,1", "0,0"], + ]), + ); + + assert.equal(v.expanded.get("0,0"), false, "0,1's value moved to 0,0"); + assert.equal(v.expanded.get("0,1"), true, "0,0's value moved to 0,1"); + assert.equal(v.expanded.size, 2); +}); + +test("ViewState: rebase rekeys nested locations the same way", () => { + // Descendant locations inside an expanded group follow the same rekey path — the algorithm is + // purely string-based. + const v = new ViewState(); + v.setExpanded("0,1-1,0", true); // an op inside the group at 0,1 + + // Group itself shifted from 0,1 to 0,2 (sibling inserted), so the descendant also moves. + v.rebase(new Map([["0,1-1,0", "0,2-1,0"]])); + + assert.equal(v.expanded.has("0,1-1,0"), false); + assert.equal(v.expanded.get("0,2-1,0"), true); +}); diff --git a/source/npm/qsharp/test/circuits-cases/bell-pair.qs.snapshot.html b/source/npm/qsharp/test/circuits-cases/bell-pair.qs.snapshot.html index 9343a37e805..5686ff2500d 100644 --- a/source/npm/qsharp/test/circuits-cases/bell-pair.qs.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/bell-pair.qs.snapshot.html @@ -357,28 +357,28 @@ x1="242" x2="242" y1="222" - y2="273" + y2="283" class="register-classical" /> @@ -390,7 +390,7 @@ x="80" y="46" width="243" - height="258" + height="268" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -964,28 +964,28 @@ x1="242" x2="242" y1="222" - y2="273" + y2="283" class="register-classical" /> @@ -997,7 +997,7 @@ x="80" y="46" width="243" - height="258" + height="268" fill-opacity="0" stroke-dasharray="8, 8" /> diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-nested.qsc b/source/npm/qsharp/test/circuits-cases/classical-control-nested.qsc new file mode 100644 index 00000000000..b1d7f6ed39c --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/classical-control-nested.qsc @@ -0,0 +1,68 @@ +{ + "_comment": "Two nested classically-controlled groups sharing the same producer (q0). Both groups' box tops live in the same gap (between q0's wire and q0's classical sub-wire), so labels and top dashed borders must STACK rather than overlap. Exercises: (a) `heightAboveFirstClassical` accumulating per-producer (running max of current depth); (b) the `targetsY`-extension fix in process.ts so the inner group's `topPadding` propagates up to the outer's `maxTopPadding` (otherwise both groups render with identical `topPadding` and their box tops merge).", + "circuits": [ + { + "qubits": [ + { "id": 0, "numResults": 1 }, + { "id": 1, "numResults": 0 } + ], + "componentGrid": [ + { + "components": [ + { + "kind": "measurement", + "gate": "Measure", + "qubits": [{ "qubit": 0 }], + "results": [{ "qubit": 0, "result": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if outer", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "H", + "targets": [{ "qubit": 1 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if inner", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "X", + "targets": [{ "qubit": 1 }] + } + ] + } + ], + "targets": [{ "qubit": 1 }], + "controls": [{ "qubit": 0, "result": 0 }], + "isConditional": true + } + ] + } + ], + "targets": [{ "qubit": 1 }], + "controls": [{ "qubit": 0, "result": 0 }], + "isConditional": true + } + ] + } + ] + } + ], + "version": 1 +} diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-nested.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/classical-control-nested.qsc.snapshot.html new file mode 100644 index 00000000000..71921e73611 --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/classical-control-nested.qsc.snapshot.html @@ -0,0 +1,742 @@ + + + + + + + +
+
+
+

Toolbox

+ + + + + + + Rx + + + + + + + + + + + + + + + + + Ry + + + + + + + + + + Y + + + + + + + + + + Rz + + + + + + + + + + Z + + + + + + + + + + S + + + + + + + + + + T + + + + + + + + + + H + + + + + + + + + + SX + + + + + + + + + + |0⟩ + + + + + + + + + + + + +
+
+
+ + + + | + ψ + 0 + ⟩ + + + | + ψ + 1 + ⟩ + + + + + + + + + + + + + + + + + + + + + + + + + + + c + 0 + + + + + + + + + + H + + + + + + + + + + + c + 0 + + + + + + + + + + + + + + if + inner + + + + + + + + + if + outer + + + + + + + + + + + + +
+ +
+ + diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-producer-above.qsc b/source/npm/qsharp/test/circuits-cases/classical-control-producer-above.qsc new file mode 100644 index 00000000000..514d93577e7 --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/classical-control-producer-above.qsc @@ -0,0 +1,56 @@ +{ + "_comment": "Classically-controlled group whose producer (q0) is above its body (q1, q2). The group is NOT on the top qubit wire. Exercises: (a) `heightAboveFirstClassical` reserving room for the label / top dashed border in the gap between q0's wire and its classical sub-wire; (b) `heightAboveWire` on the producer's qubit MUST NOT be bumped — otherwise toggling expand/collapse on this group would shift the entire circuit vertically.", + "circuits": [ + { + "qubits": [ + { "id": 0, "numResults": 1 }, + { "id": 1, "numResults": 0 }, + { "id": 2, "numResults": 0 } + ], + "componentGrid": [ + { + "components": [ + { + "kind": "measurement", + "gate": "Measure", + "qubits": [{ "qubit": 0 }], + "results": [{ "qubit": 0, "result": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "H", + "targets": [{ "qubit": 1 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "X", + "targets": [{ "qubit": 2 }] + } + ] + } + ], + "targets": [{ "qubit": 1 }, { "qubit": 2 }], + "controls": [{ "qubit": 0, "result": 0 }], + "isConditional": true + } + ] + } + ] + } + ], + "version": 1 +} diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-producer-above.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/classical-control-producer-above.qsc.snapshot.html new file mode 100644 index 00000000000..bc9b55b1829 --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/classical-control-producer-above.qsc.snapshot.html @@ -0,0 +1,767 @@ + + + + + + + +
+
+
+

Toolbox

+ + + + + + + Rx + + + + + + + + + + + + + + + + + Ry + + + + + + + + + + Y + + + + + + + + + + Rz + + + + + + + + + + Z + + + + + + + + + + S + + + + + + + + + + T + + + + + + + + + + H + + + + + + + + + + SX + + + + + + + + + + |0⟩ + + + + + + + + + + + + +
+
+
+ + + + | + ψ + 0 + ⟩ + + + | + ψ + 1 + ⟩ + + + | + ψ + 2 + ⟩ + + + + + + + + + + + + + + + + + + + + + + + + + + + + c + 0 + + + + + + + + + + H + + + + + + + + + + + + + + if + + + + + + + + + + + + +
+ +
+ + diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc b/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc new file mode 100644 index 00000000000..6d5f06334e9 --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc @@ -0,0 +1,56 @@ +{ + "_comment": "Classically-controlled group whose producer (q2) is BELOW its body (q0, q1). Exercises bottom-border placement: when there is a producer below the body, the group's bottom dashed border lives BELOW the producer's classical sub-wires, not below the body's bottom quantum wire. The bottom border bump must therefore go onto the producer's `heightBelowWire`, not the body-bottom qubit's.", + "circuits": [ + { + "qubits": [ + { "id": 0, "numResults": 0 }, + { "id": 1, "numResults": 0 }, + { "id": 2, "numResults": 1 } + ], + "componentGrid": [ + { + "components": [ + { + "kind": "measurement", + "gate": "Measure", + "qubits": [{ "qubit": 2 }], + "results": [{ "qubit": 2, "result": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "H", + "targets": [{ "qubit": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "X", + "targets": [{ "qubit": 1 }] + } + ] + } + ], + "targets": [{ "qubit": 0 }, { "qubit": 1 }], + "controls": [{ "qubit": 2, "result": 0 }], + "isConditional": true + } + ] + } + ] + } + ], + "version": 1 +} diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc.snapshot.html new file mode 100644 index 00000000000..3e405476e23 --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/classical-control-producer-below.qsc.snapshot.html @@ -0,0 +1,767 @@ + + + + + + + +
+
+
+

Toolbox

+ + + + + + + Rx + + + + + + + + + + + + + + + + + Ry + + + + + + + + + + Y + + + + + + + + + + Rz + + + + + + + + + + Z + + + + + + + + + + S + + + + + + + + + + T + + + + + + + + + + H + + + + + + + + + + SX + + + + + + + + + + |0⟩ + + + + + + + + + + + + +
+
+
+ + + + | + ψ + 0 + ⟩ + + + | + ψ + 1 + ⟩ + + + | + ψ + 2 + ⟩ + + + + + + + + + + + + + + + + + + + + + + + + + + + + c + 0 + + + + + + + + + + H + + + + + + + + + + + + + + if + + + + + + + + + + + + +
+ +
+ + diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-producer-inside-body.qsc b/source/npm/qsharp/test/circuits-cases/classical-control-producer-inside-body.qsc new file mode 100644 index 00000000000..4b4001cc91d --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/classical-control-producer-inside-body.qsc @@ -0,0 +1,56 @@ +{ + "_comment": "Classically-controlled group whose producer (q1) sits INSIDE the body's quantum wire range (body spans q0..q2). Regular-group geometry applies: the box top is above q0's wire (`heightAboveWire` on q0), the box bottom is below q2's wire (`heightBelowWire` on q2), and `heightAboveFirstClassical` should NOT be bumped on q1 — q1's classical sub-wire is internal to the body.", + "circuits": [ + { + "qubits": [ + { "id": 0, "numResults": 0 }, + { "id": 1, "numResults": 1 }, + { "id": 2, "numResults": 0 } + ], + "componentGrid": [ + { + "components": [ + { + "kind": "measurement", + "gate": "Measure", + "qubits": [{ "qubit": 1 }], + "results": [{ "qubit": 1, "result": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "H", + "targets": [{ "qubit": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "X", + "targets": [{ "qubit": 2 }] + } + ] + } + ], + "targets": [{ "qubit": 0 }, { "qubit": 2 }], + "controls": [{ "qubit": 1, "result": 0 }], + "isConditional": true + } + ] + } + ] + } + ], + "version": 1 +} diff --git a/source/npm/qsharp/test/circuits-cases/classical-control-producer-inside-body.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/classical-control-producer-inside-body.qsc.snapshot.html new file mode 100644 index 00000000000..1eacad9a6c0 --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/classical-control-producer-inside-body.qsc.snapshot.html @@ -0,0 +1,807 @@ + + + + + + + +
+
+
+

Toolbox

+ + + + + + + Rx + + + + + + + + + + + + + + + + + Ry + + + + + + + + + + Y + + + + + + + + + + Rz + + + + + + + + + + Z + + + + + + + + + + S + + + + + + + + + + T + + + + + + + + + + H + + + + + + + + + + SX + + + + + + + + + + |0⟩ + + + + + + + + + + + + +
+
+
+ + + + | + ψ + 0 + ⟩ + + + | + ψ + 1 + ⟩ + + + | + ψ + 2 + ⟩ + + + + + + + + + + + + + + + + + + + + + + + + + + + + c + 0 + + + + + + + + + + H + + + + + + + + + + + + + + if + + + + + + + + + + + + +
+ +
+ + diff --git a/source/npm/qsharp/test/circuits-cases/conditionals.qs.snapshot.html b/source/npm/qsharp/test/circuits-cases/conditionals.qs.snapshot.html index 53e72f62c88..cf99d0f54a5 100644 --- a/source/npm/qsharp/test/circuits-cases/conditionals.qs.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/conditionals.qs.snapshot.html @@ -2649,7 +2649,7 @@ - + - - + + - + - + - + - + @@ -3058,7 +3058,7 @@ - + @@ -3305,28 +3305,28 @@ x1="689" x2="689" y1="1300" - y2="1351" + y2="1361" class="register-classical" /> @@ -3334,14 +3334,14 @@ @@ -3364,14 +3364,14 @@ @@ -3395,28 +3395,28 @@ x1="846" x2="846" y1="1710" - y2="1761" + y2="1771" class="register-classical" />
@@ -3424,14 +3424,14 @@ @@ -3485,28 +3485,28 @@ x1="805.5" x2="805.5" y1="2120" - y2="2171" + y2="2191" class="register-classical" />
@@ -3514,14 +3514,14 @@ @@ -3545,28 +3545,28 @@ x1="611" x2="611" y1="2410" - y2="2461" + y2="2471" class="register-classical" />
@@ -3604,14 +3604,14 @@ @@ -3635,28 +3635,28 @@ x1="611" x2="611" y1="2866" - y2="2917" + y2="2927" class="register-classical" /> @@ -3664,14 +3664,14 @@ @@ -3695,28 +3695,28 @@ x1="612" x2="612" y1="3146" - y2="3197" + y2="3207" class="register-classical" /> @@ -3934,14 +3934,14 @@ @@ -3964,14 +3964,14 @@ @@ -4958,9 +4958,9 @@ @@ -4973,16 +4973,16 @@ H @@ -4998,22 +4998,22 @@ @@ -5229,7 +5229,7 @@ @@ -5237,17 +5237,17 @@ - - + + @@ -5260,16 +5260,16 @@ H @@ -5286,16 +5286,16 @@ H @@ -5311,22 +5311,22 @@ @@ -5340,22 +5340,22 @@ @@ -5747,7 +5747,7 @@ @@ -5755,17 +5755,17 @@ - - + + @@ -5778,16 +5778,16 @@ H @@ -5829,22 +5829,22 @@ @@ -6333,7 +6333,7 @@ @@ -6341,17 +6341,17 @@ - - + + @@ -6364,16 +6364,16 @@ H @@ -6389,22 +6389,22 @@ @@ -6558,7 +6558,7 @@ @@ -6568,8 +6568,8 @@ - - + + @@ -6770,9 +6770,9 @@ @@ -6785,16 +6785,16 @@ H @@ -6810,22 +6810,22 @@ @@ -6978,7 +6978,7 @@ @@ -6986,17 +6986,17 @@ - - + + @@ -7009,16 +7009,16 @@ H @@ -7034,22 +7034,22 @@ @@ -7176,7 +7176,7 @@ @@ -7184,8 +7184,8 @@ - - + + @@ -8984,9 +8984,9 @@ @@ -8999,16 +8999,16 @@ H @@ -9053,22 +9053,22 @@ @@ -9187,22 +9187,22 @@ @@ -9320,7 +9320,7 @@ @@ -9330,8 +9330,8 @@ - - + + @@ -9524,16 +9524,16 @@ |0⟩ @@ -9576,16 +9576,16 @@ |0⟩ @@ -9602,16 +9602,16 @@ |0⟩ @@ -9654,16 +9654,16 @@ |0⟩ @@ -9732,16 +9732,16 @@ |0⟩ @@ -9810,16 +9810,16 @@ |0⟩ @@ -9862,16 +9862,16 @@ |0⟩ @@ -10174,16 +10174,16 @@ |0⟩ diff --git a/source/npm/qsharp/test/circuits-cases/empty.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/empty.qsc.snapshot.html index c02ba32aa0b..33b518cfdaa 100644 --- a/source/npm/qsharp/test/circuits-cases/empty.qsc.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/empty.qsc.snapshot.html @@ -15,7 +15,7 @@

Toolbox

@@ -236,21 +236,6 @@

Toolbox

/>
- - - Run - @@ -265,40 +250,42 @@

Toolbox

- - - - | - ψ - 0 - ⟩ - - - - - - + @@ -85,28 +85,28 @@ x1="513" x2="513" y1="222" - y2="273" + y2="283" class="register-classical" /> @@ -116,9 +116,9 @@ @@ -131,13 +131,13 @@ - + H @@ -151,22 +151,22 @@ @@ -376,14 +376,14 @@ Main - - + + @@ -402,7 +402,7 @@ - + @@ -466,28 +466,28 @@ x1="513" x2="513" y1="222" - y2="273" + y2="283" class="register-classical" /> @@ -497,9 +497,9 @@ @@ -512,13 +512,13 @@ - + H @@ -532,22 +532,22 @@ @@ -757,14 +757,14 @@ Main - - + + diff --git a/source/npm/qsharp/test/circuits-cases/intersecting-wires.qs.snapshot.html b/source/npm/qsharp/test/circuits-cases/intersecting-wires.qs.snapshot.html index 3a279dd2b59..e68e173230a 100644 --- a/source/npm/qsharp/test/circuits-cases/intersecting-wires.qs.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/intersecting-wires.qs.snapshot.html @@ -1291,28 +1291,28 @@ x1="369" x2="369" y1="648" - y2="699" + y2="709" class="register-classical" /> @@ -1321,28 +1321,28 @@ x1="741" x2="741" y1="648" - y2="751" + y2="761" class="register-classical" /> @@ -1351,28 +1351,28 @@ x1="369" x2="369" y1="876" - y2="927" + y2="937" class="register-classical" /> @@ -1381,28 +1381,28 @@ x1="741" x2="741" y1="876" - y2="979" + y2="989" class="register-classical" /> @@ -1414,7 +1414,7 @@ x="80" y="46" width="1030" - height="974" + height="984" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -1689,7 +1689,7 @@ x="214" y="576" width="236" - height="154" + height="164" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -1843,7 +1843,7 @@ x="214" y="804" width="236" - height="154" + height="164" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -2211,7 +2211,7 @@ x="586" y="576" width="236" - height="206" + height="216" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -2365,7 +2365,7 @@ x="586" y="804" width="236" - height="206" + height="216" fill-opacity="0" stroke-dasharray="8, 8" /> diff --git a/source/npm/qsharp/test/circuits-cases/intrinsics.qs.snapshot.html b/source/npm/qsharp/test/circuits-cases/intrinsics.qs.snapshot.html index 81019886f68..04311582b9d 100644 --- a/source/npm/qsharp/test/circuits-cases/intrinsics.qs.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/intrinsics.qs.snapshot.html @@ -180,8 +180,8 @@ xmlns="http://www.w3.org/2000/svg" class="qviz" width="567" - height="240" - viewBox="0 0 567 240" + height="250" + viewBox="0 0 567 250" > @@ -209,28 +209,28 @@ x1="419" x2="419" y1="118" - y2="169" + y2="179" class="register-classical" /> @@ -242,7 +242,7 @@ x="80" y="46" width="469" - height="164" + height="174" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -329,7 +329,7 @@ x="388" y="72" width="151" - height="128" + height="138" fill-opacity="0" stroke-dasharray="8, 8" /> diff --git a/source/npm/qsharp/test/circuits-cases/lambda.qs.snapshot.html b/source/npm/qsharp/test/circuits-cases/lambda.qs.snapshot.html index f12d7a5dc40..914bf75ad6b 100644 --- a/source/npm/qsharp/test/circuits-cases/lambda.qs.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/lambda.qs.snapshot.html @@ -230,28 +230,28 @@ x1="287" x2="287" y1="118" - y2="169" + y2="179" class="register-classical" /> @@ -263,7 +263,7 @@ x="80" y="46" width="288" - height="154" + height="164" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -676,28 +676,28 @@ x1="287" x2="287" y1="118" - y2="169" + y2="179" class="register-classical" /> @@ -709,7 +709,7 @@ x="80" y="46" width="288" - height="154" + height="164" fill-opacity="0" stroke-dasharray="8, 8" /> diff --git a/source/npm/qsharp/test/circuits-cases/loops.qs.snapshot.html b/source/npm/qsharp/test/circuits-cases/loops.qs.snapshot.html index 4ca80761182..e59c90033f4 100644 --- a/source/npm/qsharp/test/circuits-cases/loops.qs.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/loops.qs.snapshot.html @@ -1408,28 +1408,28 @@ x1="1751" x2="1751" y1="1002" - y2="1053" + y2="1073" class="register-classical" /> @@ -1441,7 +1441,7 @@ x="80" y="46" width="1762" - height="1048" + height="1068" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -1452,7 +1452,7 @@ x="90" y="72" width="1742" - height="1012" + height="1032" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -8066,28 +8066,28 @@ x1="1751" x2="1751" y1="1002" - y2="1053" + y2="1073" class="register-classical" /> @@ -8099,7 +8099,7 @@ x="80" y="46" width="1762" - height="1048" + height="1068" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -8110,7 +8110,7 @@ x="90" y="72" width="1742" - height="1012" + height="1032" fill-opacity="0" stroke-dasharray="8, 8" /> diff --git a/source/npm/qsharp/test/circuits-cases/nested-callables.qs.snapshot.html b/source/npm/qsharp/test/circuits-cases/nested-callables.qs.snapshot.html index 792ca4d0aa0..489726011d8 100644 --- a/source/npm/qsharp/test/circuits-cases/nested-callables.qs.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/nested-callables.qs.snapshot.html @@ -514,28 +514,28 @@ x1="379" x2="379" y1="170" - y2="221" + y2="231" class="register-classical" /> @@ -544,28 +544,28 @@ x1="751" x2="751" y1="170" - y2="273" + y2="283" class="register-classical" /> @@ -574,28 +574,28 @@ x1="379" x2="379" y1="398" - y2="449" + y2="459" class="register-classical" /> @@ -604,28 +604,28 @@ x1="751" x2="751" y1="398" - y2="501" + y2="511" class="register-classical" /> @@ -637,7 +637,7 @@ x="80" y="46" width="772" - height="506" + height="516" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -648,7 +648,7 @@ x="90" y="72" width="752" - height="470" + height="480" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -801,7 +801,7 @@ x="224" y="98" width="236" - height="154" + height="164" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -962,7 +962,7 @@ x="224" y="326" width="236" - height="154" + height="164" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -1261,7 +1261,7 @@ x="596" y="98" width="236" - height="206" + height="216" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -1422,7 +1422,7 @@ x="596" y="326" width="236" - height="206" + height="216" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -2128,28 +2128,28 @@ x1="379" x2="379" y1="170" - y2="221" + y2="231" class="register-classical" /> @@ -2158,28 +2158,28 @@ x1="751" x2="751" y1="170" - y2="273" + y2="283" class="register-classical" /> @@ -2188,28 +2188,28 @@ x1="379" x2="379" y1="398" - y2="449" + y2="459" class="register-classical" /> @@ -2218,28 +2218,28 @@ x1="751" x2="751" y1="398" - y2="501" + y2="511" class="register-classical" /> @@ -2251,7 +2251,7 @@ x="80" y="46" width="772" - height="506" + height="516" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -2262,7 +2262,7 @@ x="90" y="72" width="752" - height="470" + height="480" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -2415,7 +2415,7 @@ x="224" y="98" width="236" - height="154" + height="164" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -2576,7 +2576,7 @@ x="224" y="326" width="236" - height="154" + height="164" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -2875,7 +2875,7 @@ x="596" y="98" width="236" - height="206" + height="216" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -3036,7 +3036,7 @@ x="596" y="326" width="236" - height="206" + height="216" fill-opacity="0" stroke-dasharray="8, 8" /> diff --git a/source/npm/qsharp/test/circuits-cases/ops-with-gap-ranges.qs.snapshot.html b/source/npm/qsharp/test/circuits-cases/ops-with-gap-ranges.qs.snapshot.html index 95005e3be7c..72a437c8890 100644 --- a/source/npm/qsharp/test/circuits-cases/ops-with-gap-ranges.qs.snapshot.html +++ b/source/npm/qsharp/test/circuits-cases/ops-with-gap-ranges.qs.snapshot.html @@ -1580,28 +1580,28 @@ x1="287" x2="287" y1="768" - y2="819" + y2="829" class="register-classical" /> @@ -1670,28 +1670,28 @@ x1="287" x2="287" y1="1090" - y2="1141" + y2="1151" class="register-classical" /> @@ -1703,7 +1703,7 @@ x="80" y="46" width="288" - height="1126" + height="1136" fill-opacity="0" stroke-dasharray="8, 8" /> @@ -4245,28 +4245,28 @@ x1="287" x2="287" y1="768" - y2="819" + y2="829" class="register-classical" /> @@ -4335,28 +4335,28 @@ x1="287" x2="287" y1="1090" - y2="1141" + y2="1151" class="register-classical" /> @@ -4368,7 +4368,7 @@ x="80" y="46" width="288" - height="1126" + height="1136" fill-opacity="0" stroke-dasharray="8, 8" /> diff --git a/source/npm/qsharp/test/circuits-cases/quantum-control-classical-group.qsc b/source/npm/qsharp/test/circuits-cases/quantum-control-classical-group.qsc new file mode 100644 index 00000000000..3b337eeb984 --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/quantum-control-classical-group.qsc @@ -0,0 +1,57 @@ +{ + "_comment": "Classically-controlled group (`if (c_0)`) on q2,q3 with an EXTRA quantum control on q1 — the post-B5 mixed-controls scenario. Verifies that the classical ref renders as the dashed `c_0` circle attached to the box's left edge while the quantum control on q1 renders as a standard solid control dot on its qubit wire (NOT as a classical circle). The snapshot harness force-expands all groups via renderDepth, so this exercises the expanded branch of `_groupedOperations`; the collapsed branch reuses the same `_getQuantumControlYs` + `_renderQuantumGroupControls` path.", + "circuits": [ + { + "qubits": [ + { "id": 0, "numResults": 1 }, + { "id": 1, "numResults": 0 }, + { "id": 2, "numResults": 0 }, + { "id": 3, "numResults": 0 } + ], + "componentGrid": [ + { + "components": [ + { + "kind": "measurement", + "gate": "Measure", + "qubits": [{ "qubit": 0 }], + "results": [{ "qubit": 0, "result": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "if", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "H", + "targets": [{ "qubit": 2 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "X", + "targets": [{ "qubit": 3 }] + } + ] + } + ], + "targets": [{ "qubit": 2 }, { "qubit": 3 }], + "controls": [{ "qubit": 1 }, { "qubit": 0, "result": 0 }], + "isConditional": true + } + ] + } + ] + } + ], + "version": 1 +} diff --git a/source/npm/qsharp/test/circuits-cases/quantum-control-classical-group.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/quantum-control-classical-group.qsc.snapshot.html new file mode 100644 index 00000000000..dfb845b48ff --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/quantum-control-classical-group.qsc.snapshot.html @@ -0,0 +1,873 @@ + + + + + + + +
+
+
+

Toolbox

+ + + + + + + Rx + + + + + + + + + + + + + + + + + Ry + + + + + + + + + + Y + + + + + + + + + + Rz + + + + + + + + + + Z + + + + + + + + + + S + + + + + + + + + + T + + + + + + + + + + H + + + + + + + + + + SX + + + + + + + + + + |0⟩ + + + + + + + + + + + + +
+
+
+ + + + | + ψ + 0 + ⟩ + + + | + ψ + 1 + ⟩ + + + | + ψ + 2 + ⟩ + + + | + ψ + 3 + ⟩ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + c + 0 + + + + + + + + + + H + + + + + + + + + + + + + + if + + + + + + + + + + + + +
+ +
+ + diff --git a/source/npm/qsharp/test/circuits-cases/quantum-control-group-collapsed.qsc b/source/npm/qsharp/test/circuits-cases/quantum-control-group-collapsed.qsc new file mode 100644 index 00000000000..d6240e7cc94 --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/quantum-control-group-collapsed.qsc @@ -0,0 +1,47 @@ +{ + "_comment": "Quantum control on a COLLAPSED group. The `.qsc` snapshot harness uses `renderDepth: 999999` (force-expand), so we explicitly opt out via `dataAttributes.expanded = false`. Exercises the collapsed branch of `_groupedOperations`: the group is rendered as a single unitary summary box, with quantum control dots + connectors above (q0) and below (q3) the body. Without this case, the collapsed control path would only be reachable via interactive collapse — not snapshot-tested.", + "circuits": [ + { + "qubits": [ + { "id": 0, "numResults": 0 }, + { "id": 1, "numResults": 0 }, + { "id": 2, "numResults": 0 }, + { "id": 3, "numResults": 0 } + ], + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "dataAttributes": { "expanded": "false" }, + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "H", + "targets": [{ "qubit": 1 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "X", + "targets": [{ "qubit": 2 }] + } + ] + } + ], + "targets": [{ "qubit": 1 }, { "qubit": 2 }], + "controls": [{ "qubit": 0 }, { "qubit": 3 }] + } + ] + } + ] + } + ], + "version": 1 +} diff --git a/source/npm/qsharp/test/circuits-cases/quantum-control-group-collapsed.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/quantum-control-group-collapsed.qsc.snapshot.html new file mode 100644 index 00000000000..9338857cffc --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/quantum-control-group-collapsed.qsc.snapshot.html @@ -0,0 +1,732 @@ + + + + + + + +
+
+
+

Toolbox

+ + + + + + + Rx + + + + + + + + + + + + + + + + + Ry + + + + + + + + + + Y + + + + + + + + + + Rz + + + + + + + + + + Z + + + + + + + + + + S + + + + + + + + + + T + + + + + + + + + + H + + + + + + + + + + SX + + + + + + + + + + |0⟩ + + + + + + + + + + + + +
+
+
+ + + + | + ψ + 0 + ⟩ + + + | + ψ + 1 + ⟩ + + + | + ψ + 2 + ⟩ + + + | + ψ + 3 + ⟩ + + + + + + + + + + + + + + + + + + H + + + + + + + + + + + + + + Foo + + + + + + + + + + + + +
+ +
+ + diff --git a/source/npm/qsharp/test/circuits-cases/quantum-control-group.qsc b/source/npm/qsharp/test/circuits-cases/quantum-control-group.qsc new file mode 100644 index 00000000000..996bbabe76a --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/quantum-control-group.qsc @@ -0,0 +1,106 @@ +{ + "_comment": "Quantum control on a group (gate-vis bug fix). Three side-by-side scenarios for the new `_renderQuantumGroupControls` path in gateFormatter._groupedOperations: (a) Foo spans q1+q2, with a quantum control on q0 ABOVE the body — expect a control dot on q0 connected to the dashed box's top edge; (b) Bar spans q1+q2, with a quantum control on q3 BELOW the body — expect a control dot on q3 connected to the box's bottom edge; (c) Baz spans q0+q3 (gap qubits q1, q2 between the children), with a quantum control on q2 INSIDE the box's y range — expect a dot on q2 with no extra connector (the wire already crosses the box).", + "circuits": [ + { + "qubits": [ + { "id": 0, "numResults": 0 }, + { "id": 1, "numResults": 0 }, + { "id": 2, "numResults": 0 }, + { "id": 3, "numResults": 0 } + ], + "componentGrid": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Foo", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "H", + "targets": [{ "qubit": 1 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "X", + "targets": [{ "qubit": 2 }] + } + ] + } + ], + "targets": [{ "qubit": 1 }, { "qubit": 2 }], + "controls": [{ "qubit": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "Bar", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "Y", + "targets": [{ "qubit": 1 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "Z", + "targets": [{ "qubit": 2 }] + } + ] + } + ], + "targets": [{ "qubit": 1 }, { "qubit": 2 }], + "controls": [{ "qubit": 3 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "Baz", + "children": [ + { + "components": [ + { + "kind": "unitary", + "gate": "H", + "targets": [{ "qubit": 0 }] + } + ] + }, + { + "components": [ + { + "kind": "unitary", + "gate": "X", + "targets": [{ "qubit": 3 }] + } + ] + } + ], + "targets": [{ "qubit": 0 }, { "qubit": 3 }], + "controls": [{ "qubit": 2 }] + } + ] + } + ] + } + ], + "version": 1 +} diff --git a/source/npm/qsharp/test/circuits-cases/quantum-control-group.qsc.snapshot.html b/source/npm/qsharp/test/circuits-cases/quantum-control-group.qsc.snapshot.html new file mode 100644 index 00000000000..ed1f026157e --- /dev/null +++ b/source/npm/qsharp/test/circuits-cases/quantum-control-group.qsc.snapshot.html @@ -0,0 +1,1257 @@ + + + + + + + +
+
+
+

Toolbox

+ + + + + + + Rx + + + + + + + + + + + + + + + + + Ry + + + + + + + + + + Y + + + + + + + + + + Rz + + + + + + + + + + Z + + + + + + + + + + S + + + + + + + + + + T + + + + + + + + + + H + + + + + + + + + + SX + + + + + + + + + + |0⟩ + + + + + + + + + + + + +
+
+
+ + + + | + ψ + 0 + ⟩ + + + | + ψ + 1 + ⟩ + + + | + ψ + 2 + ⟩ + + + | + ψ + 3 + ⟩ + + + + + + + + + + + + + + + + + + H + + + + + + + + + + + + + + Foo + + + + + + + + + + + + + + + Y + + + + + + + + + + Z + + + + + + + Bar + + + + + + + + + + + + + + + H + + + + + + + + + + + + + + Baz + + + + + + + + + + + + +
+ +
+ + diff --git a/source/npm/qsharp/test/state-viz-cases/state_viz_snapshot_-_single_basis_state.snapshot.html b/source/npm/qsharp/test/state-viz/cases/state_viz_snapshot_-_single_basis_state.snapshot.html similarity index 100% rename from source/npm/qsharp/test/state-viz-cases/state_viz_snapshot_-_single_basis_state.snapshot.html rename to source/npm/qsharp/test/state-viz/cases/state_viz_snapshot_-_single_basis_state.snapshot.html diff --git a/source/npm/qsharp/test/state-viz-cases/state_viz_snapshot_-_superposition_with_phase.snapshot.html b/source/npm/qsharp/test/state-viz/cases/state_viz_snapshot_-_superposition_with_phase.snapshot.html similarity index 100% rename from source/npm/qsharp/test/state-viz-cases/state_viz_snapshot_-_superposition_with_phase.snapshot.html rename to source/npm/qsharp/test/state-viz/cases/state_viz_snapshot_-_superposition_with_phase.snapshot.html diff --git a/source/npm/qsharp/test/state-viz-cases/state_viz_snapshot_-_threshold_aggregates_to_Others.snapshot.html b/source/npm/qsharp/test/state-viz/cases/state_viz_snapshot_-_threshold_aggregates_to_Others.snapshot.html similarity index 100% rename from source/npm/qsharp/test/state-viz-cases/state_viz_snapshot_-_threshold_aggregates_to_Others.snapshot.html rename to source/npm/qsharp/test/state-viz/cases/state_viz_snapshot_-_threshold_aggregates_to_Others.snapshot.html diff --git a/source/npm/qsharp/test/stateCompute.test.mjs b/source/npm/qsharp/test/state-viz/stateCompute.test.mjs similarity index 97% rename from source/npm/qsharp/test/stateCompute.test.mjs rename to source/npm/qsharp/test/state-viz/stateCompute.test.mjs index e297edeb9c2..26950da18b9 100644 --- a/source/npm/qsharp/test/stateCompute.test.mjs +++ b/source/npm/qsharp/test/state-viz/stateCompute.test.mjs @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { computeAmpMapForCircuit } from "../dist/ux/circuit-vis/state-viz/worker/stateCompute.js"; -import { evaluateAngleExpression } from "../dist/ux/circuit-vis/angleExpression.js"; +import { computeAmpMapForCircuit } from "../../dist/ux/circuit-vis/state-viz/worker/stateCompute.js"; +import { evaluateAngleExpression } from "../../dist/ux/circuit-vis/angleExpression.js"; const approxEq = (a, b, eps = 1e-12) => Math.abs(a - b) <= eps; diff --git a/source/npm/qsharp/test/stateViz.js b/source/npm/qsharp/test/state-viz/stateViz.js similarity index 92% rename from source/npm/qsharp/test/stateViz.js rename to source/npm/qsharp/test/state-viz/stateViz.js index 19fe69ac8cd..b3acaae87ca 100644 --- a/source/npm/qsharp/test/stateViz.js +++ b/source/npm/qsharp/test/state-viz/stateViz.js @@ -3,7 +3,7 @@ // State visualizer snapshot tests. // -// Snapshots are stored as .html files in `test/state-viz-cases/`. +// Snapshots are stored as .html files in `test/state-viz/cases/`. // To (re)generate snapshots: // node --test --test-update-snapshots test/stateViz.js @@ -18,8 +18,8 @@ import prettier from "prettier"; import { createStatePanel, updateStatePanelFromColumns, -} from "../dist/ux/circuit-vis/state-viz/stateViz.js"; -import { prepareStateVizColumnsFromAmpMap } from "../dist/ux/circuit-vis/state-viz/worker/stateVizPrep.js"; +} from "../../dist/ux/circuit-vis/state-viz/stateViz.js"; +import { prepareStateVizColumnsFromAmpMap } from "../../dist/ux/circuit-vis/state-viz/worker/stateVizPrep.js"; const documentTemplate = ` @@ -60,10 +60,7 @@ afterEach(() => { }); function getCasesDirectory() { - return path.join( - path.dirname(fileURLToPath(import.meta.url)), - "state-viz-cases", - ); + return path.join(path.dirname(fileURLToPath(import.meta.url)), "cases"); } /** diff --git a/source/npm/qsharp/ux/circuit-vis/README.md b/source/npm/qsharp/ux/circuit-vis/README.md index 0a33f95dc05..9df96c06c7e 100644 --- a/source/npm/qsharp/ux/circuit-vis/README.md +++ b/source/npm/qsharp/ux/circuit-vis/README.md @@ -1 +1,447 @@ -The code in this folder was copied and adapted from `https://github.com/microsoft/quantum-viz.js`. +# Circuit editor — architecture walkthrough + +> Structural reference for the `circuit-vis` circuit editor. Captures the layering the code follows +> so contributors can navigate it without reverse-engineering it from imports. + +## TL;DR + +The circuit editor is split into three layers: + +``` +data/ ← persistent circuit definition + value types +actions/ ← pure mutations against data, plus session state +editor/ ← DOM glue: controllers, overlays, context menu +renderer/ ← layout + SVG generation (View, called by sqore.ts) +state-viz/ ← state visualization panel (parallel subsystem) +``` + +Plus a few top-level files: + +- `sqore.ts` — entrypoint. `new Sqore(...).draw()` renders, and on every render hands control to + either the read-only path or `installEditor`. +- `utils.ts` — pure shared helpers, no DOM (location-walking `findOperation` family, register + helpers, `mathChars`). +- `index.ts` — public API barrel. +- `angleExpression.ts` — parse/normalize/evaluate gate rotation angle expressions (handles `pi`/`π` + and simple arithmetic). + +The hard rule that drives the layering: **`data/` and `actions/` are pure data and never touch the +DOM.** That's why the [circuitActions](actions/circuitActions.ts), +[circuitModel](data/circuitModel.ts), and [location](data/location.ts) tests can be plain Node tests +with no JSDOM, while the editor controllers use a tiny JSDOM in their test harnesses. + +```mermaid +flowchart TB + sqore["sqore.ts
(entrypoint)"] + subgraph View["View layer (DOM)"] + editor["editor/
shell · dropzones · controllers/"] + renderer["renderer/
layout · SVG"] + end + subgraph Action["Action layer (pure)"] + actions["actions/
circuitActions · interactionActions"] + end + subgraph Data["Data layer (pure)"] + data["data/
Circuit · CircuitModel · Location"] + end + + sqore --> editor + sqore --> renderer + editor --> actions + editor --> renderer + actions --> data + renderer -->|read-only| data +``` + +Arrows are _depends on_. `actions/` and `data/` have no edges out to `editor/` or `renderer/` — +that's the testability boundary. + +--- + +## Module map + +``` +ux/circuit-vis/ +├── sqore.ts ← entrypoint (Sqore class) +├── utils.ts ← pure helpers: findOperation/findParentArray/... + register helpers +├── index.ts ← public re-exports +├── angleExpression.ts ← parse/normalize/evaluate gate angle expressions (π-aware) +│ +├── data/ ← Data layer (no DOM, no actions) +│ ├── circuit.ts Circuit/ComponentGrid/Operation types +│ ├── circuitModel.ts CircuitModel: invariants + qubitUseCounts +│ ├── location.ts Location: hierarchical address value type +│ ├── register.ts Register / qubit-id helpers +│ └── viewState.ts ViewState: per-session view prefs (e.g. expand/collapse) +│ +├── actions/ ← Action layer (mutates data, no DOM) +│ ├── circuitActions.ts addOperation/moveOperation/etc against CircuitModel +│ ├── interactionState.ts InteractionState: ephemeral session state +│ ├── interactionActions.ts resetTransient/clearSelection/etc against InteractionState +│ └── circuit-actions/ circuitActions.ts internals (grid/move/ref helpers) +│ ├── gridPrimitives.ts addOp/removeOp, overlap resolution, measurement-line edits +│ ├── move.ts moveX/moveY placement + measurement-wire collection +│ ├── ancestors.ts ancestor-chain walking for nested (grouped) ops +│ ├── classicalRefs.ts classical-register ref remap on move/delete +│ └── derivedTargets.ts refresh/prune derived .targets + span-change resolution +│ +├── editor/ ← View layer (DOM glue, controllers) +│ ├── installEditor.ts Editor-mode bootstrap (one call from sqore.ts) +│ ├── events.ts CircuitEvents: builds InteractionContext, owns controllers +│ ├── shell.ts DOM shell: wrapper, toolbox panel, empty-msg, classes +│ ├── toolbox.ts createToolboxElement (+ optional Run button) +│ ├── toolboxGates.ts toolboxGateDictionary (gate templates) +│ ├── standaloneRenderData.ts toRenderData for ghosts / toolbox icons +│ ├── domUtils.ts DOM lookups (findGateElem/getWireData/getHostElems/parseWireYs/...) +│ ├── draggable.ts createDropzones, ghost helpers, wire-dropzone factory +│ ├── contextMenu.ts right-click menu (uses CircuitEvents shim) +│ ├── prompts.ts confirm + input prompt primitives, delete/move confirm + arg-entry flows +│ │ +│ └── controllers/ Pointer/keyboard event translation +│ ├── interactionContext.ts InteractionContext: shared deps for controllers +│ ├── dragController.ts gate-drag, toolbox-drag, dropzone commit, doc-mouseup, add/remove control +│ ├── qubitController.ts qubit-label drag + remove-qubit-with-confirm +│ ├── selectionController.ts host-element mousedown + context-menu attach +│ ├── keyboardController.ts Ctrl-toggle move/copy mode +│ └── scrollController.ts enableAutoScroll() shared by gate + qubit drags +│ +│ +├── renderer/ ← View layer (layout + SVG generation) +│ ├── process.ts layout pass: positions every gate, builds LayoutMap +│ ├── layoutMap.ts LayoutMap value type (geometry handed to editor) +│ ├── gateRenderData.ts render-data shape consumed by formatters +│ ├── gateWidth.ts getMinGateWidth: gate-width math from render data +│ ├── constants.ts gate sizes, paddings, SVG NS +│ └── formatters/ render-data → SVG +│ ├── formatUtils.ts low-level SVG element builders (group/line/circle/...) +│ ├── gateFormatter.ts gate boxes, controls, zoom buttons +│ ├── registerFormatter.ts qubit/classical register wire lines +│ └── inputFormatter.ts qubit input labels +│ +└── state-viz/ ← parallel subsystem (state visualization panel) + └── ... +``` + +--- + +## The three layers + +### Data layer — `data/` + +The persistent circuit definition. Read by the renderer, mutated by the action layer, **never** +touches the DOM. + +- **[`Circuit`, `ComponentGrid`, `Operation`, `Qubit`](data/circuit.ts)** — the on-disk JSON shape. + Everything ultimately serializes back to this. +- **[`CircuitModel`](data/circuitModel.ts)** — wraps a `Circuit` and maintains incremental derived + state (`qubitUseCounts`). Owns its own invariants (`removeTrailingUnusedQubits`, + `ensureQubitCount`). Does **not** know how to perform user-level edits; that's the action layer's + job. + - Borrows `componentGrid` and `qubits` by reference, not copy. Intentional: the renderer (`Sqore`) + and the editor see the same arrays. +- **[`Location`](data/location.ts)** — value type for hierarchical addresses (`"0,1"` top-level, + `"0,1-2,3"` nested). Owns the parse/compose of the format so the addressing convention lives in + exactly one place. Immutable; `parent()`/`child()` return new instances. `Location.root()` is the + empty-segments case. +- **[`Register`](data/register.ts)** — qubit/result register IDs. +- **[`ViewState`](data/viewState.ts)** — per-session view preferences that survive `renderCircuit` + but are intentionally NOT serialized into the saved `.qsc` file. Today: per-group expand/collapse + overrides keyed by location string. Owned by `Sqore`; read by `renderCircuit` after the + default-expansion passes; written by the chevron click handler. Sits in `data/` because it's plain + mutable state with no DOM/action coupling — but it's a _third_ lifetime distinct from + `CircuitModel` (persisted) and `InteractionState` (single gesture). + +### Action layer — `actions/` + +Pure mutations. Each function takes a state container as its first argument and mutates it in place; +returns the affected `Operation`/`boolean` when the caller needs a handle. + +- **[`circuitActions.ts`](actions/circuitActions.ts)** — operates on `CircuitModel`. Examples: + `addOperation`, `removeOperation`, `moveOperation`, `addControl`, `removeControl`, + `removeQubitWithDependents`, `moveQubit`, `removeQubit`. No DOM. The heavier grid mechanics live + in the [`circuit-actions/`](actions/circuit-actions/) subfolder it delegates to — grid primitives, + move placement, ancestor-chain walking, classical-ref remapping, and derived-`.targets` refresh. +- **[`interactionState.ts`](actions/interactionState.ts)** — ephemeral session state container. + Holds `selectedOperation` (persists across mouseup so the context menu can use it), + `selectedWire`, `movingControl`, `mouseUpOnCircuit`, `dragging`, `disableLeftAutoScroll`, + `temporaryDropzones`. Plain mutable fields; no methods. +- **[`interactionActions.ts`](actions/interactionActions.ts)** — pure helpers (and one DOM-touching + helper, `clearTemporaryDropzones`) for the multi-step state transitions controllers need: + `resetTransient`, `beginToolboxDrag`, `trackTemporaryDropzone`, etc. + +The deliberate split between `CircuitModel` (persistent) and `InteractionState` (ephemeral) makes it +obvious which fields should never round-trip through serialization. + +### View layer — `editor/` + `renderer/` + +The renderer turns a `Circuit` into SVG; the editor wires the resulting DOM to the action layer. + +- **`renderer/`** — runs on every render. `processOperations` positions every gate and emits a + [`LayoutMap`](renderer/layoutMap.ts) alongside the SVG, so the editor can position dropzones from + the same numbers instead of reverse-engineering them from rendered attributes. +- **`editor/`** — installed once per render via [`installEditor`](editor/installEditor.ts). Builds + the editor's shell DOM, creates the dropzone overlay, and instantiates the controllers that + translate pointer/keyboard events into action calls. + +--- + +## How a render happens + +1. Host calls `new Sqore(circuitGroup, options).draw(container)` ([sqore.ts](sqore.ts)). +2. `draw` calls the private `renderCircuit(container)`, which: + - Deep-copies the circuit (so mutations don't leak back to the host), assigns `Location`-based + IDs to every op, runs the default-expansion passes (`expandOperationsToDepth`, + `expandIfSingleOperation`), then applies [`viewState`](data/viewState.ts) overrides on top so + any user-toggled expand/collapse choices win. Runs the layout pass and replaces the previous + `svg.qviz` element. + - If `options.editor` is set (editing enabled), calls + [`installEditor(container, this, layoutMap, editor, refresh)`](editor/installEditor.ts). +3. `installEditor` does four things in order: + 1. **`createDropzones`** ([draggable.ts](editor/draggable.ts)) — builds the + `` group inside `svg.qviz` and populates the dropzone + ghost-qubit + sub-layers from the `LayoutMap`. + 2. **`mountEditorShell`** ([shell.ts](editor/shell.ts)) — wraps the SVG in `.circuit-wrapper`, + prepends the `.panel` toolbox (with optional Run button), adds the empty-circuit hint when + relevant, and ensures the state-viz panel is mounted. + 3. **`enableEvents`** ([events.ts](editor/events.ts)) — disposes any previous `CircuitEvents`, + builds a new one, and fires a `qsharp:circuit:modelReady` event so state-viz can recompute. + 4. **`editor.editCallback(...)`** — notifies the host of the new minimized circuit (so the host + can persist it). +4. `Sqore.renderCircuit` is also the editor's re-render hook. The `refresh` closure handed to + `installEditor` is `() => sqore.renderCircuit(container)`. Every controller calls + `ctx.renderFn()` after a successful action. + +```mermaid +sequenceDiagram + participant Host + participant Sqore as sqore.ts + participant Renderer as renderer/process.ts + participant Install as editor/installEditor.ts + participant Shell as editor/shell.ts + participant Events as editor/events.ts + + Host->>Sqore: new Sqore(...).draw(container) + Sqore->>Sqore: renderCircuit(container) + Sqore->>Renderer: processOperations(circuit) + Renderer-->>Sqore: SVG + LayoutMap + Sqore->>Sqore: replace svg.qviz in DOM + alt editor enabled + Sqore->>Install: installEditor(container, this, layoutMap, editor, refresh) + Install->>Install: createDropzones(...) + Install->>Shell: mountEditorShell(...) + Install->>Events: enableEvents(...) + Note right of Events: builds InteractionContext,
instantiates controllers,
fires modelReady event + Install->>Host: editor.editCallback(minimized) + end +``` + +--- + +## How a click flows through the layers + +The cleanest way to read the layering is to follow a single user action end-to-end. + +### Example: "user drags an H gate from the toolbox onto wire 0" + +1. **Toolbox mousedown** ([dragController.ts](editor/controllers/dragController.ts) — + `installToolboxListeners`). Reads the toolbox-item type, calls + `beginToolboxDrag(interaction, templateOp)` which sets `selectedOperation = templateOp` and + `disableLeftAutoScroll = true`. Spawns the ghost element via `createGateGhost`, which sets + `interaction.dragging = true` ([draggable.ts](editor/draggable.ts)). Registers a temporary + dropzone overlay with `trackTemporaryDropzone`. +2. **Mousemove** is handled by `enableAutoScroll` + ([scrollController.ts](editor/controllers/scrollController.ts)) — installed at drag start, + removes itself on the next mouseup. +3. **Dropzone mouseup** ([dragController.ts](editor/controllers/dragController.ts) — + `installDropzoneListeners`). Reads `data-dropzone-location`/`data-wire` off the dropzone element, + marks `mouseUpOnCircuit = true`, and dispatches one of: + - `addOperation(model, templateOp, location, wire)` — for a fresh toolbox drop. + - `moveOperation(model, src, dst, srcWire, dstWire, ...)` — for a placed-gate move. + - `addControl(model, op, wire)` / `removeControl(...)` — for the wire-pick flow that + `contextMenu` invokes. +4. **Action** ([circuitActions.ts](actions/circuitActions.ts)) mutates the `CircuitModel`. Pure + data; no DOM. +5. **Re-render**. Controller calls `ctx.renderFn()`, which is the + `() => sqore.renderCircuit(container)` closure. Sqore re-runs the layout pass, replaces + `svg.qviz`, and `installEditor` runs again. The previous `CircuitEvents` is disposed; a new one + is built. The model is fresh from `new CircuitModel(sqore.circuit)`. +6. **Document mouseup** ([dragController.ts](editor/controllers/dragController.ts) — + `installDocumentListeners`). Cleans up the ghost, calls `resetTransient(interaction)` to clear + all transient flags and tear down the temporary dropzones, removes the auto-scroll listener. + +Every controller is read-only with respect to the others — they all read/write the same `model` and +`interaction` via the shared [`InteractionContext`](editor/controllers/interactionContext.ts), and +they all re-render via the same `renderFn`. + +```mermaid +sequenceDiagram + actor User + participant Drag as DragController + participant State as InteractionState + participant Act as circuitActions + participant Model as CircuitModel + participant Sqore as sqore.ts + + User->>Drag: mousedown on toolbox H + Drag->>State: beginToolboxDrag(templateOp) + Drag->>Drag: createGateGhost (dragging=true) + User->>Drag: mouseup on dropzone + Drag->>Act: addOperation(model, templateOp, "0,0", 0) + Act->>Model: mutate componentGrid + qubitUseCounts + Drag->>Sqore: ctx.renderFn() ⟹ renderCircuit(container) + Note right of Sqore: layout pass · replace svg ·
installEditor again ·
fresh CircuitEvents + User->>Drag: document mouseup + Drag->>State: resetTransient() + Drag->>Drag: remove ghost · clear temp dropzones +``` + +--- + +## The `InteractionContext` + +The single piece of glue every controller depends on. Built once per `CircuitEvents` instance in +[events.ts](editor/events.ts) and handed by reference to each controller: + +```ts +interface InteractionContext { + readonly model: CircuitModel; // Data layer + readonly interaction: InteractionState; // Action-layer ephemeral + readonly layoutMap: LayoutMap; // geometry from renderer + readonly container: HTMLElement; + readonly circuitSvg: SVGElement; + readonly overlayLayer: SVGGElement; // editor-only DOM lives here + readonly dropzoneLayer: SVGGElement; + readonly ghostQubitLayer: SVGGElement; + wireData: number[]; // wire Y positions; mutable + readonly renderFn: () => void; // = () => sqore.renderCircuit(container) +} +``` + +Controllers are intentionally translation-only: they own their listeners and lifecycle, but hold no +state. State lives on `model` (persistent) or `interaction` (ephemeral). That's what lets +`dragController.test.mjs` etc. construct a controller with a hand-built context and exercise it +directly. + +--- + +## Controller responsibilities + +| Controller | Surface | Notes | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [DragController](editor/controllers/dragController.ts) | gate-drag, toolbox-drag, dropzone commit, document-level mouseup, add/remove-control wire-pick | Largest controller — these flows share dropzones/ghost/`interaction` flags so splitting wouldn't separate concerns. Holds a `QubitController` ref for the qubit-label drag-out-delete path. | +| [QubitController](editor/controllers/qubitController.ts) | qubit-label drag (swap + insert-between dropzones), `removeQubitLineWithConfirmation` | Public method called from two callers: context menu (via `CircuitEvents` shim) and `DragController`'s document-mouseup handler. | +| [SelectionController](editor/controllers/selectionController.ts) | host-element mousedown (sets `selectedWire`/`movingControl`), context-menu attach | Smallest controller; runs deeper in the DOM than `DragController`'s gate handler so its state mutation is visible by the time the drag handler runs. | +| [KeyboardController](editor/controllers/keyboardController.ts) | document `keydown`/`keyup` for Ctrl-toggle move/copy | Stateless; only consults whether `selectedOperation` has a location. | +| `enableAutoScroll` ([scrollController.ts](editor/controllers/scrollController.ts)) | document `mousemove` near container edges | Function not class — no shared state, called fresh by both gate-drag and qubit-drag. Self-removes on next mouseup. | + +`CircuitEvents` itself ([events.ts](editor/events.ts)) is just wiring: build the context, +instantiate each controller, expose `dispose()` and the two +`_startAddingControl`/`_startRemovingControl` delegates that `contextMenu.ts` still calls by name. + +### Compatibility shims still on `CircuitEvents` + +Three deliberate carryovers from the pre-refactor API: + +- `componentGrid` / `qubits` / `qubitUseCounts` getters delegate to `model`. Kept so + `getCurrentCircuitModel` (consumed by state-viz) and `contextMenu.ts` keep working unchanged. +- `_startAddingControl(op, loc)` / `_startRemovingControl(op)` delegate to `DragController`. Kept so + `contextMenu.ts` doesn't need to know about controllers yet. + +These can be retired once `addContextMenuToHostElem` itself is migrated to a controller-shaped API. +Out of scope for this PR. + +--- + +## Notable invariants + +- **Editor-only DOM lives in one place.** Every node the editor creates inside `svg.qviz` lives + inside `` ([draggable.ts](editor/draggable.ts)). Renderer-owned children + of `svg.qviz` (gates, wires, register labels) stay purely presentational. Controllers append to + `ctx.overlayLayer` / `ctx.dropzoneLayer` / `ctx.ghostQubitLayer`, never directly to `svg.qviz`. +- **Re-render replaces the SVG.** `renderCircuit` swaps `svg.qviz` wholesale on every render. + Anything inside the SVG dies with it (including all listener attachments on host elements / qubit + labels / dropzones). That's why those controllers don't need `dispose()` — only + `KeyboardController` and `DragController` install document-level listeners and need to clean them + up. +- **Locations are always `Location.parse`-able.** Strings on the wire (`data-location`, + `data-dropzone-location`, `LayoutMap` keys) all round-trip through [`Location`](data/location.ts). + Out-of-bounds locations return `null` from `findOperation`/`findParentArray`/`findParentOperation` + — they no longer throw `TypeError`, which made stale-DOM-attribute races unsafe + ([utils.ts](utils.ts)). +- **`LayoutMap` is the single source of geometry.** Dropzones and the editor's ghost positioning + read from `LayoutMap`, not from rendered SVG attributes. Keeps the editor accurate for nested + scopes too. + +--- + +## Testing layout + +Tests mirror the source layering: + +``` +test/ +├── circuit-editor/ ← layered editor tests +│ ├── location.test.mjs (data/) +│ ├── circuitModel.test.mjs (data/) +│ ├── viewState.test.mjs (data/) +│ ├── circuit-actions/ (actions/ — one file per topic:) +│ │ ├── addRemove.test.mjs add/remove operations +│ │ ├── groupAddRemove.test.mjs add/remove inside expanded groups +│ │ ├── groupAncestorRefresh.test.mjs ancestor derived-target refresh +│ │ ├── groupCollisionSplit.test.mjs collision split within groups +│ │ ├── groupMove.test.mjs moving grouped operations +│ │ ├── measurementCascade.test.mjs measurement-dependency cascade +│ │ ├── moveStamp.test.mjs move placement / stamping +│ │ ├── producerOrdering.test.mjs classical producer ordering +│ │ └── qubitOps.test.mjs qubit add/remove/move +│ ├── interactionActions.test.mjs (actions/) +│ ├── findOperation.test.mjs (utils.ts nav helpers) +│ ├── utils.test.mjs (utils.ts) +│ ├── dropzones.test.mjs (editor/draggable layer) +│ ├── draggable.test.mjs (editor/draggable helpers) +│ ├── dragController.test.mjs (editor/) +│ ├── qubitController.test.mjs (editor/) +│ ├── selectionController.test.mjs (editor/) +│ ├── keyboardController.test.mjs (editor/) +│ ├── scrollController.test.mjs (editor/) +│ ├── toolbox.test.mjs (editor/) +│ ├── contextMenu.test.mjs (editor/) +│ ├── prompts.test.mjs (editor/) +│ ├── gateFormatter.test.mjs (renderer/formatters) +│ ├── angleExpression.test.mjs (angleExpression.ts) +│ ├── sqore.test.mjs (entrypoint) +│ ├── _helpers.mjs (shared test helpers — not a suite) +│ └── jsdom.d.ts (JSDOM type declarations — not a suite) +│ +├── state-viz/ ← state-viz subsystem tests +│ ├── stateCompute.test.mjs +│ ├── stateViz.js +│ └── cases/ HTML snapshots +│ +├── circuits-cases/ ← rendered-circuit HTML snapshots +│ +└── (top-level: language/compiler tests — unchanged) +``` + +`data/` and `actions/` tests are plain Node tests with no JSDOM. `editor/` controller tests +construct an `InteractionContext` from a tiny JSDOM and a fresh `CircuitModel`, then invoke the +controller directly. Support files that are not suites (`_helpers.mjs`, `jsdom.d.ts`) do not match +`*.test.mjs`, so run the suite with an explicit glob: + +```pwsh +# from source/npm/qsharp/ +node --test "test/circuit-editor/**/*.test.mjs" +``` + +--- + +## Conventions + +- **Controllers translate, they do not own.** No mutable state on the controller class itself. +- **Actions mutate, they do not render.** No DOM, no `renderFn` calls inside `actions/`. Controllers + re-render after dispatching. +- **Locations go through `Location`.** Never hand-format `${col},${op}`; always + `Location.of(...).toString()` (or use the string already on the DOM attribute). +- **`null` means "not found".** `findOperation` & friends return `null` for both "no input" and "out + of bounds" — callers stay defensive, no throws to catch. +- **One overlay group, one source of geometry.** Don't append to `svg.qviz` directly; use + `ctx.overlayLayer`. Don't measure rendered SVG; ask the `LayoutMap`. diff --git a/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/ancestors.ts b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/ancestors.ts new file mode 100644 index 00000000000..e443ab3a816 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/ancestors.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ComponentGrid, Operation } from "../../data/circuit.js"; +import { CircuitModel } from "../../data/circuitModel.js"; +import { Location } from "../../data/location.js"; +import { findOperation, findParentArray } from "../../utils.js"; + +/* + * `ancestors.ts` — ancestor-chain capture for the Action layer. + * + * A move/add/remove mutates a deeply nested op, then walks back up the tree to refresh (or prune) + * every enclosing group. Because mid-mutation column splices invalidate location strings, these + * helpers capture the chain as `(op, containingArray)` object references BEFORE any mutation — + * those survive structural edits a location string can't. Depends on the Data layer and `utils.ts`. + */ + +/** + * One rung of an ancestor chain: an ancestor op paired with the array reference that contains it, + * captured BEFORE any mutation so both stay valid even as column splices invalidate location + * strings. + */ +type AncestorRung = { op: Operation; containingArray: ComponentGrid }; + +/** + * Collect the ancestor chain of the op (or slot) at `location`, innermost-first, up to (but not + * including) the root grid. The chain is the ancestors of `location`'s parent; the op/slot at + * `location` itself is never included. + * + * Both `moveOperation` call sites use this: `sourceLocation` feeds the empty-group cleanup (the + * source op is already detached by `removeOp`), and `targetLocation` feeds the dest-side refresh + * (its parent is the dropzone's scope op). Captured rungs are object references, so they survive + * column splices that invalidate location strings. + */ +const collectAncestorChain = ( + model: CircuitModel, + location: string, +): AncestorRung[] => { + const chain: AncestorRung[] = []; + let loc = Location.parse(location).parent(); + while (!loc.isRoot) { + const locStr = loc.toString(); + const op = findOperation(model.componentGrid, locStr); + const containingArray = findParentArray(model.componentGrid, locStr); + if (op == null || containingArray == null) break; + chain.push({ op, containingArray }); + loc = loc.parent(); + } + return chain; +}; + +/** + * Walk the tree and collect the ancestor chain (innermost-first, up to but not including the root + * grid) leading to `target`, comparing by object identity. Used by mutators like + * [`addControl`](circuitActions.ts)/[`removeControl`](circuitActions.ts) that hold an `Operation` + * reference but no location. Returns `[]` if `target` is top-level or not found (callers treat both + * the same: no ancestors to refresh). + */ +const findAncestorChainForOp = ( + model: CircuitModel, + target: Operation, +): AncestorRung[] => { + const walk = ( + grid: ComponentGrid, + chain: AncestorRung[], + ): AncestorRung[] | null => { + for (const col of grid) { + for (const op of col.components) { + if (op === target) return chain; + if (op.children != null) { + const found = walk(op.children, [ + { op, containingArray: grid }, + ...chain, + ]); + if (found != null) return found; + } + } + } + return null; + }; + return walk(model.componentGrid, []) ?? []; +}; + +/** + * Like `findAncestorChainForOp` but also captures the op's own rung. `opRung.containingArray` is + * the grid one level above the op (the model's top-level grid for a top-level op, else the parent + * group's `children`). Used by widening mutators (`addControl`) that feed + * [`resolveSpanChange`](derivedTargets.ts). Returns `null` if the op isn't in the model. + */ +const findOpRungAndAncestors = ( + model: CircuitModel, + target: Operation, +): { opRung: AncestorRung; ancestorChain: AncestorRung[] } | null => { + const walk = ( + grid: ComponentGrid, + chain: AncestorRung[], + ): { opRung: AncestorRung; ancestorChain: AncestorRung[] } | null => { + for (const col of grid) { + for (const op of col.components) { + if (op === target) { + return { + opRung: { op, containingArray: grid }, + ancestorChain: chain, + }; + } + if (op.children != null) { + const found = walk(op.children, [ + { op, containingArray: grid }, + ...chain, + ]); + if (found != null) return found; + } + } + } + return null; + }; + return walk(model.componentGrid, []); +}; + +export type { AncestorRung }; +export { collectAncestorChain, findAncestorChainForOp, findOpRungAndAncestors }; diff --git a/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/classicalRefs.ts b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/classicalRefs.ts new file mode 100644 index 00000000000..cabac403b4e --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/classicalRefs.ts @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ComponentGrid, Operation } from "../../data/circuit.js"; +import { Register } from "../../data/register.js"; +import { findOperation, getOperationRegisters } from "../../utils.js"; + +/* + * `classicalRefs.ts` — classical-register producer/consumer analysis for the Action layer. + * + * Measurements produce classical registers; classically-controlled ops consume them. This module + * keeps that graph consistent across moves and deletes (document-order constraints, + * cascade-deletes, result-index remaps). Pure grid walks over the Data layer — no DOM. + */ + +/** + * Collect the classical-register IDs produced by any measurement in `op`'s subtree (including + * `op`). Keyed `":"`, the pair a consumer's classical control reads. + */ +const collectInternalClassicalRegs = (op: Operation): Set => { + const set = new Set(); + const walk = (o: Operation): void => { + if (o.kind === "measurement") { + for (const r of o.results) { + if (r.result !== undefined) { + set.add(`${r.qubit}:${r.result}`); + } + } + } + if (o.children) { + for (const col of o.children) { + for (const child of col.components) { + walk(child); + } + } + } + }; + walk(op); + return set; +}; + +/** + * Map every classical register to the location string of the measurement that produces it + * (`":"` → location). Locations use the editor's hierarchical format (`"0,1"`, + * `"0,1-2,3"`), as compared by [`Location.inEarlierColumnThan`](../../data/location.ts). + * + * Used by `collectExternalProducerLocations` (and indirectly the dropzone filter and + * `moveOperation` safety net) to enforce "producer column strictly earlier than consumer." If a key + * has multiple producers (shouldn't happen in a well-formed circuit), the last one wins. + */ +const _indexProducers = (grid: ComponentGrid): Map => { + const map = new Map(); + const walk = (g: ComponentGrid, prefix: string): void => { + g.forEach((col, ci) => { + col.components.forEach((op, oi) => { + const loc = prefix === "" ? `${ci},${oi}` : `${prefix}-${ci},${oi}`; + if (op.kind === "measurement") { + for (const r of op.results) { + if (r.result !== undefined) { + map.set(`${r.qubit}:${r.result}`, loc); + } + } + } + if (op.children) walk(op.children, loc); + }); + }); + }; + walk(grid, ""); + return map; +}; + +/** + * For the operation at `subtreeLocation`, return the locations of every measurement that produces a + * classical register the subtree consumes — but only producers living OUTSIDE the subtree. Internal + * producers travel with the consumer when the subtree moves as a unit, so they impose no + * drop-target constraint; external producers stay put, so the consumer's new position must come + * after them. + * + * Used by: + * - The dropzone-filter pass in + * [`DragController.onGateMouseDown`](../../editor/controllers/dragController.ts) to hide drop + * targets that would invert producer-before-consumer. + * - The `moveOperation` safety net (returns `null` if a producer ends up after the consumer) as + * defense in depth. + * + * Returns `[]` if the op has no external classical consumers or the subtree doesn't exist. + * Producers whose location can't be resolved are skipped. + */ +const collectExternalProducerLocations = ( + rootGrid: ComponentGrid, + subtreeLocation: string, +): string[] => { + const subtree = findOperation(rootGrid, subtreeLocation); + if (subtree == null) return []; + + // Collect internal producers (their `"qubit:result"` keys) so we can exclude them from the + // constraint check. + const internalProducers = collectInternalClassicalRegs(subtree); + + // Walk the subtree and collect every classical-ref's key that is NOT in the internal set. + const externalKeys = new Set(); + const collectRefs = (op: Operation): void => { + for (const r of getOperationRegisters(op)) { + if (r.result !== undefined) { + const key = `${r.qubit}:${r.result}`; + if (!internalProducers.has(key)) externalKeys.add(key); + } + } + if (op.children) { + for (const col of op.children) { + for (const c of col.components) collectRefs(c); + } + } + }; + collectRefs(subtree); + if (externalKeys.size === 0) return []; + + // Map every measurement in the grid to its location, then look up each external key. + const producers = _indexProducers(rootGrid); + const locations: string[] = []; + for (const key of externalKeys) { + const loc = producers.get(key); + if (loc != null) locations.push(loc); + } + return locations; +}; + +/** + * For the measurement at `mLocation`, find every downstream consumer: any op whose register fields + * hold a classical-ref `(qubit, result)` matching one of this M's `results`. Returned entries pair + * the consumer op (object reference) with its location string. Walks into nested children; the M op + * itself is excluded. + * + * Only `.controls` count as consumption (not `.targets`): a consumer is an op whose execution is + * GATED by the M's signal, which for unitaries is a `.controls` entry with `result` defined. A + * group's `.targets` is a derived cache that propagates a classically-controlled child's ref up + * into every ancestor; treating those as consumption would falsely flag every enclosing group and + * the cascade-delete would wipe out unrelated siblings. A classically-controlled group is still + * flagged correctly via its own `.controls`. + * + * Returns `[]` if the location isn't a measurement, the M has no classical results, or nothing + * references them. + */ +const collectMeasurementConsumers = ( + rootGrid: ComponentGrid, + mLocation: string, +): { op: Operation; location: string }[] => { + const mOp = findOperation(rootGrid, mLocation); + if (mOp == null || mOp.kind !== "measurement") return []; + + // Build the set of (qubit, result) keys this M produces. + const producedKeys = new Set(); + for (const r of mOp.results) { + if (r.result !== undefined) { + producedKeys.add(`${r.qubit}:${r.result}`); + } + } + if (producedKeys.size === 0) return []; + + const consumers: { op: Operation; location: string }[] = []; + const walk = (g: ComponentGrid, prefix: string): void => { + g.forEach((col, ci) => { + col.components.forEach((op, oi) => { + const loc = prefix === "" ? `${ci},${oi}` : `${prefix}-${ci},${oi}`; + // Skip the M itself, but still recurse into its children. + if (op !== mOp) { + // Logical consumption lives in `.controls` only. + const controls = op.kind === "unitary" ? op.controls : undefined; + if (controls) { + for (const reg of controls) { + if ( + reg.result !== undefined && + producedKeys.has(`${reg.qubit}:${reg.result}`) + ) { + consumers.push({ op, location: loc }); + break; + } + } + } + } + if (op.children) walk(op.children, loc); + }); + }); + }; + walk(rootGrid, ""); + return consumers; +}; + +/** + * Walk the grid and remap every classical-ref entry's `(qubit, result)` pair according to + * `keyRemap`. Visits both the op's own register-bearing fields AND the cached `.targets` / + * `.controls` on group ops (which hold their own `Register` objects, independent of descendant ops + * — see [`_dedupRegistersByIdentity`](derivedTargets.ts)). + * + * Only classical refs (`result !== undefined`) are touched; quantum refs are left alone. + */ +const applyClassicalRefRemap = ( + grid: ComponentGrid, + keyRemap: Map, +): void => { + const remapRegister = (reg: Register): void => { + if (reg.result === undefined) return; + const preKey = `${reg.qubit}:${reg.result}`; + const postKey = keyRemap.get(preKey); + if (postKey == null) return; + const colonIdx = postKey.indexOf(":"); + reg.qubit = parseInt(postKey.substring(0, colonIdx), 10); + reg.result = parseInt(postKey.substring(colonIdx + 1), 10); + }; + const walk = (g: ComponentGrid): void => { + for (const col of g) { + for (const op of col.components) { + // Remap consumer-side refs only. A measurement's `.results` is the producer side, already + // assigned by `updateMeasurementLines`; remapping it here would double-remap and collapse + // distinct Ms onto the same key. So for measurements visit only `.qubits`; for everything + // else visit all registers, which are refs to producers elsewhere. + if (op.kind === "measurement") { + for (const reg of op.qubits) remapRegister(reg); + } else { + for (const reg of getOperationRegisters(op)) { + remapRegister(reg); + } + } + if (op.children) walk(op.children); + } + } + }; + walk(grid); +}; + +/** + * Walk the grid for an op matching `target` by object identity and return its hierarchical location + * string, or `null` if not found. Used by callers (e.g. `removeMeasurementWithDependents`) that + * capture an op reference BEFORE a mutation that may shift its location, then need a fresh location + * string AFTER the mutation. + */ +const findLocationByRef = ( + grid: ComponentGrid, + target: Operation, +): string | null => { + const walk = (g: ComponentGrid, prefix: string): string | null => { + for (let ci = 0; ci < g.length; ci++) { + for (let oi = 0; oi < g[ci].components.length; oi++) { + const op = g[ci].components[oi]; + const loc = prefix === "" ? `${ci},${oi}` : `${prefix}-${ci},${oi}`; + if (op === target) return loc; + if (op.children) { + const r = walk(op.children, loc); + if (r != null) return r; + } + } + } + return null; + }; + return walk(grid, ""); +}; + +export { + applyClassicalRefRemap, + collectInternalClassicalRegs, + findLocationByRef, + collectExternalProducerLocations, + collectMeasurementConsumers, +}; diff --git a/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/derivedTargets.ts b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/derivedTargets.ts new file mode 100644 index 00000000000..76f395b4319 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/derivedTargets.ts @@ -0,0 +1,323 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ComponentGrid, Operation } from "../../data/circuit.js"; +import { Register } from "../../data/register.js"; +import { getMinMaxRegIdx, getOperationRegisters } from "../../utils.js"; +import { AncestorRung } from "./ancestors.js"; +import { doesOverlap } from "./gridPrimitives.js"; + +/* + * `derivedTargets.ts` — the eager `.targets` cache and the ancestor-refresh cascade that keeps it + * in sync. + * + * Every group op carries an authoritative `.targets` (`.results` for measurements) that is the + * deduped union of its descendants' wires. This module recomputes that cache, walks it up the + * ancestor chain, prunes emptied groups, and resolves the sibling-column collisions a widened span + * can introduce. The eager cache keeps `.targets` authoritative so the renderer and resolver read + * wire spans in O(1) instead of walking descendants on every query. + * + * Depends on `gridPrimitives` and the `AncestorRung` type; no DOM. + */ + +/** + * `true` if `op` has no rendered content — no `children`, or only empty columns. The cleanup pass + * in `moveOperation` deletes such groups (no semantics, no sensible render). + */ +const _isOperationEmpty = (op: Operation): boolean => { + if (op.children == null || op.children.length === 0) return true; + return op.children.every((col) => col.components.length === 0); +}; + +/** + * Dedup a `Register[]` by full `(qubit, result)` identity, returning fresh `Register` objects in + * canonical order: qubit-only refs sort before that qubit's classical-result refs, then classical + * refs by ascending `result`. Bare-qubit and classical-ref entries are distinct identities; both + * survive. + */ +const _dedupRegistersByIdentity = (registers: Register[]): Register[] => { + const seen = new Set(); + const out: Register[] = []; + for (const reg of registers) { + const key = + reg.result === undefined + ? `${reg.qubit}:q` + : `${reg.qubit}:c${reg.result}`; + if (seen.has(key)) continue; + seen.add(key); + out.push( + reg.result === undefined + ? { qubit: reg.qubit } + : { qubit: reg.qubit, result: reg.result }, + ); + } + out.sort((a, b) => { + if (a.qubit !== b.qubit) return a.qubit - b.qubit; + if (a.result === undefined && b.result === undefined) return 0; + if (a.result === undefined) return -1; + if (b.result === undefined) return 1; + return a.result - b.result; + }); + return out; +}; + +/** + * Compute `op`'s derived `.targets` (or `.results`) from the union of its immediate children's + * register-bearing fields. Each group's `.targets` is already the eager cache of its own subtree, + * so the immediate children suffice — no recursion. Returns `[]` when `op` has no children grid. + */ +const _computeDerivedTargets = (op: Operation): Register[] => { + if (op.children == null) return []; + const registers: Register[] = []; + for (const col of op.children) { + for (const child of col.components) { + registers.push(...getOperationRegisters(child)); + } + } + return _dedupRegistersByIdentity(registers); +}; + +/** + * Order-sensitive equality on `Register[]`. The ancestor cascade uses it to decide whether a + * refresh changed the cached value; positional compare is safe because `_computeDerivedTargets` + * produces stable-order output. + */ +const _registerListsEqual = (a: Register[], b: Register[]): boolean => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i].qubit !== b[i].qubit) return false; + if (a[i].result !== b[i].result) return false; + } + return true; +}; + +/** + * Refresh `op`'s derived `.targets` / `.results` from its immediate children. Returns `true` iff + * the cached value changed — the ancestor cascade uses this signal to terminate. No-op (`false`) + * for leaf ops with no `children` grid. + */ +const refreshDerivedTargets = (op: Operation): boolean => { + if (op.children == null) return false; + const newValue = _computeDerivedTargets(op); + if (op.kind === "measurement") { + if (_registerListsEqual(op.results, newValue)) return false; + op.results = newValue; + return true; + } + if (op.kind === "unitary" || op.kind === "ket") { + if (_registerListsEqual(op.targets, newValue)) return false; + op.targets = newValue; + return true; + } + return false; +}; + +/** + * Post-order deep refresh of every group's derived `.targets` / `.results` in `grid`. Used by batch + * mutators like [`removeQubitWithDependents`](circuitActions.ts) that strip ops from many ancestor + * chains at once. Post-order is essential: a parent is recomputed from its children's caches, which + * must already reflect the post-mutation state. Narrowing-only — batch removal can't widen spans, + * so no new collisions appear. + */ +const deepRefreshDerivedTargets = (grid: ComponentGrid): void => { + for (const col of grid) { + for (const op of col.components) { + if (op.children != null) { + deepRefreshDerivedTargets(op.children); + refreshDerivedTargets(op); + } + } + } +}; + +/** + * Sibling-collision resolver for the extend cascade. + * + * After a refresh widens `op`'s `.targets`, its span may overlap a sibling in the same column. + * Following the [`commitAddControl`](../../editor/controllers/dragController.ts) convention, splice + * `op` out and insert a fresh column containing only `op` at the same index — this pushes the + * surviving siblings one slot right, restoring a non-overlapping layout. + * + * No-op when the column has no siblings, no sibling overlaps `op`, or `op` isn't found in + * `containingArray`. Symmetric to `resolveOverlappingOperations` but targets a single known op + * rather than scanning the whole grid. + */ +const _resolveOverlapAfterExtend = ( + op: Operation, + containingArray: ComponentGrid, +): void => { + // Locate `op` in `containingArray`. + let columnIndex = -1; + let position = -1; + for (let c = 0; c < containingArray.length; c++) { + const idx = containingArray[c].components.indexOf(op); + if (idx >= 0) { + columnIndex = c; + position = idx; + break; + } + } + if (columnIndex < 0) return; + + const column = containingArray[columnIndex]; + if (column.components.length <= 1) return; + + const [opMin, opMax] = getMinMaxRegIdx(op); + let collides = false; + for (let i = 0; i < column.components.length; i++) { + if (i === position) continue; + const [sMin, sMax] = getMinMaxRegIdx(column.components[i]); + if (doesOverlap([opMin, opMax], [sMin, sMax])) { + collides = true; + break; + } + } + if (!collides) return; + + // Splice `op` out and insert a fresh column containing only `op` at the same index — this pushes + // the surviving siblings one column to the right of `op`. + column.components.splice(position, 1); + containingArray.splice(columnIndex, 0, { components: [op] }); +}; + +/** + * Walk an ancestor chain innermost-out and refresh each still-attached ancestor's derived + * `.targets` / `.results` from its immediate children. Stops at the first ancestor whose cached + * value didn't change — nothing higher up can change either, since every ancestor's cache depends + * only on its own immediate children's caches. This is the single mechanism keeping the eager cache + * in sync, used by both the source-side and dest-side cascades in `moveOperation`. + * + * Contract: + * - `chain` is innermost-first (index 0 is the deepest ancestor). + * - Rungs whose `op` is detached from its captured `containingArray` are skipped (the empty-prune + * pass may have removed them between capture and refresh); the walk continues. + * - Refresh is idempotent: a second pass on the same chain is a no-op. + * - Does no DOM work or structural edits. Callers reacting to a widened span (e.g. column-split + * on overlap) pass `onAfterRefresh`. The hook fires on EVERY visited still-attached rung, even + * when `.targets` didn't change: a shared ancestor between source and dest chains may already + * have been written by the first cascade, yet its span still widened relative to pre-mutation + * state, so the hook must run. The hook is expected to be idempotent. + * + * The chain is captured (as `(op, containingArray)` object references) before mutation by + * `collectAncestorChain` / `findOpRungAndAncestors`, because mid-move column splices can invalidate + * location strings while object references survive. + * + * Works for both narrowing (source-side child removed) and widening (dest-side child inserted) + * refreshes; the cache-unchanged check is the stop condition for both directions. + */ +const refreshAncestorTargets = ( + chain: AncestorRung[], + options: { onAfterRefresh?: (rung: AncestorRung) => void } = {}, +): void => { + for (const rung of chain) { + const { op, containingArray } = rung; + + // Skip rungs detached between capture and refresh (e.g. spliced out by the empty-prune pass). + // Keep walking — the next-attached ancestor's children changed and still needs refreshing. + let stillAttached = false; + for (const col of containingArray) { + if (col.components.indexOf(op) >= 0) { + stillAttached = true; + break; + } + } + if (!stillAttached) continue; + + const changed = refreshDerivedTargets(op); + // Fires on every visited rung (see the "shared ancestor" note in the doc comment for why this + // can't be gated on `changed`). + options.onAfterRefresh?.(rung); + // Early-exit once nothing higher can change. EXCEPTION: when a hook is provided, walk the full + // chain so it fires on every ancestor. In `moveOperation` the source-side cascade (no hook) + // already propagated spans up through shared ancestors, so the dest-side cascade sees + // `!changed` at the first rung but the collision lives higher up. + if (!changed && options.onAfterRefresh == null) return; + } +}; + +/** + * Centralized post-widening cleanup for any single op whose register span just changed (added + * control/target, body widened by a remap). Call this whenever a mutation widens a specific op's + * span — the single chokepoint so "make everyone move out of the way" can't be missed at a call + * site. + * + * Two stages: + * 1. The op itself — check it against its own column siblings and split a fresh column on + * collision. This is the case the ancestor-only cascade misses (a top-level `addControl` that + * collides with a same-column sibling has no ancestors). + * 2. Ancestor chain — re-derive each ancestor's cache and resolve its column collision via the + * same hook. + * + * Idempotent on the no-collision path; safe to call redundantly. + * + * @param opRung The op being widened paired with its containing array (the grid one level above the + * op). Capture BEFORE the mutation if column splices may follow. + * @param ancestorChain Innermost-first ancestor rungs above `opRung.op`. Empty when `opRung.op` is + * top-level. + */ +const resolveSpanChange = ( + opRung: AncestorRung, + ancestorChain: AncestorRung[], +): void => { + _resolveOverlapAfterExtend(opRung.op, opRung.containingArray); + refreshAncestorTargets(ancestorChain, { + onAfterRefresh: ({ op, containingArray }) => + _resolveOverlapAfterExtend(op, containingArray), + }); +}; + +/** + * Walk the ancestor chain innermost-out and delete any ancestor whose children just collapsed to + * empty. Cascading: if removing an ancestor empties its grandparent, the grandparent goes next. + * + * `moveOperation` removes the source op but leaves its now-childless parent group in place; an + * empty group has no semantics and trips the renderer, so deleting it matches what users expect + * when they move the last thing out of a group. + * + * Returns the surviving rungs (innermost-first); the caller hands them to `refreshAncestorTargets` + * so the first non-empty ancestor (and any above it) get their derived `.targets` updated. + * + * `qubitUseCounts` isn't adjusted here — the empty group contributed no per-leaf counts; + * `removeTrailingUnusedQubits` is the safety net. + */ +const pruneEmptyAncestors = (chain: AncestorRung[]): AncestorRung[] => { + const survived: AncestorRung[] = []; + let stillPruning = true; + for (const rung of chain) { + if (!stillPruning) { + // Above the first non-empty ancestor — nothing here was emptied by our move, but keep the + // rung so the refresh walk can early-exit through it cleanly. + survived.push(rung); + continue; + } + if (!_isOperationEmpty(rung.op)) { + // First non-empty rung ends the prune phase; it and everything above it survive. + stillPruning = false; + survived.push(rung); + continue; + } + // Splice rung.op out of its containing array. Mirror `removeOp`'s column-cleanup: if the column + // is now empty, drop it too. + for (let colIdx = 0; colIdx < rung.containingArray.length; colIdx++) { + const col = rung.containingArray[colIdx]; + const opIdx = col.components.indexOf(rung.op); + if (opIdx >= 0) { + col.components.splice(opIdx, 1); + if (col.components.length === 0) { + rung.containingArray.splice(colIdx, 1); + } + break; + } + } + // Don't push the deleted rung to `survived`. + } + return survived; +}; + +export { + deepRefreshDerivedTargets, + pruneEmptyAncestors, + refreshDerivedTargets, + resolveSpanChange, + refreshAncestorTargets, +}; diff --git a/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/gridPrimitives.ts b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/gridPrimitives.ts new file mode 100644 index 00000000000..4218c25b8a5 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/gridPrimitives.ts @@ -0,0 +1,263 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Column, ComponentGrid, Operation } from "../../data/circuit.js"; +import { CircuitModel } from "../../data/circuitModel.js"; +import { getMinMaxRegIdx, getOperationRegisters } from "../../utils.js"; + +/* + * `gridPrimitives.ts` — low-level component-grid operations shared across the Action layer. + * + * Structural primitives every higher-level action builds on: inserting/removing an op into a + * column, detecting and resolving sibling-column overlaps, measuring an op's drawn span, and + * renumbering per-wire measurement results. Depend only on the Data layer and `utils.ts`, so they + * sit at the bottom of the import DAG. + */ + +/** Determines whether two register index ranges overlap. */ +const doesOverlap = (op1: [number, number], op2: [number, number]): boolean => { + const [min1, max1] = op1; + const [min2, max2] = op2; + return max1 >= min2 && max2 >= min1; +}; + +/** Check if an operation is classically controlled. */ +const _isClassicallyControlled = (operation: Operation): boolean => { + if (operation.kind !== "unitary") return false; + if (operation.controls === undefined) return false; + const clsControl = operation.controls.find( + ({ result }) => result !== undefined, + ); + return clsControl !== undefined; +}; + +/** + * Update measurement-result indices for a specific wire. + * + * Walks the entire grid tree (including nested children) and renumbers every measurement on + * `wireIndex` in document order, then sets `model.qubits[wireIndex].numResults` to the total. + * Recursing into children is essential: the renderer reads any measurement's results, including + * ones inside expanded groups, and throws on an uncounted nested measurement. + */ +const updateMeasurementLines = (model: CircuitModel, wireIndex: number) => { + model.ensureQubitCount(wireIndex); + let resultIndex = 0; + const walk = (grid: ComponentGrid): void => { + for (const col of grid) { + for (const comp of col.components) { + if (comp.kind === "measurement") { + const qubit = comp.qubits.find((q) => q.qubit === wireIndex); + if (qubit) { + comp.results = [{ qubit: qubit.qubit, result: resultIndex++ }]; + } + } + if (comp.children) walk(comp.children); + } + } + }; + walk(model.componentGrid); + model.qubits[wireIndex].numResults = + resultIndex > 0 ? resultIndex : undefined; +}; + +/** + * Add an operation to the circuit at the specified location. + */ +const addOp = ( + model: CircuitModel, + sourceOperation: Operation, + targetOperationParent: ComponentGrid, + targetLastIndex: readonly [number, number], + insertNewColumn: boolean = false, + originalOperation: Operation | null = null, +) => { + const [colIndex, opIndex] = targetLastIndex; + + insertNewColumn = + insertNewColumn || _isClassicallyControlled(sourceOperation); + + // Overlap check only applies when inserting into an EXISTING column: two ops can't occupy the + // same wire range in one column (a column is a single time-step), so an overlap forces a fresh + // column. A trailing slot (`colIndex === length`, no column there yet) has nothing to overlap. + const existingColumn = targetOperationParent[colIndex]; + if (!insertNewColumn && existingColumn != null) { + const [minTarget, maxTarget] = getMinMaxRegIdx(sourceOperation); + for (const op of existingColumn.components) { + if (op === originalOperation) continue; + + const [opMinTarget, opMaxTarget] = getMinMaxRegIdx(op); + if (doesOverlap([minTarget, maxTarget], [opMinTarget, opMaxTarget])) { + insertNewColumn = true; + break; + } + } + } + + if (insertNewColumn) { + // `splice` at `colIndex === length` appends, so a trailing new column needs no placeholder. + targetOperationParent.splice(colIndex, 0, { + components: [sourceOperation], + }); + } else { + // In-column insert. Materialize a fresh trailing column on demand (never eagerly, or an + // insertNewColumn path would leave it behind as a stray empty column). + if (existingColumn == null) { + targetOperationParent[colIndex] = { components: [] }; + } + targetOperationParent[colIndex].components.splice( + opIndex, + 0, + sourceOperation, + ); + } + + model.incrementQubitUseCountForOp(sourceOperation); + + if (sourceOperation.kind === "measurement") { + for (const targetWire of sourceOperation.qubits) { + updateMeasurementLines(model, targetWire.qubit); + } + } +}; + +/** Remove an operation from the circuit. */ +const removeOp = ( + model: CircuitModel, + sourceOperation: Operation, + sourceOperationParent: ComponentGrid, +) => { + if (sourceOperation.dataAttributes === undefined) { + sourceOperation.dataAttributes = { removed: "true" }; + } else { + sourceOperation.dataAttributes["removed"] = "true"; + } + + // Find and remove the operation in sourceOperationParent + for (let colIndex = 0; colIndex < sourceOperationParent.length; colIndex++) { + const col = sourceOperationParent[colIndex]; + const indexToRemove = col.components.findIndex( + (operation) => + operation.dataAttributes && operation.dataAttributes["removed"], + ); + if (indexToRemove !== -1) { + col.components.splice(indexToRemove, 1); + if (col.components.length === 0) { + sourceOperationParent.splice(colIndex, 1); + } + break; + } + } + + model.decrementQubitUseCountForOp(sourceOperation); + + if (sourceOperation.kind === "measurement") { + for (const result of sourceOperation.results) { + updateMeasurementLines(model, result.qubit); + } + } +}; + +/** Move an element of `arr` from index `from` to index `to`. */ +const moveArrayElement = (arr: T[], from: number, to: number) => { + const el = arr.splice(from, 1)[0]; + arr.splice(to, 0, el); +}; + +/** + * Walk `op` and every descendant to find the lowest and highest quantum wire (registers with + * `result` undefined; classical-ref entries are skipped, as they reference a producer's wire). Used + * by `moveOperation` to refuse a unit-shift that would push a wire below 0 and to know how far to + * grow the model. Walking the subtree matters for groups whose root `.targets` may miss deeply + * nested wires. Returns `[-1, -1]` if the subtree references no quantum wires. + */ +const getSubtreeMinMaxWire = (op: Operation): [number, number] => { + let min = Number.POSITIVE_INFINITY; + let max = -1; + const walk = (o: Operation): void => { + for (const r of getOperationRegisters(o)) { + if (r.result === undefined) { + if (r.qubit < min) min = r.qubit; + if (r.qubit > max) max = r.qubit; + } + } + if (o.children) { + for (const col of o.children) { + for (const c of col.components) walk(c); + } + } + }; + walk(op); + return [Number.isFinite(min) ? min : -1, max]; +}; + +/** + * Resolves overlapping operations in each column of the component grid. For each column, splits + * overlapping operations into separate columns so that no two operations in the same column overlap + * on their register ranges. Modifies the component grid in-place. + */ +const resolveOverlappingOperations = (parentArray: ComponentGrid): void => { + // Helper to resolve a single column into non-overlapping columns + const resolveColumn = (col: Column): Column[] => { + const newColumn: Column = { components: [] }; + let [lastMin, lastMax] = [-1, -1]; + let i = 0; + while (i < col.components.length) { + const op = col.components[i]; + const [currMin, currMax] = getMinMaxRegIdx(op); + // Sets up the first operation for comparison or if the current operation doesn't overlap + if (i === 0 || !doesOverlap([lastMin, lastMax], [currMin, currMax])) { + [lastMin, lastMax] = [currMin, currMax]; + i++; + } else { + // If they overlap, add the current operation to the new column + newColumn.components.push(op); + col.components.splice(i, 1); + } + } + if (newColumn.components.length > 0) { + const newColumns = resolveColumn(newColumn); + newColumns.push(col); + return newColumns; + } else { + return [col]; + } + }; + + // In-place update of parentArray + let i = 0; + while (i < parentArray.length) { + const col = parentArray[i]; + const newColumns = resolveColumn(col); + if (newColumns.length > 1) { + parentArray.splice(i, 1, ...newColumns); + i += newColumns.length; + } + i++; + } +}; + +/** + * Recursive variant of `resolveOverlappingOperations` — resolves overlaps in every column at every + * nesting level of the grid. Used by `moveQubit`, which can widen group spans anywhere in the tree. + */ +const resolveOverlappingOperationsRecursive = (grid: ComponentGrid): void => { + resolveOverlappingOperations(grid); + for (const col of grid) { + for (const op of col.components) { + if (op.children != null) { + resolveOverlappingOperationsRecursive(op.children); + } + } + } +}; + +export { + addOp, + doesOverlap, + getSubtreeMinMaxWire, + moveArrayElement, + removeOp, + updateMeasurementLines, + resolveOverlappingOperations, + resolveOverlappingOperationsRecursive, +}; diff --git a/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/move.ts b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/move.ts new file mode 100644 index 00000000000..5f795cdd653 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/actions/circuit-actions/move.ts @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Operation } from "../../data/circuit.js"; +import { CircuitModel } from "../../data/circuitModel.js"; +import { Location } from "../../data/location.js"; +import { Register } from "../../data/register.js"; +import { findParentArray } from "../../utils.js"; +import { addOp } from "./gridPrimitives.js"; + +/* + * `move.ts` — the geometry of moving an operation. + * + * Splits a move into horizontal (`moveX`: which column/grid) and vertical (`moveY`: which wires) + * components. The `moveOperation` orchestrator in `circuitActions.ts` drives these and handles the + * surrounding ancestor/measurement bookkeeping. Depends on `gridPrimitives`; no DOM. + */ + +/** + * Move an operation horizontally. + */ +const moveX = ( + model: CircuitModel, + sourceOperation: Operation, + originalOperation: Operation, + targetLocation: string, + insertNewColumn: boolean = false, +) => { + const targetOperationParent = findParentArray( + model.componentGrid, + targetLocation, + ); + + const targetLastIndex = Location.parse(targetLocation).last(); + + if (targetOperationParent == null || targetLastIndex == null) return; + + // Insert sourceOperation to target last index + addOp( + model, + sourceOperation, + targetOperationParent, + targetLastIndex, + insertNewColumn, + originalOperation, + ); +}; + +/** + * Collect the wires that carry at least one measurement anywhere in `op`'s subtree, so their + * per-wire `numResults` counters can be refreshed after a move. + */ +const collectMeasurementWires = (op: Operation, set: Set): void => { + if (op.kind === "measurement") { + for (const q of op.qubits) set.add(q.qubit); + } + if (op.children) { + for (const col of op.children) { + for (const child of col.components) { + collectMeasurementWires(child, set); + } + } + } +}; + +/** + * Move an operation vertically by changing its controls and targets. + * + * Pure mutator on `sourceOperation` — no grid walks, no model touches. The parent-operation + * `targets`/`results` refresh runs at the end of `moveOperation` instead, against the post-removal + * children grid (otherwise the parent would keep claiming the departed child's wires). + * + * Rewires the grabbed leg (one target or one control) to `targetWire`, leaving the other legs in + * place ("rewire one leg of a CNOT"). + */ +const moveY = ( + sourceOperation: Operation, + sourceWire: number, + targetWire: number, + movingControl: boolean, +): void => { + // Check if the source operation already has a target or control on the target wire + let targets: Register[]; + switch (sourceOperation.kind) { + case "unitary": + case "ket": + targets = sourceOperation.targets; + break; + case "measurement": + targets = sourceOperation.qubits; + break; + } + + let controls: Register[]; + switch (sourceOperation.kind) { + case "unitary": + controls = sourceOperation.controls || []; + break; + case "measurement": + case "ket": + controls = []; + break; + } + + let likeRegisters: Register[]; + let unlikeRegisters: Register[]; + if (movingControl) { + likeRegisters = controls; + unlikeRegisters = targets; + } else { + likeRegisters = targets; + unlikeRegisters = controls; + } + + // If a similar register already exists, don't move the gate + if (likeRegisters.find((reg) => reg.qubit === targetWire)) { + return; + } + + // If a different kind of register already exists, swap the control and target + if (unlikeRegisters.find((reg) => reg.qubit === targetWire)) { + const index = unlikeRegisters.findIndex((reg) => reg.qubit === targetWire); + unlikeRegisters[index].qubit = sourceWire; + } + + switch (sourceOperation.kind) { + case "unitary": + if (movingControl) { + sourceOperation.controls?.forEach((control) => { + if (control.qubit === sourceWire) { + control.qubit = targetWire; + } + }); + sourceOperation.controls = sourceOperation.controls?.sort( + (a, b) => a.qubit - b.qubit, + ); + } else { + sourceOperation.targets = [{ qubit: targetWire }]; + } + break; + case "measurement": + sourceOperation.qubits = [{ qubit: targetWire }]; + // The measurement result is updated later in the updateMeasurementLines function + break; + case "ket": + sourceOperation.targets = [{ qubit: targetWire }]; + break; + } +}; + +export { collectMeasurementWires, moveX, moveY }; diff --git a/source/npm/qsharp/ux/circuit-vis/actions/circuitActions.ts b/source/npm/qsharp/ux/circuit-vis/actions/circuitActions.ts new file mode 100644 index 00000000000..c0c905da28d --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/actions/circuitActions.ts @@ -0,0 +1,830 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ComponentGrid, Operation, Unitary } from "../data/circuit.js"; +import { CircuitModel } from "../data/circuitModel.js"; +import { Location } from "../data/location.js"; +import { + findOperation, + findParentArray, + getOperationRegisters, +} from "../utils.js"; +import { + AncestorRung, + collectAncestorChain, + findAncestorChainForOp, + findOpRungAndAncestors, +} from "./circuit-actions/ancestors.js"; +import { + applyClassicalRefRemap, + findLocationByRef, + collectExternalProducerLocations, + collectMeasurementConsumers, +} from "./circuit-actions/classicalRefs.js"; +import { + deepRefreshDerivedTargets, + pruneEmptyAncestors, + resolveSpanChange, + refreshAncestorTargets, +} from "./circuit-actions/derivedTargets.js"; +import { + addOp, + moveArrayElement, + removeOp, + updateMeasurementLines, + resolveOverlappingOperations, + resolveOverlappingOperationsRecursive, +} from "./circuit-actions/gridPrimitives.js"; +import { + collectMeasurementWires, + moveX, + moveY, +} from "./circuit-actions/move.js"; + +/* + * `circuitActions.ts` — the Action layer in the Data / Action / View architecture. + * + * Each exported function takes a `CircuitModel` first and mutates it in place — no DOM, interaction + * state, or rendering. They return the new/affected `Operation` or a `boolean` status, and (being + * pure data mutations) can be tested directly against a freshly built `CircuitModel` with no JSDOM. + * + * This is the orchestration + public API layer; the mechanical helpers live in sibling modules: + * `gridPrimitives` (column insert/remove, overlap, span), `ancestors` (chain capture), + * `derivedTargets` (the eager `.targets` cascade), `move` (move geometry), `classicalRefs` + * (producer/consumer analysis). + */ + +/** + * Move an operation in the circuit. + * + * After the move, both the source-side and dest-side ancestor chains are walked innermost-out by + * `refreshAncestorTargets` and each still-attached ancestor's derived `.targets`/`.results` is + * rebuilt from its post-move children, maintaining the invariant that an ancestor's `.targets` is + * the union of its descendants' wires. The target location string is authoritative about which + * group the op lands in. + * + * @param model The circuit model to mutate. + * @param sourceLocation The location string of the source operation. + * @param targetLocation The location string of the target position. + * @param sourceWire The wire index of the source operation. + * @param targetWire The wire index to move the operation to. + * @param movingControl Whether the operation is being moved as a control. + * @param insertNewColumn Whether to insert a new column when adding the operation. + * @returns The moved operation or null if the move was unsuccessful. + */ +const moveOperation = ( + model: CircuitModel, + sourceLocation: string, + targetLocation: string, + sourceWire: number, + targetWire: number, + movingControl: boolean, + insertNewColumn: boolean = false, +): Operation | null => { + const originalOperation = findOperation(model.componentGrid, sourceLocation); + + if (originalOperation == null) return null; + + // Resolve source-side parent references BEFORE any mutation: `moveX` may splice a fresh column + // into a grid on the source's path, invalidating its location string. The array reference stays + // valid as its contents shift. + const sourceOperationParent = findParentArray( + model.componentGrid, + sourceLocation, + ); + if (sourceOperationParent == null) return null; + + // Capture the source ancestor chain BEFORE any mutation so the empty-group cleanup at the tail + // keeps valid references after `moveX` splices columns. + const ancestorChain = collectAncestorChain(model, sourceLocation); + + // Dest ancestor chain, captured pre-move for the dest-side cascade below. Empty for a top-level + // drop. + const destAncestorChain: AncestorRung[] = collectAncestorChain( + model, + targetLocation, + ); + + // Dest containing array (the grid the moved op lives in directly), captured pre-move; falls back + // to the top-level grid. + const destContainingArray: ComponentGrid = + findParentArray(model.componentGrid, targetLocation) ?? model.componentGrid; + + // Safety net: refuse the move if it would place the source before one of its external + // classical-register producers in document order. The dropzone filter in + // [`DragController`](../editor/controllers/dragController.ts) hides invalid dropzones at + // drag-start; this catches any path that bypasses it. Compares PRE-mutation locations via + // [`Location.inEarlierColumnThan`](../data/location.ts). + const externalProducerLocs = collectExternalProducerLocations( + model.componentGrid, + sourceLocation, + ); + if (externalProducerLocs.length > 0) { + const targetLoc = Location.parse(targetLocation); + for (const pLocStr of externalProducerLocs) { + const pLoc = Location.parse(pLocStr); + if (!pLoc.inEarlierColumnThan(targetLoc)) return null; + } + } + + // Create a deep copy of the source operation + const newSourceOperation: Operation = JSON.parse( + JSON.stringify(originalOperation), + ); + + // Stamp the clone with a one-shot "previous location" marker so + // [`Sqore.rebaseViewState`](../sqore.ts) can transfer the user's expand/collapse state across the + // move. The JSON deep-clone below breaks object identity, so the identity lookup would otherwise + // miss and drop the ViewState entry. The stamp is consumed on the next rebase, so it never leaks + // into the rendered SVG. + if (newSourceOperation.dataAttributes == null) { + newSourceOperation.dataAttributes = {}; + } + newSourceOperation.dataAttributes["sqore-prev-location"] = sourceLocation; + + // Capture pre-move measurement wires from the live source, to refresh per-wire `numResults` + // counters after the move (see the `updateMeasurementLines` sweep at the tail). + const affectedMeasurementWires = new Set(); + collectMeasurementWires(originalOperation, affectedMeasurementWires); + + // Grow the model to fit the wire the moved leg will land on. + model.ensureQubitCount(targetWire); + + // Update operation's targets and controls + moveY(newSourceOperation, sourceWire, targetWire, movingControl); + + // Capture POST-shift measurement wires too, so the sweep covers both the wires measurements left + // and the wires they landed on. + collectMeasurementWires(newSourceOperation, affectedMeasurementWires); + + // Move horizontally + moveX( + model, + newSourceOperation, + originalOperation, + targetLocation, + insertNewColumn, + ); + + removeOp(model, originalOperation, sourceOperationParent); + + // Source-side cleanup: prune any ancestor groups whose children just collapsed to empty (cascades + // upward), then refresh the surviving ancestors' derived `.targets`. Prune before refresh: + // `_isOperationEmpty` reads `children`, so refreshing a soon-to-be-deleted rung is wasted work. + const survivedSourceChain = pruneEmptyAncestors(ancestorChain); + refreshAncestorTargets(survivedSourceChain); + + // Dest-side cleanup. Centralized post-widening cascade: the newly-moved op vs its own column + // siblings, plus every dest ancestor whose `.targets` no longer encloses its child's wire span + // (with the collision resolver firing on each). Always-on because the target location is + // authoritative; no-op for a top-level drop or when every dest ancestor was pruned. + resolveSpanChange( + { + op: newSourceOperation, + containingArray: destContainingArray, + }, + destAncestorChain, + ); + + // Refresh per-wire `numResults` counters for every wire that may have gained or lost a + // measurement. `addOp` / `removeOp` only fire this for TOP-LEVEL measurements; a measurement + // crossing wires inside a moved group is only kept in step here. + for (const wire of affectedMeasurementWires) { + if (wire >= 0 && wire < model.qubits.length) { + updateMeasurementLines(model, wire); + } + } + + model.removeTrailingUnusedQubits(); + + return newSourceOperation; +}; + +/** + * Move a measurement that has downstream classical consumers, propagating the effects to those + * consumers. + * + * Wraps `moveOperation` with the bookkeeping to keep the classical producer→consumer graph + * consistent. The caller (the editor's prompt layer) is expected to have already: + * 1. Called `collectMeasurementConsumers` on the M. + * 2. Partitioned the result against `targetLocation` by + * [`Location.inEarlierColumnThan`](../data/location.ts) into survivors (their classical refs + * get remapped) and invalidated consumers (passed as `invalidatedConsumers`, cascade-deleted). + * 3. Confirmed the cascade with the user. + * + * Wire-remap detail: `moveOperation`'s tail-end `updateMeasurementLines` sweep renumbers result + * indices on every affected wire, so we snapshot each measurement's pre-move `(qubit, result)` keys + * and compare with post-move keys to build a complete remap (also catching consumers of OTHER Ms on + * the renumbered wires). The moved M becomes a new object reference, so its pre-move keys are + * captured separately and paired positionally. + * + * Ordering: move first, then cascade-delete — the pre-move `targetLocation` is still valid against + * the unmodified grid, and cascade-delete uses object-reference predicates that survive the move's + * column splices. + * + * Returns the moved M op, or `null` if `moveOperation` refused it. + */ +const moveMeasurementWithDependents = ( + model: CircuitModel, + sourceLocation: string, + targetLocation: string, + sourceWire: number, + targetWire: number, + insertNewColumn: boolean, + invalidatedConsumers: Operation[], +): Operation | null => { + const mOp = findOperation(model.componentGrid, sourceLocation); + if (mOp == null || mOp.kind !== "measurement") return null; + + // Snapshot every M's pre-move (qubit, result) keys by object identity. Other Ms renumbered by the + // tail-end `updateMeasurementLines` sweep need their consumers updated too. + const preMoveKeysByRef = new Map< + Operation, + { qubit: number; result: number }[] + >(); + const walkMeasurements = (g: ComponentGrid): void => { + for (const col of g) { + for (const op of col.components) { + if (op.kind === "measurement") { + const list: { qubit: number; result: number }[] = []; + for (const r of op.results) { + if (r.result !== undefined) { + list.push({ qubit: r.qubit, result: r.result }); + } + } + preMoveKeysByRef.set(op, list); + } + if (op.children) walkMeasurements(op.children); + } + } + }; + walkMeasurements(model.componentGrid); + + // The moving M's pre-keys captured SEPARATELY: it becomes a new object post-move (deep-cloned), + // so the by-ref map won't have it. + const movedMPreKeys = preMoveKeysByRef.get(mOp) ?? []; + + // Move M. The standard path handles its wire change, column placement, and the global + // `updateMeasurementLines` renumbering. + const movedM = moveOperation( + model, + sourceLocation, + targetLocation, + sourceWire, + targetWire, + /* movingControl */ false, + insertNewColumn, + ); + if (movedM == null) return null; + + // Cascade-delete invalidated consumers AFTER the move. The predicate matches on object identity, + // so shifted locations don't matter. + const invalidatedSet = new Set(invalidatedConsumers); + if (invalidatedSet.size > 0) { + _findAndRemoveOperations(model, (op) => invalidatedSet.has(op)); + } + + // Build the (oldQubit, oldResult) → (newQubit, newResult) remap by pairing pre/post snapshots + // positionally per M: the moved M from `movedMPreKeys` → `movedM.results`, every other M from + // `preMoveKeysByRef` → its still-live op. + const keyRemap = new Map(); + const recordRemap = ( + preList: { qubit: number; result: number }[], + postOp: Operation, + ): void => { + if (postOp.kind !== "measurement") return; + const postList: { qubit: number; result: number }[] = []; + for (const r of postOp.results) { + if (r.result !== undefined) { + postList.push({ qubit: r.qubit, result: r.result }); + } + } + const n = Math.min(preList.length, postList.length); + for (let i = 0; i < n; i++) { + const preKey = `${preList[i].qubit}:${preList[i].result}`; + const postKey = `${postList[i].qubit}:${postList[i].result}`; + if (preKey !== postKey) keyRemap.set(preKey, postKey); + } + }; + recordRemap(movedMPreKeys, movedM); + for (const [preOp, preList] of preMoveKeysByRef) { + if (preOp === mOp) continue; // moved M handled above + recordRemap(preList, preOp); + } + + // Apply the remap to every classical ref in the grid. Walking the whole grid catches consumers of + // OTHER Ms bumped by the renumber. + if (keyRemap.size > 0) { + applyClassicalRefRemap(model.componentGrid, keyRemap); + } + + // Consumers' visual spans may have changed, widening or narrowing group `.targets` caches and + // introducing new collisions. Re-derive bottom-up and resolve recursively (same pattern as + // `moveQubit`). + deepRefreshDerivedTargets(model.componentGrid); + resolveOverlappingOperationsRecursive(model.componentGrid); + + return movedM; +}; + +/** + * Remove a measurement and cascade-delete every op that depends on its classical outputs. + * + * Same handoff contract as `moveMeasurementWithDependents`: the prompt layer collects consumers, + * confirms with the user, then calls this with the consumer set. + * + * Cascade-delete consumers FIRST so `removeOperation`'s ancestor refresh runs against a grid with + * no dangling classical refs to the deleted M (whose location may shift in the cascade, so we look + * it back up by object reference). + * + * Result-index propagation: `removeOperation`'s tail-end `updateMeasurementLines` sweep renumbers + * per-wire result indices to close the gap. If OTHER Ms share that wire, their consumers keep stale + * keys, so we snapshot every surviving M's pre-removal keys by identity, remove, then build and + * apply a pre/post remap (same mechanism as the move path). The deleted M is excluded. + */ +const removeMeasurementWithDependents = ( + model: CircuitModel, + mLocation: string, + consumers: Operation[], +): void => { + const mOp = findOperation(model.componentGrid, mLocation); + if (mOp == null) return; + + // Snapshot every OTHER M's pre-removal (qubit, result) keys by identity. The deleted M is + // excluded; surviving Ms on the same wire(s) get renumbered by `removeOperation`'s sweep and + // their consumers need a matching remap. + const preRemovalKeysByRef = new Map< + Operation, + { qubit: number; result: number }[] + >(); + const walkMeasurements = (g: ComponentGrid): void => { + for (const col of g) { + for (const op of col.components) { + if (op.kind === "measurement" && op !== mOp) { + const list: { qubit: number; result: number }[] = []; + for (const r of op.results) { + if (r.result !== undefined) { + list.push({ qubit: r.qubit, result: r.result }); + } + } + preRemovalKeysByRef.set(op, list); + } + if (op.children) walkMeasurements(op.children); + } + } + }; + walkMeasurements(model.componentGrid); + + // Cascade-delete the consumers. The predicate matches on object identity, so location-string + // drift doesn't matter. + if (consumers.length > 0) { + const consumerSet = new Set(consumers); + _findAndRemoveOperations(model, (op) => consumerSet.has(op)); + } + + // M's location may have shifted in the cascade; re-derive by ref. + const newMLoc = findLocationByRef(model.componentGrid, mOp); + if (newMLoc != null) { + removeOperation(model, newMLoc); + } + + // Build the remap by pairing pre/post snapshots positionally per surviving M. An M + // cascade-deleted with a consumer group is dropped: it isn't visited in the post-removal walk. + const keyRemap = new Map(); + for (const [postOp, preList] of preRemovalKeysByRef) { + if (postOp.kind !== "measurement") continue; + const postList: { qubit: number; result: number }[] = []; + for (const r of postOp.results) { + if (r.result !== undefined) { + postList.push({ qubit: r.qubit, result: r.result }); + } + } + const n = Math.min(preList.length, postList.length); + for (let i = 0; i < n; i++) { + const preKey = `${preList[i].qubit}:${preList[i].result}`; + const postKey = `${postList[i].qubit}:${postList[i].result}`; + if (preKey !== postKey) keyRemap.set(preKey, postKey); + } + } + + if (keyRemap.size > 0) { + applyClassicalRefRemap(model.componentGrid, keyRemap); + // Surviving classically-controlled groups' spans may have shifted; refresh + resolve overlaps + // (same as the move path). + deepRefreshDerivedTargets(model.componentGrid); + resolveOverlappingOperationsRecursive(model.componentGrid); + } +}; + +/** + * Add an operation into the circuit. + * + * @returns The added operation or null if the addition was unsuccessful. + */ +const addOperation = ( + model: CircuitModel, + sourceOperation: Operation, + targetLocation: string, + targetWire: number, + insertNewColumn: boolean = false, +): Operation | null => { + const targetOperationParent = findParentArray( + model.componentGrid, + targetLocation, + ); + const targetLastIndex = Location.parse(targetLocation).last(); + + if (targetOperationParent == null || targetLastIndex == null) return null; + + // Reject an out-of-range location on either axis. + const [targetColIndex, targetOpIndex] = targetLastIndex; + if (targetColIndex < 0 || targetColIndex > targetOperationParent.length) { + return null; + } + // A brand-new trailing column doesn't exist yet, so its length is 0 — only op index 0 is valid. + const targetColumnLength = + targetOperationParent[targetColIndex]?.components.length ?? 0; + if (targetOpIndex < 0 || targetOpIndex > targetColumnLength) { + return null; + } + + // Create a deep copy of the source operation + const newSourceOperation: Operation = JSON.parse( + JSON.stringify(sourceOperation), + ); + + // Single-leg rewrite (toolbox drop, single-target clone): re-pin the op to `targetWire`. + if (newSourceOperation.kind === "measurement") { + newSourceOperation.qubits = [{ qubit: targetWire }]; + // The measurement result is updated later in the updateMeasurementLines function + } else if ( + newSourceOperation.kind === "unitary" || + newSourceOperation.kind === "ket" + ) { + newSourceOperation.targets = [{ qubit: targetWire }]; + } + model.ensureQubitCount(targetWire); + + // Capture the dest ancestor chain BEFORE addOp so the rung references survive any column splices. + // Empty when top-level. + const destAncestorChain: AncestorRung[] = collectAncestorChain( + model, + targetLocation, + ); + + addOp( + model, + newSourceOperation, + targetOperationParent, + targetLastIndex, + insertNewColumn, + ); + + // After mutating the parent group's children, the centralized post-widening cleanup re-derives + // every ancestor's `.targets` and resolves any sibling-column collisions the widening introduced. + resolveSpanChange( + { op: newSourceOperation, containingArray: targetOperationParent }, + destAncestorChain, + ); + + return newSourceOperation; +}; + +/** + * Remove an operation from the circuit. + */ +const removeOperation = (model: CircuitModel, sourceLocation: string) => { + const sourceOperation = findOperation(model.componentGrid, sourceLocation); + const sourceOperationParent = findParentArray( + model.componentGrid, + sourceLocation, + ); + + if (sourceOperation == null || sourceOperationParent == null) return null; + + // Capture the source ancestor chain BEFORE removeOp so the rung references survive the splice + // (and any column collapse). + const ancestorChain = collectAncestorChain(model, sourceLocation); + + removeOp(model, sourceOperation, sourceOperationParent); + + // Re-derive the parent's `.targets` (and every ancestor above) from the surviving children. + // Narrowing-only: shrinking a span can't introduce new sibling collisions, so no resolver hook. + const survivedChain = pruneEmptyAncestors(ancestorChain); + refreshAncestorTargets(survivedChain); + + model.removeTrailingUnusedQubits(); +}; + +/** + * Find and remove operations in-place that return `true` for a predicate function. + */ +const _findAndRemoveOperations = ( + model: CircuitModel, + pred: (op: Operation) => boolean, +) => { + // Remove operations that are true for the predicate function + const inPlaceFilter = (grid: ComponentGrid) => { + let i = 0; + while (i < grid.length) { + let j = 0; + while (j < grid[i].components.length) { + const op = grid[i].components[j]; + if (op.children) { + inPlaceFilter(op.children); + } + if (pred(op)) { + model.decrementQubitUseCountForOp(op); + grid[i].components.splice(j, 1); + } else { + j++; + } + } + if (grid[i].components.length === 0) { + grid.splice(i, 1); + } else { + i++; + } + } + }; + + inPlaceFilter(model.componentGrid); + + // Batch removal may have stripped ops from many ancestor chains, so re-derive every group's cache + // in one bottom-up sweep. + deepRefreshDerivedTargets(model.componentGrid); +}; + +/** + * Returns true if `op` is a multi-target unitary, multi-qubit measurement, or a group — i.e. an op + * with more than one wire-leg, with no single canonical position to attach a quantum-control + * connector. + * + * Gates `addControl` and `removeControl`: the editor refuses to create or destroy quantum controls + * on such ops. Groups carry classical controls only; for multi-target ops it's a rendering-rule + * limitation. Existing quantum controls in loaded `.qsc` data still render and can be dragged (the + * `movingControl` path permutes existing controls rather than adding one). + * + * Mirrors the structural-shape half of `moveAsUnit`. + */ +const _isMultiTargetOrGroup = (op: Operation): boolean => { + if (op.children != null) return true; + switch (op.kind) { + case "unitary": + case "ket": + return op.targets.length > 1; + case "measurement": + return op.qubits.length > 1; + } +}; + +/** + * Add a control to the specified operation on the given wire index. + * + * @returns True if the control was added, false if it already existed. + */ +const addControl = ( + model: CircuitModel, + op: Unitary, + wireIndex: number, +): boolean => { + // Refuse on multi-target ops and groups by design (see `_isMultiTargetOrGroup`). Gating here + // covers every entry point uniformly. + if (_isMultiTargetOrGroup(op)) return false; + if (!op.controls) { + op.controls = []; + } + // Match only PURE-QUANTUM controls. A classical-ref on the same wire is a different register + // identity and must not block adding a new quantum control. + const existingControl = op.controls.find( + (control) => control.qubit === wireIndex && control.result === undefined, + ); + if (!existingControl) { + // Capture the op's rung and ancestor chain before mutating so the references survive any column + // splices. + const rungs = findOpRungAndAncestors(model, op); + if (rungs == null) return false; + + op.controls.push({ qubit: wireIndex }); + op.controls.sort((a, b) => a.qubit - b.qubit); + model.ensureQubitCount(wireIndex); + model.qubitUseCounts[wireIndex]++; + + // Adding a control outside the op's span widens it. Run the centralized post-widening cleanup + // so the op (and every ancestor that widens transitively) is checked against its siblings. + resolveSpanChange(rungs.opRung, rungs.ancestorChain); + return true; + } + return false; +}; + +/** + * Remove a control from the specified operation on the given wire index. + * + * @returns True if the control was removed, false if it did not exist. + */ +const removeControl = ( + model: CircuitModel, + op: Unitary, + wireIndex: number, +): boolean => { + // Symmetric to `addControl`: refuse on multi-target ops and groups by design. The `movingControl` + // drag path is permutation-only and doesn't reach here. See `_isMultiTargetOrGroup`. + if (_isMultiTargetOrGroup(op)) return false; + if (op.controls) { + // Match only PURE-QUANTUM controls; a classical-ref entry on the same wire is the group's + // conditional dependency, not a removable control dot. + const controlIndex = op.controls.findIndex( + (control) => control.qubit === wireIndex && control.result === undefined, + ); + if (controlIndex !== -1) { + // Capture ancestors before mutating, for consistency with the other mutators (narrowing can't + // trigger column splices). + const ancestorChain = findAncestorChainForOp(model, op); + + op.controls.splice(controlIndex, 1); + model.qubitUseCounts[wireIndex]--; + if (wireIndex === model.qubits.length - 1) { + model.removeTrailingUnusedQubits(); + } + + // Narrowing only — no overlap-resolver hook needed. + refreshAncestorTargets(ancestorChain); + return true; + } + } + return false; +}; + +/** + * Move a qubit line from `sourceWire` to `targetWire`. Two modes: + * + * - `isBetween: true` — insert before `targetWire`. + * - `isBetween: false` — swap with `targetWire`. + * + * Updates qubit IDs and every register reference (including ops nested in group `children` and the + * cached `.targets` on groups), then refreshes every group's derived `.targets` and runs the + * overlap resolver recursively (the remap can both widen and narrow spans). No-op if `sourceWire + * === targetWire` or either is null. + */ +const moveQubit = ( + model: CircuitModel, + sourceWire: number, + targetWire: number, + isBetween: boolean, +): void => { + if (sourceWire === targetWire || sourceWire == null || targetWire == null) { + return; + } + + if (isBetween) { + // Moving sourceWire to just before targetWire. + let insertAt = targetWire; + // If moving down and passing over itself, adjust index. + if (sourceWire < insertAt) insertAt--; + moveArrayElement(model.qubits, sourceWire, insertAt); + moveArrayElement(model.qubitUseCounts, sourceWire, insertAt); + } else { + // Swap sourceWire and targetWire. + [model.qubits[sourceWire], model.qubits[targetWire]] = [ + model.qubits[targetWire], + model.qubits[sourceWire], + ]; + [model.qubitUseCounts[sourceWire], model.qubitUseCounts[targetWire]] = [ + model.qubitUseCounts[targetWire], + model.qubitUseCounts[sourceWire], + ]; + } + + // Update qubit ids to match their new positions + model.qubits.forEach((q, idx) => { + q.id = idx; + }); + + // Compute the wire-index remap once and apply it to every register reference in the tree — + // including ops nested in group children and each group's own cached `.targets` / `.results` + // (independent `Register` objects, not shared with descendants). + const remapWire = (oldWire: number): number => { + if (isBetween) { + if (oldWire === sourceWire) { + return sourceWire < targetWire ? targetWire - 1 : targetWire; + } else if ( + sourceWire < targetWire && + oldWire > sourceWire && + oldWire < targetWire + ) { + return oldWire - 1; + } else if ( + sourceWire > targetWire && + oldWire >= targetWire && + oldWire < sourceWire + ) { + return oldWire + 1; + } + return oldWire; + } else { + if (oldWire === sourceWire) return targetWire; + if (oldWire === targetWire) return sourceWire; + return oldWire; + } + }; + const remapRefsInGrid = (grid: ComponentGrid): void => { + for (const column of grid) { + for (const op of column.components) { + getOperationRegisters(op).forEach((reg) => { + reg.qubit = remapWire(reg.qubit); + }); + if (op.children != null) remapRefsInGrid(op.children); + } + // Sort operations in this column by their lowest-numbered register + column.components.sort((a, b) => { + const aRegs = getOperationRegisters(a); + const bRegs = getOperationRegisters(b); + const aMin = Math.min(...aRegs.map((r) => r.qubit)); + const bMin = Math.min(...bRegs.map((r) => r.qubit)); + return aMin - bMin; + }); + } + }; + remapRefsInGrid(model.componentGrid); + + // Group `.targets` caches were remapped in-place above, but that may have introduced duplicate + // refs or stale ordering. The deep refresh re-derives each group's `.targets` from its children + // bottom-up, the canonical source of truth. + deepRefreshDerivedTargets(model.componentGrid); + + // Resolve overlaps at every nesting level: widening a group's span via the remap can introduce + // collisions inside that group too. + resolveOverlappingOperationsRecursive(model.componentGrid); + + model.removeTrailingUnusedQubits(); +}; + +/** + * Remove a qubit line at `qubitIdx`. Caller is responsible for asking the user to confirm if the + * wire still has operations on it; this function only does the data mutation. + * + * Decrements all references on higher-numbered wires by 1 (since their indices shift down) and + * renumbers qubit ids to match. Operations that touched `qubitIdx` are **not** removed by this call + * — use `removeQubitWithDependents` if you want the ops on the wire stripped too. + */ +const removeQubit = (model: CircuitModel, qubitIdx: number): void => { + model.qubits.splice(qubitIdx, 1); + model.qubitUseCounts.splice(qubitIdx, 1); + model.removeTrailingUnusedQubits(); + + // Update all references throughout the tree — including ops nested in groups and the eager + // `.targets` / `.results` caches on those groups. Walking recursively keeps child refs and cached + // refs in lockstep, so the uniform shift preserves cache coherence. + const shiftRefsInGrid = (grid: ComponentGrid): void => { + for (const column of grid) { + for (const op of column.components) { + getOperationRegisters(op).forEach((reg) => { + if (reg.qubit > qubitIdx) reg.qubit -= 1; + }); + if (op.children != null) shiftRefsInGrid(op.children); + } + } + }; + shiftRefsInGrid(model.componentGrid); + + // Update qubit ids to match their new positions + model.qubits.forEach((q, idx) => { + q.id = idx; + }); +}; + +/** + * Remove a qubit line at `qubitIdx` together with every operation that touches it. Counterpart to + * the measurement `*WithDependents` actions: strips every op with a register on `qubitIdx`, then + * drops the wire and renumbers the higher wires down. + * + * The strip must run BEFORE `removeQubit`, which shifts higher wires down by one and would + * otherwise invalidate `qubitIdx` mid-cascade. + */ +const removeQubitWithDependents = ( + model: CircuitModel, + qubitIdx: number, +): void => { + _findAndRemoveOperations(model, (op) => + getOperationRegisters(op).some((reg) => reg.qubit === qubitIdx), + ); + removeQubit(model, qubitIdx); +}; + +export { + addControl, + addOperation, + collectExternalProducerLocations, + collectMeasurementConsumers, + moveMeasurementWithDependents, + moveOperation, + moveQubit, + removeControl, + removeMeasurementWithDependents, + removeOperation, + removeQubit, + removeQubitWithDependents, + resolveOverlappingOperations, + _isMultiTargetOrGroup, +}; diff --git a/source/npm/qsharp/ux/circuit-vis/actions/interactionActions.ts b/source/npm/qsharp/ux/circuit-vis/actions/interactionActions.ts new file mode 100644 index 00000000000..d88f5cc0052 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/actions/interactionActions.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Operation } from "../data/circuit.js"; +import { InteractionState } from "./interactionState.js"; + +/* + * `interactionActions.ts` — the Action layer for ephemeral editor session state + * (drag/selection/temporary-overlay tracking). Mirrors [circuitActions.ts](circuitActions.ts): each + * function takes an `InteractionState` first and mutates it in place, returning `void`. + * + * Most functions are pure data helpers (no DOM) and unit-testable without JSDOM; + * `clearTemporaryDropzones` is the only DOM-touching one. Direct setters (`state.selectedOperation + * = ...`) are fine for one-line writes in event handlers; the wrappers exist to centralize the + * multi-step sequences (e.g. `beginToolboxDrag` sets two fields together) so they aren't reinvented + * inconsistently. + */ + +/** + * Clear all transient drag/gesture flags. **Does not** clear `selectedOperation` — that survives + * across resets so the context menu can use it. + */ +export function resetTransient(state: InteractionState): void { + state.selectedWire = null; + state.movingControl = false; + state.mouseUpOnCircuit = false; + state.dragging = false; + state.disableLeftAutoScroll = false; + clearTemporaryDropzones(state); +} + +/** + * Clear the persistent selection. Called when the selected op no longer represents a meaningful + * target — e.g. after committing a toolbox drop, after starting an add-control flow, when the user + * clicks on the canvas background. + */ +export function clearSelection(state: InteractionState): void { + state.selectedOperation = null; +} + +/** + * Set the persistent selection to `op`. Used by the various mousedown handlers when the user grabs + * a gate or starts a control add/remove flow. + */ +export function markSelected( + state: InteractionState, + op: Operation | null, +): void { + state.selectedOperation = op; +} + +/** + * Begin a drag from the toolbox. Records the toolbox-template operation as the selection and + * suppresses left-edge auto-scroll for this drag (so the user doesn't get a runaway scroll while + * still over the toolbox panel near the canvas's left edge). + */ +export function beginToolboxDrag( + state: InteractionState, + templateOp: Operation, +): void { + state.selectedOperation = templateOp; + state.disableLeftAutoScroll = true; +} + +/** Track that the user is dragging a control dot. */ +export function markMovingControl(state: InteractionState): void { + state.movingControl = true; +} + +/** + * Track that a mouseup landed on the circuit SVG (vs. outside). Read by `documentMouseupHandler` to + * decide whether to commit the drop or treat it as a "dragged out" delete. + */ +export function markMouseUpOnCircuit(state: InteractionState): void { + state.mouseUpOnCircuit = true; +} + +/** + * Track that a drag is now in flight. Set by `_createGhostElement` once the visual ghost is up. + */ +export function markDragging(state: InteractionState): void { + state.dragging = true; +} + +/** + * Append `dz` to the list of temporary dropzones to be torn down on the next reset. Caller is still + * responsible for inserting the element into the DOM; this is just a bookkeeping handle. + */ +export function trackTemporaryDropzone( + state: InteractionState, + dz: SVGElement, +): void { + state.temporaryDropzones.push(dz); +} + +/** + * Remove every tracked temporary dropzone from its parent node and clear the tracking list. Safe to + * call when the list is already empty. The only DOM-touching function in this module. + */ +export function clearTemporaryDropzones(state: InteractionState): void { + for (const dz of state.temporaryDropzones) { + if (dz.parentNode) { + dz.parentNode.removeChild(dz); + } + } + state.temporaryDropzones = []; +} diff --git a/source/npm/qsharp/ux/circuit-vis/actions/interactionState.ts b/source/npm/qsharp/ux/circuit-vis/actions/interactionState.ts new file mode 100644 index 00000000000..4ec2dfe896c --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/actions/interactionState.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Operation } from "../data/circuit.js"; + +/** + * `InteractionState` — ephemeral session state for the circuit editor; the Action layer's state + * container in the Data / Action / View architecture. Holds the mutable fields read and written by + * the editor's pointer/keyboard handlers. + * + * Kept distinct from [`CircuitModel`](circuitModel.ts) because none of these fields belong in saved + * circuit JSON. + * + * Two field lifetimes are mixed here: + * + * - **Persistent** — `selectedOperation` survives the `resetTransient` reset on mouseup, so the + * context menu can find its target after opening. Cleared explicitly by callers. + * - **Transient** — `selectedWire`, `movingControl`, `mouseUpOnCircuit`, `dragging`, + * `disableLeftAutoScroll`, `temporaryDropzones`. Owned by the in-flight gesture; reset between + * drags. + * + * Fields are public for direct read/write from event handlers in `events.ts`. Mutations with + * non-trivial logic go through [interactionActions.ts](interactionActions.ts) so that + * `CircuitEvents` doesn't need to know how to e.g. tear down temporary dropzones. + * + * No methods on this class: it's pure data paired with the free functions in + * `interactionActions.ts` (mirroring `CircuitModel` + `circuitActions.ts`), which keeps unit tests + * trivial. + */ +export class InteractionState { + /** + * The operation the user last clicked / mousedown'd. Persistent across `resetTransient` so the + * context menu can use it. Cleared explicitly by callers (toolbox-drop completion, qubit-line + * drag start, control-add/remove completion) when no longer relevant. + */ + selectedOperation: Operation | null = null; + + /** + * The wire index the user mousedown'd on. Transient — used during a single drag gesture to know + * which wire of a multi-target gate is being grabbed (and is therefore exempt from getting a + * temporary dropzone of its own). Cleared on every mouseup. + */ + selectedWire: number | null = null; + + /** + * `true` when the dragged element is a control dot (vs. a target box). Drives whether + * `dropzoneMouseupHandler` calls `addControl`/`removeControl` semantics or the regular + * move-operation path. Transient — reset on mouseup. + */ + movingControl: boolean = false; + + /** + * `true` once a mouseup is received over the circuit SVG itself (vs. outside the canvas). Used by + * `documentMouseupHandler` to distinguish "dropped on canvas" from "dragged out and dropped" + * (which triggers delete-on-release). Transient. + */ + mouseUpOnCircuit: boolean = false; + + /** + * `true` while a drag is in progress. Transient — set by `_createGhostElement` when the ghost + * appears, cleared on mouseup. + */ + dragging: boolean = false; + + /** + * One-shot flag suppressing left-edge auto-scroll for the current drag. Set when a toolbox drag + * starts inside the toolbox panel (whose right edge is near the canvas's left edge), so the user + * doesn't get a runaway scroll while still over the toolbox. The flag clears itself once the + * cursor moves comfortably past the left auto-scroll threshold (see `_enableAutoScroll`). + * Transient. + */ + disableLeftAutoScroll: boolean = false; + + /** + * DOM elements added during a drag (per-op multi-target dropzones, qubit-line dropzones) that + * need to be removed on mouseup. Owned here because their lifetime matches the gesture; cleared + * by [`clearTemporaryDropzones`](interactionActions.ts), the only function in the Action layer + * that touches the DOM. + */ + temporaryDropzones: SVGElement[] = []; +} diff --git a/source/npm/qsharp/ux/circuit-vis/angleExpression.ts b/source/npm/qsharp/ux/circuit-vis/angleExpression.ts index 4a1c8578ed7..6a9a62644b2 100644 --- a/source/npm/qsharp/ux/circuit-vis/angleExpression.ts +++ b/source/npm/qsharp/ux/circuit-vis/angleExpression.ts @@ -1,14 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -// Normalizes "pi" (any case) to "π" and trims whitespace for -// consistent storage and display. +// Normalizes "pi" (any case) to "π" and trims whitespace for consistent storage and display. export function normalizeAngleExpression(expr: string): string { return expr.trim().replace(/pi/gi, "π"); } -// Evaluate a simple arithmetic expression supporting numbers, + - * /, parentheses, and π. -// Returns `undefined` for invalid inputs. +// Evaluate a simple arithmetic expression supporting numbers, + - * /, parentheses, and π. Returns +// `undefined` for invalid inputs. export function evaluateAngleExpression(expr: string): number | undefined { if (!expr) return undefined; const src = normalizeAngleExpression(expr); diff --git a/source/npm/qsharp/ux/circuit-vis/circuitManipulation.ts b/source/npm/qsharp/ux/circuit-vis/circuitManipulation.ts deleted file mode 100644 index 046ab5bbf46..00000000000 --- a/source/npm/qsharp/ux/circuit-vis/circuitManipulation.ts +++ /dev/null @@ -1,668 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { getOperationRegisters } from "../../src/utils.js"; -import { Column, ComponentGrid, Operation, Unitary } from "./circuit.js"; -import { CircuitEvents } from "./events.js"; -import { Register } from "./register.js"; -import { - findOperation, - findParentArray, - findParentOperation, - getChildTargets, - locationStringToIndexes, -} from "./utils.js"; - -/** - * Move an operation in the circuit. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param sourceLocation The location string of the source operation. - * @param targetLocation The location string of the target position. - * @param sourceWire The wire index of the source operation. - * @param targetWire The wire index to move the operation to. - * @param movingControl Whether the operation is being moved as a control. - * @param insertNewColumn Whether to insert a new column when adding the operation. - * @returns The moved operation or null if the move was unsuccessful. - */ -const moveOperation = ( - circuitEvents: CircuitEvents, - sourceLocation: string, - targetLocation: string, - sourceWire: number, - targetWire: number, - movingControl: boolean, - insertNewColumn: boolean = false, -): Operation | null => { - const originalOperation = findOperation( - circuitEvents.componentGrid, - sourceLocation, - ); - - if (originalOperation == null) return null; - - // Create a deep copy of the source operation - const newSourceOperation: Operation = JSON.parse( - JSON.stringify(originalOperation), - ); - - _ensureQubitCount(circuitEvents, targetWire); - - // Update operation's targets and controls - _moveY( - circuitEvents, - newSourceOperation, - sourceLocation, - sourceWire, - targetWire, - movingControl, - ); - - // Move horizontally - _moveX( - circuitEvents, - newSourceOperation, - originalOperation, - targetLocation, - insertNewColumn, - ); - - const sourceOperationParent = findParentArray( - circuitEvents.componentGrid, - sourceLocation, - ); - if (sourceOperationParent == null) return null; - _removeOp(circuitEvents, originalOperation, sourceOperationParent); - removeTrailingUnusedQubits(circuitEvents); - - return newSourceOperation; -}; - -/** - * Move an operation horizontally. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param sourceOperation The operation to be moved. - * @param originalOperation The original source operation to be ignored during the check for existing operations. - * @param targetLocation The location string of the target position. - * @param insertNewColumn Whether to insert a new column when adding the operation. - */ -const _moveX = ( - circuitEvents: CircuitEvents, - sourceOperation: Operation, - originalOperation: Operation, - targetLocation: string, - insertNewColumn: boolean = false, -) => { - const targetOperationParent = findParentArray( - circuitEvents.componentGrid, - targetLocation, - ); - - const targetLastIndex = locationStringToIndexes(targetLocation).pop(); - - if (targetOperationParent == null || targetLastIndex == null) return; - - // Insert sourceOperation to target last index - _addOp( - circuitEvents, - sourceOperation, - targetOperationParent, - targetLastIndex, - insertNewColumn, - originalOperation, - ); -}; - -/** - * Move an operation vertically by changing its controls and targets. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param sourceOperation The operation to be moved. - * @param sourceLocation The location string of the source operation. - * @param sourceWire The wire index of the source operation. - * @param targetWire The wire index to move the operation to. - * @param movingControl Whether the operation is being moved as a control. - */ -const _moveY = ( - circuitEvents: CircuitEvents, - sourceOperation: Operation, - sourceLocation: string, - sourceWire: number, - targetWire: number, - movingControl: boolean, -): void => { - // Check if the source operation already has a target or control on the target wire - let targets: Register[]; - switch (sourceOperation.kind) { - case "unitary": - case "ket": - targets = sourceOperation.targets; - break; - case "measurement": - targets = sourceOperation.qubits; - break; - } - - let controls: Register[]; - switch (sourceOperation.kind) { - case "unitary": - controls = sourceOperation.controls || []; - break; - case "measurement": - case "ket": - controls = []; - break; - } - - let likeRegisters: Register[]; - let unlikeRegisters: Register[]; - if (movingControl) { - likeRegisters = controls; - unlikeRegisters = targets; - } else { - likeRegisters = targets; - unlikeRegisters = controls; - } - - // If a similar register already exists, don't move the gate - if (likeRegisters.find((reg) => reg.qubit === targetWire)) { - return; - } - - // If a different kind of register already exists, swap the control and target - if (unlikeRegisters.find((reg) => reg.qubit === targetWire)) { - const index = unlikeRegisters.findIndex((reg) => reg.qubit === targetWire); - unlikeRegisters[index].qubit = sourceWire; - } - - switch (sourceOperation.kind) { - case "unitary": - if (movingControl) { - sourceOperation.controls?.forEach((control) => { - if (control.qubit === sourceWire) { - control.qubit = targetWire; - } - }); - sourceOperation.controls = sourceOperation.controls?.sort( - (a, b) => a.qubit - b.qubit, - ); - } else { - sourceOperation.targets = [{ qubit: targetWire }]; - } - break; - case "measurement": - sourceOperation.qubits = [{ qubit: targetWire }]; - // The measurement result is updated later in the _updateMeasurementLines function - break; - case "ket": - sourceOperation.targets = [{ qubit: targetWire }]; - break; - } - - // Update parent operation targets - const parentOperation = findParentOperation( - circuitEvents.componentGrid, - sourceLocation, - ); - if (parentOperation) { - if (parentOperation.kind === "measurement") { - // Note: this is very confusing with measurements. Maybe the right thing to do - // will become more apparent if we implement expandable measurements. - parentOperation.results = getChildTargets(parentOperation); - } else if ( - parentOperation.kind === "unitary" || - parentOperation.kind === "ket" - ) { - parentOperation.targets = getChildTargets(parentOperation); - } - } -}; - -/** - * Add an operation into the circuit. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param sourceOperation The operation to be added. - * @param targetLocation The location string of the target position. - * @param targetWire The wire index to add the operation to. - * @param insertNewColumn Whether to insert a new column when adding the operation. - * @returns The added operation or null if the addition was unsuccessful. - */ -const addOperation = ( - circuitEvents: CircuitEvents, - sourceOperation: Operation, - targetLocation: string, - targetWire: number, - insertNewColumn: boolean = false, -): Operation | null => { - const targetOperationParent = findParentArray( - circuitEvents.componentGrid, - targetLocation, - ); - const targetLastIndex = locationStringToIndexes(targetLocation).pop(); - - if (targetOperationParent == null || targetLastIndex == null) return null; - // Create a deep copy of the source operation - const newSourceOperation: Operation = JSON.parse( - JSON.stringify(sourceOperation), - ); - - if (newSourceOperation.kind === "measurement") { - newSourceOperation.qubits = [{ qubit: targetWire }]; - // The measurement result is updated later in the _updateMeasurementLines function - } else if ( - newSourceOperation.kind === "unitary" || - newSourceOperation.kind === "ket" - ) { - newSourceOperation.targets = [{ qubit: targetWire }]; - } - - _ensureQubitCount(circuitEvents, targetWire); - - _addOp( - circuitEvents, - newSourceOperation, - targetOperationParent, - targetLastIndex, - insertNewColumn, - ); - - return newSourceOperation; -}; - -/** - * Remove an operation from the circuit. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param sourceLocation The location string of the operation to be removed. - */ -const removeOperation = ( - circuitEvents: CircuitEvents, - sourceLocation: string, -) => { - const sourceOperation = findOperation( - circuitEvents.componentGrid, - sourceLocation, - ); - const sourceOperationParent = findParentArray( - circuitEvents.componentGrid, - sourceLocation, - ); - - if (sourceOperation == null || sourceOperationParent == null) return null; - - _removeOp(circuitEvents, sourceOperation, sourceOperationParent); - removeTrailingUnusedQubits(circuitEvents); -}; - -/** - * Find and remove operations in-place that return `true` for a predicate function. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param pred The predicate function to determine which operations to remove. - */ -const findAndRemoveOperations = ( - circuitEvents: CircuitEvents, - pred: (op: Operation) => boolean, -) => { - // Remove operations that are true for the predicate function - const inPlaceFilter = (grid: ComponentGrid) => { - let i = 0; - while (i < grid.length) { - let j = 0; - while (j < grid[i].components.length) { - const op = grid[i].components[j]; - if (op.children) { - inPlaceFilter(op.children); - } - if (pred(op)) { - circuitEvents.decrementQubitUseCountForOp(op); - grid[i].components.splice(j, 1); - } else { - j++; - } - } - if (grid[i].components.length === 0) { - grid.splice(i, 1); - } else { - i++; - } - } - }; - - inPlaceFilter(circuitEvents.componentGrid); -}; - -/** - * Add a control to the specified operation on the given wire index. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param op The unitary operation to which the control will be added. - * @param wireIndex The index of the wire where the control will be added. - * @returns True if the control was added, false if it already existed. - */ -const addControl = ( - circuitEvents: CircuitEvents, - op: Unitary, - wireIndex: number, -): boolean => { - if (!op.controls) { - op.controls = []; - } - const existingControl = op.controls.find( - (control) => control.qubit === wireIndex, - ); - if (!existingControl) { - op.controls.push({ qubit: wireIndex }); - op.controls.sort((a, b) => a.qubit - b.qubit); - _ensureQubitCount(circuitEvents, wireIndex); - circuitEvents.qubitUseCounts[wireIndex]++; - return true; - } - return false; -}; - -/** - * Remove a control from the specified operation on the given wire index. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param op The unitary operation from which the control will be removed. - * @param wireIndex The index of the wire where the control will be removed. - * @returns True if the control was removed, false if it did not exist. - */ -const removeControl = ( - circuitEvents: CircuitEvents, - op: Unitary, - wireIndex: number, -): boolean => { - if (op.controls) { - const controlIndex = op.controls.findIndex( - (control) => control.qubit === wireIndex, - ); - if (controlIndex !== -1) { - op.controls.splice(controlIndex, 1); - circuitEvents.qubitUseCounts[wireIndex]--; - if (wireIndex === circuitEvents.qubits.length - 1) { - removeTrailingUnusedQubits(circuitEvents); - } - return true; - } - } - return false; -}; - -/** - * Resolves overlapping operations in each column of the component grid. - * For each column, splits overlapping operations into separate columns so that - * no two operations in the same column overlap on their register ranges. - * Modifies the component grid in-place. - * - * @param parentArray The component grid (array of columns) to process. - */ -const resolveOverlappingOperations = (parentArray: ComponentGrid): void => { - // Helper to resolve a single column into non-overlapping columns - const resolveColumn = (col: Column): Column[] => { - const newColumn: Column = { components: [] }; - let [lastMin, lastMax] = [-1, -1]; - let i = 0; - while (i < col.components.length) { - const op = col.components[i]; - const [currMin, currMax] = _getMinMaxRegIdx(op); - // Sets up the first operation for comparison or if the current operation doesn't overlap - if (i === 0 || !_doesOverlap([lastMin, lastMax], [currMin, currMax])) { - [lastMin, lastMax] = [currMin, currMax]; - i++; - } else { - // If they overlap, add the current operation to the new column - newColumn.components.push(op); - col.components.splice(i, 1); - } - } - if (newColumn.components.length > 0) { - const newColumns = resolveColumn(newColumn); - newColumns.push(col); - return newColumns; - } else { - return [col]; - } - }; - - // In-place update of parentArray - let i = 0; - while (i < parentArray.length) { - const col = parentArray[i]; - const newColumns = resolveColumn(col); - if (newColumns.length > 1) { - parentArray.splice(i, 1, ...newColumns); - i += newColumns.length; - } - i++; - } -}; - -/** - * Remove trailing unused qubits from the circuit. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - */ -const removeTrailingUnusedQubits = (circuitEvents: CircuitEvents) => { - while ( - circuitEvents.qubitUseCounts.length > 0 && - circuitEvents.qubitUseCounts[circuitEvents.qubitUseCounts.length - 1] === 0 - ) { - circuitEvents.qubits.pop(); - circuitEvents.qubitUseCounts.pop(); - } -}; - -/** - * Determines whether two register index ranges overlap. - * - * @param op1 The [min, max] register indices of the first operation. - * @param op2 The [min, max] register indices of the second operation. - * @returns True if the ranges overlap, false otherwise. - */ -const _doesOverlap = ( - op1: [number, number], - op2: [number, number], -): boolean => { - const [min1, max1] = op1; - const [min2, max2] = op2; - return max1 >= min2 && max2 >= min1; -}; - -/** - * Add an operation to the circuit at the specified location. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param sourceOperation The operation to be added. - * @param targetOperationParent The parent grid where the operation will be added. - * @param targetLastIndex The index within the parent array where the operation will be added. - * @param insertNewColumn Whether to insert a new column when adding the operation. - * @param originalOperation The original source operation to be ignored during the check for existing operations. - */ -const _addOp = ( - circuitEvents: CircuitEvents, - sourceOperation: Operation, - targetOperationParent: ComponentGrid, - targetLastIndex: [number, number], - insertNewColumn: boolean = false, - originalOperation: Operation | null = null, -) => { - const [colIndex, opIndex] = targetLastIndex; - if (targetOperationParent[colIndex] == null) { - targetOperationParent[colIndex] = { components: [] }; - } - - insertNewColumn = - insertNewColumn || _isClassicallyControlled(sourceOperation); - - // Check if there are any existing operations in the target - // column within the wire range of the new operation - if (!insertNewColumn) { - const [minTarget, maxTarget] = _getMinMaxRegIdx(sourceOperation); - for (const op of targetOperationParent[colIndex].components) { - if (op === originalOperation) continue; - - const [opMinTarget, opMaxTarget] = _getMinMaxRegIdx(op); - if (_doesOverlap([minTarget, maxTarget], [opMinTarget, opMaxTarget])) { - insertNewColumn = true; - break; - } - } - } - - if (insertNewColumn) { - targetOperationParent.splice(colIndex, 0, { - components: [sourceOperation], - }); - } else { - targetOperationParent[colIndex].components.splice( - opIndex, - 0, - sourceOperation, - ); - } - - circuitEvents.incrementQubitUseCountForOp(sourceOperation); - - if (sourceOperation.kind === "measurement") { - for (const targetWire of sourceOperation.qubits) { - _updateMeasurementLines(circuitEvents, targetWire.qubit); - } - } -}; - -/** - * Get the minimum and maximum register indices for a given operation. - * Based on getMinMaxRegIdx in process.ts, but without the numQubits. - * - * @param operation The operation for which to get the register indices. - * @returns A tuple containing the minimum and maximum register indices. - */ -const _getMinMaxRegIdx = (operation: Operation): [number, number] => { - const qRegs: Register[] = getOperationRegisters(operation).filter( - ({ result }) => result === undefined, - ); - if (qRegs.length === 0) return [-1, -1]; - const qRegIdxList: number[] = qRegs.map(({ qubit }) => qubit); - // Pad the contiguous range of registers that it covers. - const minRegIdx: number = Math.min(...qRegIdxList); - const maxRegIdx: number = Math.max(...qRegIdxList); - - return [minRegIdx, maxRegIdx]; -}; - -/** - * Check if an operation is classically controlled. - * - * @param operation The operation for which to get the register indices. - * @returns True if the operation is classically controlled, false otherwise. - */ -const _isClassicallyControlled = (operation: Operation): boolean => { - if (operation.kind !== "unitary") return false; - if (operation.controls === undefined) return false; - const clsControl = operation.controls.find( - ({ result }) => result !== undefined, - ); - return clsControl !== undefined; -}; - -/** - * Remove an operation from the circuit. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param sourceOperation The operation to be removed. - * @param sourceOperationParent The parent grid from which the operation will be removed. - */ -const _removeOp = ( - circuitEvents: CircuitEvents, - sourceOperation: Operation, - sourceOperationParent: ComponentGrid, -) => { - if (sourceOperation.dataAttributes === undefined) { - sourceOperation.dataAttributes = { removed: "true" }; - } else { - sourceOperation.dataAttributes["removed"] = "true"; - } - - // Find and remove the operation in sourceOperationParent - for (let colIndex = 0; colIndex < sourceOperationParent.length; colIndex++) { - const col = sourceOperationParent[colIndex]; - const indexToRemove = col.components.findIndex( - (operation) => - operation.dataAttributes && operation.dataAttributes["removed"], - ); - if (indexToRemove !== -1) { - col.components.splice(indexToRemove, 1); - if (col.components.length === 0) { - sourceOperationParent.splice(colIndex, 1); - } - break; - } - } - - circuitEvents.decrementQubitUseCountForOp(sourceOperation); - - if (sourceOperation.kind === "measurement") { - for (const result of sourceOperation.results) { - _updateMeasurementLines(circuitEvents, result.qubit); - } - } -}; - -/** - * Update measurement lines for a specific wire. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param wireIndex The index of the wire to update the measurement lines for. - */ -const _updateMeasurementLines = ( - circuitEvents: CircuitEvents, - wireIndex: number, -) => { - _ensureQubitCount(circuitEvents, wireIndex); - let resultIndex = 0; - for (const col of circuitEvents.componentGrid) { - for (const comp of col.components) { - if (comp.kind === "measurement") { - // Find measurements on the correct wire based on their qubit. - const qubit = comp.qubits.find((qubit) => qubit.qubit === wireIndex); - if (qubit) { - // Remove any existing results and add a new one with the updated index. - comp.results = [{ qubit: qubit.qubit, result: resultIndex++ }]; - } - } - } - } - circuitEvents.qubits[wireIndex].numResults = - resultIndex > 0 ? resultIndex : undefined; -}; - -/** - * Ensure that the qubit count in the circuit is sufficient for the given wire index. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param wireIndex The index of the wire to check. - */ -const _ensureQubitCount = (circuitEvents: CircuitEvents, wireIndex: number) => { - while (circuitEvents.qubits.length <= wireIndex) { - circuitEvents.qubits.push({ - id: circuitEvents.qubits.length, - numResults: undefined, - }); - circuitEvents.qubitUseCounts.push(0); - } -}; - -export { - moveOperation, - addOperation, - removeOperation, - findAndRemoveOperations, - addControl, - removeControl, - resolveOverlappingOperations, - removeTrailingUnusedQubits, -}; diff --git a/source/npm/qsharp/ux/circuit-vis/contextMenu.ts b/source/npm/qsharp/ux/circuit-vis/contextMenu.ts deleted file mode 100644 index fa3cc0a8315..00000000000 --- a/source/npm/qsharp/ux/circuit-vis/contextMenu.ts +++ /dev/null @@ -1,335 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { Parameter } from "./circuit.js"; -import { removeControl, removeOperation } from "./circuitManipulation.js"; -import { CircuitEvents } from "./events.js"; -import { findGateElem, findOperation } from "./utils.js"; -import { - isValidAngleExpression, - normalizeAngleExpression, -} from "./angleExpression.js"; - -/** - * Adds a context menu to a host element in the circuit visualization. - * - * @param circuitEvents The CircuitEvents instance to handle circuit-related events. - * @param hostElem The SVG element representing a gate component to which the context menu will be added. - */ -const addContextMenuToHostElem = ( - circuitEvents: CircuitEvents, - hostElem: SVGGraphicsElement, -) => { - hostElem?.addEventListener("contextmenu", (ev: MouseEvent) => { - ev.preventDefault(); - - // Remove any existing context menu - const existingContextMenu = document.querySelector(".context-menu"); - if (existingContextMenu) { - document.body.removeChild(existingContextMenu); - } - - const gateElem = findGateElem(hostElem); - if (!gateElem) return; - const selectedLocation = gateElem.getAttribute("data-location"); - const selectedOperation = findOperation( - circuitEvents.componentGrid, - selectedLocation, - ); - if (!selectedOperation || !selectedLocation) return; - - const contextMenu = document.createElement("div"); - contextMenu.classList.add("context-menu"); - contextMenu.style.top = `${ev.clientY + window.scrollY}px`; - contextMenu.style.left = `${ev.clientX + window.scrollX}px`; - contextMenu.addEventListener("contextmenu", (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - contextMenu.addEventListener("mouseup", (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - - const dataWireStr = hostElem.getAttribute("data-wire"); - const dataWire = dataWireStr != null ? parseInt(dataWireStr) : null; - const isControl = - hostElem.classList.contains("control-dot") && dataWire != null; - - const deleteOption = _createContextMenuItem("Delete", () => { - removeOperation(circuitEvents, selectedLocation); - circuitEvents.renderFn(); - }); - - if ( - selectedOperation.kind === "measurement" || - selectedOperation.kind === "ket" - ) { - contextMenu.appendChild(deleteOption); - } else if (isControl) { - const removeControlOption = _createContextMenuItem( - "Remove control", - () => { - removeControl(circuitEvents, selectedOperation, dataWire); - circuitEvents.renderFn(); - }, - ); - contextMenu.appendChild(removeControlOption!); - } else { - const adjointOption = _createContextMenuItem("Toggle Adjoint", () => { - if (selectedOperation.kind !== "unitary") return; - selectedOperation.isAdjoint = !selectedOperation.isAdjoint; - circuitEvents.renderFn(); - }); - - const addControlOption = _createContextMenuItem("Add Control", () => { - if (selectedOperation.kind !== "unitary") return; - circuitEvents._startAddingControl(selectedOperation, selectedLocation); - }); - - let removeControlOption: HTMLDivElement | undefined; - if (selectedOperation.controls && selectedOperation.controls.length > 0) { - removeControlOption = _createContextMenuItem("Remove Control", () => { - circuitEvents._startRemovingControl(selectedOperation); - }); - contextMenu.appendChild(removeControlOption); - } - - const promptArgOption = _createContextMenuItem("Edit Argument", () => { - promptForArguments( - selectedOperation.params!, - selectedOperation.args, - ).then((args) => { - if (args.length > 0) { - selectedOperation.args = args; - } else { - selectedOperation.args = undefined; - } - circuitEvents.renderFn(); - }); - }); - - if (selectedOperation.gate == "X") { - contextMenu.appendChild(addControlOption); - if (removeControlOption) { - contextMenu.appendChild(removeControlOption); - } - contextMenu.appendChild(deleteOption); - } else { - contextMenu.appendChild(adjointOption); - contextMenu.appendChild(addControlOption); - if (removeControlOption) { - contextMenu.appendChild(removeControlOption); - } - if ( - selectedOperation.params !== undefined && - selectedOperation.params.length > 0 - ) { - contextMenu.appendChild(promptArgOption); - } - contextMenu.appendChild(deleteOption); - } - } - - document.body.appendChild(contextMenu); - - document.addEventListener( - "click", - () => { - if (document.body.contains(contextMenu)) { - document.body.removeChild(contextMenu); - } - }, - { once: true }, - ); - }); -}; - -/** - * Prompt the user for argument values. - * @param params - The parameters for which the user needs to provide values. - * @param defaultArgs - The default values for the parameters, if any. - * @returns A Promise that resolves with the user-provided arguments as an array of strings. - */ -const promptForArguments = ( - params: Parameter[], - defaultArgs: string[] = [], -): Promise => { - return new Promise((resolve) => { - const collectedArgs: string[] = []; - let currentIndex = 0; - - const promptNext = () => { - if (currentIndex >= params.length) { - resolve(collectedArgs); - return; - } - - const param = params[currentIndex]; - const defaultValue = defaultArgs[currentIndex] || ""; - - _createInputPrompt( - `Enter value for parameter "${param.name}":`, - (userInput) => { - if (userInput !== null) { - collectedArgs.push(userInput); - currentIndex++; - promptNext(); - } else { - resolve(defaultArgs); // User canceled the prompt - } - }, - defaultValue, - isValidAngleExpression, - 'Examples: "2.0 * π" or "π / 2.0"', - ); - }; - - promptNext(); - }); -}; - -/** - * Create a context menu item - * @param text - The text to display in the menu item - * @param onClick - The function to call when the menu item is clicked - * @returns The created menu item element - */ -const _createContextMenuItem = ( - text: string, - onClick: () => void, -): HTMLDivElement => { - const menuItem = document.createElement("div"); - menuItem.classList.add("context-menu-option"); - menuItem.textContent = text; - menuItem.addEventListener("click", onClick); - return menuItem; -}; - -/** - * Create a user input prompt element - * @param message - The message to display in the prompt - * @param callback - The callback function to handle the user input - * @param defaultValue - The default value to display in the input element - * @param validateInput - A function to validate the user input - * @param placeholder - The placeholder text for the input element - */ -const _createInputPrompt = ( - message: string, - callback: (input: string | null) => void, - defaultValue: string = "", - validateInput: (input: string) => boolean = () => true, - placeholder: string = "", -) => { - // Create the prompt overlay - const overlay = document.createElement("div"); - overlay.classList.add("prompt-overlay"); - overlay.addEventListener("contextmenu", (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - - // Create the prompt container - const promptContainer = document.createElement("div"); - promptContainer.classList.add("prompt-container"); - - // Create the message element - const messageElem = document.createElement("div"); - messageElem.classList.add("prompt-message"); - messageElem.textContent = message; - - // Create the input element - const inputElem = document.createElement("input"); - inputElem.classList.add("prompt-input"); - inputElem.type = "text"; - inputElem.value = defaultValue; - inputElem.placeholder = placeholder; - - // Create the buttons container - const buttonsContainer = document.createElement("div"); - buttonsContainer.classList.add("prompt-buttons"); - - // Create the OK button - const okButton = document.createElement("button"); - okButton.classList.add("prompt-button"); - okButton.textContent = "OK"; - - // Function to validate input and toggle the OK button - const validateAndToggleOkButton = () => { - const processedInput = normalizeAngleExpression(inputElem.value); - const isValid = validateInput(processedInput); - okButton.disabled = !isValid; - }; - - // Add input event listener for validation - inputElem.addEventListener("input", validateAndToggleOkButton); - - // Handle Enter key when input is focused - inputElem.addEventListener("keydown", (event) => { - if (event.key === "Enter" && !okButton.disabled) { - event.preventDefault(); - okButton.click(); - } - }); - - okButton.disabled = !validateInput(normalizeAngleExpression(defaultValue)); - okButton.addEventListener("click", () => { - callback(normalizeAngleExpression(inputElem.value)); - document.body.removeChild(overlay); - document.removeEventListener("keydown", handleGlobalKeyDown, true); - }); - - // Create the π button - const piButton = document.createElement("button"); - piButton.textContent = "π"; - piButton.classList.add("pi-button", "prompt-button"); - piButton.addEventListener("click", () => { - const cursorPosition = inputElem.selectionStart || 0; - const textBefore = inputElem.value.substring(0, cursorPosition); - const textAfter = inputElem.value.substring(cursorPosition); - inputElem.value = `${textBefore}π${textAfter}`; - inputElem.focus(); - inputElem.setSelectionRange(cursorPosition + 1, cursorPosition + 1); // Move cursor after "π" - validateAndToggleOkButton(); - }); - - // Create the Cancel button - const cancelButton = document.createElement("button"); - cancelButton.classList.add("prompt-button"); - cancelButton.textContent = "Cancel"; - cancelButton.addEventListener("click", () => { - callback(null); - document.body.removeChild(overlay); - document.removeEventListener("keydown", handleGlobalKeyDown, true); - }); - - // Handle Escape key globally while prompt is open - const handleGlobalKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - cancelButton.click(); - } - }; - document.addEventListener("keydown", handleGlobalKeyDown, true); - - // Append buttons to the container - buttonsContainer.appendChild(piButton); - buttonsContainer.appendChild(okButton); - buttonsContainer.appendChild(cancelButton); - - // Append elements to the prompt container - promptContainer.appendChild(messageElem); - promptContainer.appendChild(inputElem); - promptContainer.appendChild(buttonsContainer); - - // Append the prompt container to the overlay - overlay.appendChild(promptContainer); - - // Append the overlay to the document body - document.body.appendChild(overlay); - - // Focus the input element - inputElem.focus(); -}; - -export { addContextMenuToHostElem, promptForArguments }; diff --git a/source/npm/qsharp/ux/circuit-vis/circuit.ts b/source/npm/qsharp/ux/circuit-vis/data/circuit.ts similarity index 60% rename from source/npm/qsharp/ux/circuit-vis/circuit.ts rename to source/npm/qsharp/ux/circuit-vis/data/circuit.ts index 691e519f0fa..7468e065460 100644 --- a/source/npm/qsharp/ux/circuit-vis/circuit.ts +++ b/source/npm/qsharp/ux/circuit-vis/data/circuit.ts @@ -15,7 +15,7 @@ export { type Parameter, type Qubit, type SourceLocation, -} from "../../src/data-structures/circuit.js"; +} from "../../../src/data-structures/circuit.js"; -export { CURRENT_VERSION } from "../../src/data-structures/circuit.js"; -export { toCircuitGroup } from "../../src/data-structures/legacyCircuitUpdate.js"; +export { CURRENT_VERSION } from "../../../src/data-structures/circuit.js"; +export { toCircuitGroup } from "../../../src/data-structures/legacyCircuitUpdate.js"; diff --git a/source/npm/qsharp/ux/circuit-vis/data/circuitModel.ts b/source/npm/qsharp/ux/circuit-vis/data/circuitModel.ts new file mode 100644 index 00000000000..f0ae91c6236 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/data/circuitModel.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { getOperationRegisters } from "../utils.js"; +import { Circuit, ComponentGrid, Operation, Qubit } from "./circuit.js"; + +/** + * `CircuitModel` — the persistent circuit definition (the Data layer of the Data / Action / View + * architecture). + * + * Owns: + * - `componentGrid` — the grid of operations. + * - `qubits` — the qubit lines (wires). + * - `qubitUseCounts`— per-wire op-use counts (derived state, maintained incrementally). + * + * Maintains its own invariants (qubit count, use counts) but does not perform user-level edits — + * those live in [circuitActions.ts](circuitActions.ts), which take a `CircuitModel` and mutate it + * in place. No DOM, SVG, rendering, or interaction state, which keeps `circuitActions.*` + * unit-testable without JSDOM. + */ +export class CircuitModel { + /** The grid of components rendered as columns of operations. */ + componentGrid: ComponentGrid; + + /** The qubits/wires in this circuit. */ + qubits: Qubit[]; + + /** + * Per-wire op-use counts. `qubitUseCounts[i]` is the number of operations whose register list + * includes qubit `i`. Used by `removeTrailingUnusedQubits` to drop unused trailing wires. + * Maintained incrementally by the `increment...` / `decrement...` methods, which Actions call + * when adding/removing an op. + */ + qubitUseCounts: number[]; + + /** + * Build a `CircuitModel` from an existing `Circuit`. `componentGrid` and `qubits` are borrowed by + * reference, not copied, so the renderer's `Sqore` and the editor's `CircuitEvents` share the + * same data. + */ + constructor(circuit: Circuit) { + this.componentGrid = circuit.componentGrid; + this.qubits = circuit.qubits; + this.qubitUseCounts = new Array(this.qubits.length).fill(0); + for (const column of this.componentGrid) { + for (const op of column.components) { + this.incrementQubitUseCountForOp(op); + } + } + } + + /** + * Return the underlying `Circuit` shape for read-only consumers. The result aliases the model's + * arrays — callers needing a deep copy must clone explicitly. + */ + snapshot(): Circuit { + return { qubits: this.qubits, componentGrid: this.componentGrid }; + } + + /** + * Grow `qubits` (and `qubitUseCounts`) so that `wireIndex` is a valid wire index. No-op if the + * model already has at least `wireIndex + 1` wires. + */ + ensureQubitCount(wireIndex: number): void { + while (this.qubits.length <= wireIndex) { + this.qubits.push({ id: this.qubits.length, numResults: undefined }); + this.qubitUseCounts.push(0); + } + } + + /** + * Drop trailing wires that no operation references anywhere in the tree (including a group op's + * derived `.targets` / `.results`). + * + * Walks the grid directly rather than consulting `qubitUseCounts`: a group op's derived + * `.targets` can be rewritten without a matching count adjustment, so the counter can report a + * wire as unused while the group still names it — dropping such a wire would crash the next + * render. + */ + removeTrailingUnusedQubits(): void { + let maxUsed = -1; + const walk = (grid: ComponentGrid): void => { + for (const col of grid) { + for (const op of col.components) { + for (const reg of getOperationRegisters(op)) { + if (reg.result === undefined && reg.qubit > maxUsed) { + maxUsed = reg.qubit; + } + } + if (op.children) walk(op.children); + } + } + }; + walk(this.componentGrid); + + while (this.qubits.length > maxUsed + 1) { + this.qubits.pop(); + this.qubitUseCounts.pop(); + } + } + + /** + * Bump `qubitUseCounts[i]` for every qubit register `i` referenced by `op` (skips + * classical-result registers). Out-of-range wires are silently ignored. + */ + incrementQubitUseCountForOp(op: Operation): void { + for (const reg of getOperationRegisters(op)) { + if ( + reg.result === undefined && + reg.qubit >= 0 && + reg.qubit < this.qubitUseCounts.length + ) { + this.qubitUseCounts[reg.qubit]++; + } + } + } + + /** Mirror of `incrementQubitUseCountForOp`; called when removing an op. */ + decrementQubitUseCountForOp(op: Operation): void { + for (const reg of getOperationRegisters(op)) { + if ( + reg.result === undefined && + reg.qubit >= 0 && + reg.qubit < this.qubitUseCounts.length + ) { + this.qubitUseCounts[reg.qubit]--; + } + } + } +} diff --git a/source/npm/qsharp/ux/circuit-vis/data/location.ts b/source/npm/qsharp/ux/circuit-vis/data/location.ts new file mode 100644 index 00000000000..4c55061dfb5 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/data/location.ts @@ -0,0 +1,196 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * `Location` — value type for hierarchical addresses inside a circuit's `componentGrid`. + * + * An operation's address is a string of the form `"col,op"` (top level) or `"col,op-col,op-..."` + * (nested inside expanded groups). This module owns the parse/compose of that format. The string + * form is what's stored on the wire (SVG `data-location` / `data-dropzone-location` attributes, + * `Operation.dataAttributes .location`, `LayoutMap.scopes` keys); `Location` is the in-memory + * representation callers operate on. + * + * Immutable: every "mutation" returns a new `Location` and the underlying `_segments` array is + * frozen. + * + * The empty location is the root scope: `Location.root()` (and `Location.parse("")`) represents the + * top-level grid, with string form `""`. + */ +export class Location { + /** + * Frozen segments. Each tuple is `[colIndex, opIndex]` for one level of nesting, ordered + * root-to-leaf. + */ + readonly segments: ReadonlyArray; + + /** + * Cached root singleton. Shared because `Location` is immutable and the empty case is hit by + * every top-level dropzone / `parent()` chain that bottoms out. + */ + private static readonly _ROOT = new Location(Object.freeze([])); + + /** Use one of the static factories. */ + private constructor(segments: ReadonlyArray) { + this.segments = segments; + } + + /** The empty location — addresses the top-level scope itself. */ + static root(): Location { + return Location._ROOT; + } + + /** + * Parse a location string into a `Location`: + * + * - `""` → root (no segments). + * - `"0,1"` → one segment. + * - `"0,1-2,3"` → two segments. + * + * Throws "Invalid location" for any segment that isn't exactly `,`. + */ + static parse(s: string): Location { + if (s === "") return Location._ROOT; + const segments = s.split("-").map((segment): readonly [number, number] => { + const coords = segment.split(","); + if (coords.length !== 2) { + throw new Error("Invalid location"); + } + const col = parseInt(coords[0]); + const op = parseInt(coords[1]); + if (!Number.isInteger(col) || !Number.isInteger(op)) { + throw new Error("Invalid location"); + } + return Object.freeze<[number, number]>([col, op]); + }); + return new Location(Object.freeze(segments)); + } + + /** + * Build a `Location` from already-parsed segments. Useful when the caller already has the numeric + * tuples (e.g. sqore building child locations during render). + */ + static of(...segments: ReadonlyArray): Location { + if (segments.length === 0) return Location._ROOT; + return new Location( + Object.freeze( + segments.map((s) => Object.freeze<[number, number]>([s[0], s[1]])), + ), + ); + } + + /** `true` if this is the root scope (no segments). */ + get isRoot(): boolean { + return this.segments.length === 0; + } + + /** Number of segments — i.e. how deep this location is nested. */ + get depth(): number { + return this.segments.length; + } + + /** + * Last `(colIndex, opIndex)` segment, or `null` if this is the root location. Most callers that + * ask for `.last()` are about to dereference into the parent scope's grid at that `(col, op)`. + */ + last(): readonly [number, number] | null { + return this.segments.length === 0 + ? null + : this.segments[this.segments.length - 1]; + } + + /** + * The location of the scope that *contains* this op. Drops the last segment; `.parent()` on root + * returns root (so chained walks terminate cleanly). For an op at `"0,1-2,3"`, the parent is + * `"0,1"`. + */ + parent(): Location { + if (this.segments.length <= 1) return Location._ROOT; + return new Location(Object.freeze(this.segments.slice(0, -1))); + } + + /** + * Append a `(col, op)` segment, producing the location of a child inside *this* scope. + */ + child(col: number, op: number): Location { + return new Location( + Object.freeze([ + ...this.segments, + Object.freeze<[number, number]>([col, op]), + ]), + ); + } + + /** + * Canonical string form. Round-trips with `parse`: `Location.parse(loc.toString()).equals(loc) + * === true`. + */ + toString(): string { + return this.segments.map(([c, o]) => `${c},${o}`).join("-"); + } + + /** Structural equality. */ + equals(other: Location): boolean { + if (this.segments.length !== other.segments.length) return false; + for (let i = 0; i < this.segments.length; i++) { + if ( + this.segments[i][0] !== other.segments[i][0] || + this.segments[i][1] !== other.segments[i][1] + ) { + return false; + } + } + return true; + } + + /** + * `true` if this location comes strictly *before* `other` in document order — the order the + * renderer visits ops in a depth-first walk of the grid. + * + * Segment-by-segment: compare `(col, op)` lexicographically; if all compared segments are equal, + * the shorter location (an ancestor) comes first. Equal locations return `false`. + * + * Not the same as "producer must precede consumer": ops in the same column are simultaneous, not + * before/after. Use `inEarlierColumnThan` for that. + */ + before(other: Location): boolean { + const n = Math.min(this.segments.length, other.segments.length); + for (let i = 0; i < n; i++) { + const [ac, ao] = this.segments[i]; + const [bc, bo] = other.segments[i]; + if (ac !== bc) return ac < bc; + if (ao !== bo) return ao < bo; + } + return this.segments.length < other.segments.length; + } + + /** + * `true` if this location is in a strictly **earlier column** than `other` — i.e. an op here is + * guaranteed to finish before an op at `other` starts, in real time-step order, with ancestor + * groups projecting their column onto everything they contain. + * + * Used to enforce "producer measurement must finish before its classical consumer starts" in the + * dropzone filter and the `moveOperation` safety net. `before` is wrong for this: two ops in the + * same column are simultaneous, and a consumer promoted to the producer's outer column shares + * that column even as a sibling. + * + * Walks segment-by-segment from the root: at each shared level, an earlier column wins + * immediately, a later column loses, and equal columns recurse deeper. Equal columns with + * differing op-indices are siblings at the same time-step (not earlier); an + * ancestor-vs-descendant pair shares the column at every level (not earlier). + */ + inEarlierColumnThan(other: Location): boolean { + const n = Math.min(this.segments.length, other.segments.length); + for (let i = 0; i < n; i++) { + const [ac, ao] = this.segments[i]; + const [bc, bo] = other.segments[i]; + if (ac < bc) return true; + if (ac > bc) return false; + // Same column; differing op-indices mean sibling subtrees at the same time-step. + if (ao !== bo) return false; + // Same (col, op) — keep walking. + } + // One location ran out: ancestor-vs-descendant or equal, both meaning "same column at every + // shared level". + return false; + } +} diff --git a/source/npm/qsharp/ux/circuit-vis/register.ts b/source/npm/qsharp/ux/circuit-vis/data/register.ts similarity index 76% rename from source/npm/qsharp/ux/circuit-vis/register.ts rename to source/npm/qsharp/ux/circuit-vis/data/register.ts index ccfda7ba559..bdac05c5600 100644 --- a/source/npm/qsharp/ux/circuit-vis/register.ts +++ b/source/npm/qsharp/ux/circuit-vis/data/register.ts @@ -6,4 +6,4 @@ export { type Register, type RegisterMap, type RegisterRenderData, -} from "../../src/data-structures/register.js"; +} from "../../../src/data-structures/register.js"; diff --git a/source/npm/qsharp/ux/circuit-vis/data/viewState.ts b/source/npm/qsharp/ux/circuit-vis/data/viewState.ts new file mode 100644 index 00000000000..6b441376fc9 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/data/viewState.ts @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ComponentGrid } from "./circuit.js"; + +/** + * Per-session view preferences that survive `Sqore.renderCircuit()` but are NOT persisted to the + * saved circuit (`.qsc`) file. + * + * The editor has three state layers with different lifetimes: + * + * | Layer | Lifetime | Persisted? | Owner | + * | ------------------ | ---------------------- | ---------- | ------------------ | + * | `CircuitModel` | The circuit's lifetime | Yes (.qsc) | Action layer | + * | `ViewState` | The editor session | No | View layer (Sqore) | + * | `InteractionState` | A single gesture | No | Action layer | + * + * `ViewState` holds state the user expects to stay stable while editing but that doesn't belong in + * the file — chiefly per-group expand/collapse. + * + * # Override semantics + * + * Only explicit user choices are stored. Default expansion is computed per-render in + * [sqore.ts](../sqore.ts); user overrides are applied after via [`applyTo`](#method-applyTo): + * + * - Absent entry → defaults win. + * - `true` entry → expanded, even if the default would collapse. + * - `false` entry → collapsed, even if the default would expand. + * + * # Position stability + * + * Entries are keyed by the op's location string (e.g. `"0,0-1,2"`), which is not stable under edits + * that splice columns or grids. The View layer (`Sqore`) snapshots an `op → location` map each + * render and calls [`rebase`](#method-rebase) on the next render to migrate keys forward by object + * identity. External tree replacement (`Sqore.updateCircuit`) breaks that identity link, so the + * snapshot is dropped and the next render starts fresh. + */ +export class ViewState { + /** + * Map from op location string to the user's explicit expansion choice. Absent = no choice (use + * defaults). + */ + readonly expanded = new Map(); + + /** + * Record the user's choice to expand or collapse the op at `location`. Idempotent. + * + * Collapsing also clears explicit overrides on descendants of `location`, so re-expanding later + * doesn't auto-spring previously-expanded children back open. + */ + setExpanded(location: string, expanded: boolean): void { + this.expanded.set(location, expanded); + if (!expanded) { + const descendantPrefix = location + "-"; + for (const key of Array.from(this.expanded.keys())) { + if (key.startsWith(descendantPrefix)) this.expanded.delete(key); + } + } + } + + /** + * Drop the user's choice for the op at `location`, falling back to the default-expansion logic on + * the next render. Idempotent; absence of an entry is fine. + */ + clearExpanded(location: string): void { + this.expanded.delete(location); + } + + /** + * Rewrite expansion keys via an old → new location mapping. + * + * For each existing entry at `oldKey`: + * - `remap.get(oldKey) === ` → rekey to that string. + * - `remap.get(oldKey) === null` → drop the entry (op is no longer in the grid). + * - `remap.has(oldKey) === false` → leave unchanged (no info about this op; keep rather than + * guess). + * + * The View layer ([sqore.ts](../sqore.ts)) calls this each render to track ops whose locations + * shifted due to upstream edits. The remap is computed by object identity against the previous + * render's snapshot, so unmoved ops keep their key even when their string location would + * otherwise drift. Idempotent against a fixed-point remap; on key collisions, later writes win. + */ + rebase(remap: ReadonlyMap): void { + // Build the rebased map in a fresh container, then swap it in. Two passes (read-only iteration, + // then atomic replacement) is what makes key chains (`a → b`, `b → c`) and key swaps (`a → b`, + // `b → a`) correct — an in-place rekey would clobber an entry before its own rename had a + // chance to run. + const next = new Map(); + for (const [oldKey, value] of this.expanded) { + if (!remap.has(oldKey)) { + // Untracked: keep at the same key. + next.set(oldKey, value); + continue; + } + const newKey = remap.get(oldKey); + if (newKey == null) continue; // op is gone; drop the entry + next.set(newKey, value); + } + this.expanded.clear(); + for (const [k, v] of next) this.expanded.set(k, v); + } + + /** + * Apply user expansion overrides to a freshly-rendered component grid. Walks `grid` recursively + * and, for any op whose location string has a `ViewState.expanded` entry, sets + * `dataAttributes.expanded` to `"true"` / `"false"` accordingly. + * + * Call AFTER the per-render default-expansion passes (`expandOperationsToDepth`, + * `expandIfSingleOperation`) so user overrides win. + * + * @param grid The grid to mutate. Must already have `dataAttributes.location` populated on every + * op (i.e. `fillGateRegistry` has run). + */ + applyTo(grid: ComponentGrid): void { + grid.forEach((col) => + col.components.forEach((op) => { + const loc = op.dataAttributes?.["location"]; + if (loc != null) { + const userPref = this.expanded.get(loc); + if (userPref !== undefined) { + if (op.dataAttributes == null) op.dataAttributes = {}; + op.dataAttributes["expanded"] = userPref ? "true" : "false"; + } + } + if (op.children != null) { + this.applyTo(op.children); + } + }), + ); + } +} diff --git a/source/npm/qsharp/ux/circuit-vis/draggable.ts b/source/npm/qsharp/ux/circuit-vis/draggable.ts deleted file mode 100644 index e755ee7ff57..00000000000 --- a/source/npm/qsharp/ux/circuit-vis/draggable.ts +++ /dev/null @@ -1,531 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { ComponentGrid, Operation } from "./circuit.js"; -import { - gateHeight, - gatePadding, - minGateWidth, - regLineStart, - startX, -} from "./constants.js"; -import { box, controlDot, line } from "./formatters/formatUtils.js"; -import { formatGate } from "./formatters/gateFormatter.js"; -import { qubitInput } from "./formatters/inputFormatter.js"; -import { toRenderData } from "./panel.js"; -import { Sqore } from "./sqore.js"; -import { - findLocation, - getHostElems, - getMinMaxRegIdx, - getToolboxElems, - getWireData, - locationStringToIndexes, -} from "./utils.js"; - -/** Register height is the height of a single gate including the padding on the top and bottom. */ -const registerHeight: number = gateHeight + gatePadding * 2; - -interface Context { - container: HTMLElement; - svg: SVGElement; - operationGrid: ComponentGrid; -} - -/** - * Create dropzones elements for dragging on circuit. - * - * @param container HTML element for rendering visualization into - * @param sqore Sqore object - */ -const createDropzones = (container: HTMLElement, sqore: Sqore): void => { - const svg = container.querySelector("svg.qviz") as SVGElement; - - const context: Context = { - container, - svg, - operationGrid: sqore.circuit.componentGrid, - }; - _addStyles(container); - _addDataWires(container); - svg.appendChild(_ghostQubitLayer(context)); - svg.appendChild(_dropzoneLayer(context)); -}; - -/** - * Creates a ghost element for dragging operations in the circuit visualization. - * - * @param ev The mouse event that triggered the creation of the ghost element. - * @param container The HTML container element where the ghost element will be appended. - * @param selectedOperation The operation that is being dragged. - * @param isControl A boolean indicating if the ghost element is for a control operation. - */ -const createGateGhost = ( - ev: MouseEvent, - container: HTMLElement, - selectedOperation: Operation, - isControl: boolean, -) => { - const ghost = isControl - ? controlDot(20, 20, []) - : (() => { - const ghostRenderData = toRenderData(selectedOperation, 0, 0); - return formatGate(ghostRenderData).cloneNode(true) as SVGElement; - })(); - - _createGhostElement(container, ev, ghost, isControl); -}; - -/** - * Creates a ghost element for dragging a qubit line label. - * - * @param ev The mouse event that triggered the drag. - * @param container The HTML container element where the ghost will be appended. - * @param labelElem The SVGTextElement representing the qubit label to be cloned (including any tspans or formatting). - */ -const createQubitLabelGhost = ( - ev: MouseEvent, - container: HTMLElement, - labelElem: SVGTextElement, -) => { - const ghostGate: Operation = { - kind: "unitary", - gate: "?", // This will be replaced by the label elem - targets: [], - }; - const ghostRenderData = toRenderData(ghostGate, 0, 0); - const ghost = formatGate(ghostRenderData) as SVGElement; - - // Replace the placeholder text with the label element - const placeholderText = ghost.querySelector(".qs-maintext"); - if (placeholderText) { - // Remove all children from placeholderText - while (placeholderText.firstChild) { - placeholderText.removeChild(placeholderText.firstChild); - } - // Clone and append each child from labelElem - for (const child of Array.from(labelElem.childNodes)) { - placeholderText.appendChild(child.cloneNode(true)); - } - placeholderText.setAttribute( - "font-size", - labelElem.getAttribute("font-size") || "16", - ); - } - - _createGhostElement(container, ev, ghost, false); -}; - -/** - * Creates and appends a draggable "ghost" element to the DOM for visual feedback during drag operations. - * - * @param container The HTML container element to which the ghost element will be appended. - * @param ev The MouseEvent that triggered the drag, used to position the ghost. - * @param ghost The SVGElement representing the visual ghost to be dragged. - * @param isControl Boolean indicating if the ghost is for a control operation (affects sizing). - */ -const _createGhostElement = ( - container: HTMLElement, - ev: MouseEvent, - ghost: SVGElement, - isControl: boolean, -) => { - // Generate svg element to wrap around ghost element - const svgElem = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svgElem.append(ghost); - - // Generate div element to wrap around svg element - const divElem = document.createElement("div"); - divElem.classList.add("ghost"); - divElem.appendChild(svgElem); - divElem.style.position = "fixed"; - - if (container) { - container.appendChild(divElem); - - // Now that the element is appended to the DOM, get its dimensions - const [ghostWidth, ghostHeight] = isControl - ? [40, 40] - : (() => { - const ghostRect = ghost.getBoundingClientRect(); - return [ghostRect.width, ghostRect.height]; - })(); - - const updateDivLeftTop = (ev: MouseEvent) => { - divElem.style.left = `${ev.clientX - ghostWidth / 2}px`; - divElem.style.top = `${ev.clientY - ghostHeight / 2}px`; - }; - - updateDivLeftTop(ev); - - const cleanup = () => { - container.removeEventListener("mousemove", updateDivLeftTop); - document.removeEventListener("mouseup", cleanup); - if (divElem.parentNode) { - divElem.parentNode.removeChild(divElem); - } - }; - - container.addEventListener("mousemove", updateDivLeftTop); - document.addEventListener("mouseup", cleanup); - } else { - console.error("container not found"); - } -}; - -/** - * Create a dropzone element that spans the length of the wire. - * - * @param circuitSvg The SVG element representing the circuit. - * @param wireData An array of y values corresponding to the circuit wires. - * @param wireIndex The index of the wire or the "between" position. - * @param isBetween If true, creates a dropzone between wires. - * @returns The created dropzone SVG element. - */ -const createWireDropzone = ( - circuitSvg: SVGElement, - wireData: number[], - wireIndex: number, - isBetween: boolean = false, -): SVGElement => { - const svgWidth = Number(circuitSvg.getAttribute("width")); - const paddingY = 20; - let wireY: number; - - if (isBetween) { - // Dropzone BETWEEN wires (including before first and after last) - if (wireIndex === wireData.length) { - wireY = wireData[wireData.length - 1] + registerHeight / 2; - } else { - wireY = wireData[wireIndex] - registerHeight / 2; - } - } else { - // Dropzone ON the wire - wireY = wireData[wireIndex]; - } - - const dropzone = box( - 0, - wireY - paddingY, - svgWidth, - paddingY * 2, - "dropzone-full-wire", - ); - dropzone.setAttribute("data-dropzone-wire", `${wireIndex}`); - - return dropzone; -}; - -/** - * Remove all wire dropzones. - * - * @param circuitSvg The SVG element representing the circuit. - */ -const removeAllWireDropzones = (circuitSvg: SVGElement) => { - const dropzones = circuitSvg.querySelectorAll(".dropzone-full-wire"); - dropzones.forEach((elem) => { - elem.parentNode?.removeChild(elem); - }); -}; - -/** - * Add data-wire to all host elements - */ -const _addDataWires = (container: HTMLElement) => { - const elems = getHostElems(container); - elems.forEach((elem) => { - const wireYs = _wireYs(elem); - // i.e. wireYs = [40], wireData returns [40, 100, 140, 180] - // dataWire will return 0, which is the index of 40 in wireData - const dataWire = getWireData(container).findIndex((y) => - wireYs.includes(y), - ); - if (dataWire !== -1) { - elem.setAttribute("data-wire", `${dataWire}`); - } - }); -}; - -/** - * Create a list of wires that element is spanning on - * i.e. Gate 'Foo' spans on wire 0 (y=40), 1 (y=100), and 2 (y=140) - * Function returns [40, 100, 140] - */ -const _wireYs = (elem: SVGGraphicsElement): number[] => { - const wireYsAttr = elem.getAttribute("data-wire-ys"); - if (wireYsAttr) { - try { - const wireYs = JSON.parse(wireYsAttr); - if (Array.isArray(wireYs) && wireYs.every((y) => typeof y === "number")) { - return wireYs; - } - } catch { - console.warn(`Invalid data-wire-ys attribute: ${wireYsAttr}`); - } - } - return []; -}; - -/** - * Add custom styles specific to this module - */ -const _addStyles = (container: HTMLElement): void => { - const elems = getHostElems(container); - elems.forEach((elem) => { - if (_wireYs(elem).length < 2) elem.style.cursor = "grab"; - }); - - const toolBoxElems = getToolboxElems(container); - toolBoxElems.forEach((elem) => { - elem.style.cursor = "grab"; - }); -}; - -/** - * Create layer with ghost qubit wire and label - */ -const _ghostQubitLayer = (context: Context) => { - const { container, svg } = context; - - const wireData = getWireData(container); - - const svgHeight = Number(svg.getAttribute("height") || svg.clientHeight || 0); - const svgWidth = Number(svg.getAttribute("width") || svg.clientWidth || 800); - const ghostY = svgHeight; - - const ghostLayer = document.createElementNS( - "http://www.w3.org/2000/svg", - "g", - ); - ghostLayer.classList.add("ghost-qubit-layer"); - ghostLayer.style.display = "none"; - // Insert before dropzone-layer if possible, otherwise at end - const dzLayer = svg.querySelector("g.dropzone-layer"); - if (dzLayer) { - svg.insertBefore(ghostLayer, dzLayer); - } else { - svg.appendChild(ghostLayer); - } - - const ghostWire = line( - regLineStart, - ghostY, - svgWidth, - ghostY, - "qubit-wire ghost-opacity", - ); - - const ghostLabel = qubitInput( - ghostY, - wireData.length, - wireData.length.toString(), - ); - ghostLabel.classList.add("ghost-opacity"); - ghostLayer.appendChild(ghostWire); - ghostLayer.appendChild(ghostLabel); - - context.svg.setAttribute("height", (svgHeight + registerHeight).toString()); - svg.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight + registerHeight}`); - - return ghostLayer; -}; - -/** - * Create dropzone layer with all dropzones populated - */ -const _dropzoneLayer = (context: Context) => { - const dropzoneLayer = document.createElementNS( - "http://www.w3.org/2000/svg", - "g", - ); - dropzoneLayer.classList.add("dropzone-layer"); - dropzoneLayer.style.display = "none"; - - const { container, operationGrid } = context; - - const colArray = getColumnOffsetsAndWidths(container); - const wireData = getWireData(container); - - // Create dropzones for each intersection of columns and wires - for (let colIndex = 0; colIndex < colArray.length; colIndex++) { - const columnOps = operationGrid[colIndex]; - let wireIndex = 0; - - const makeBox = (opIndex: number, interColumn: boolean) => - makeDropzoneBox( - colIndex, - opIndex, - colArray, - wireData, - wireIndex, - interColumn, - ); - - columnOps.components.forEach((op, opIndex) => { - const [minTarget, maxTarget] = getMinMaxRegIdx(op); - // Add dropzones before the first target - while (wireIndex <= maxTarget) { - dropzoneLayer.appendChild(makeBox(opIndex, true)); - // We don't want to make a central zone if the spot is occupied by a gate or its connecting lines - if (wireIndex < minTarget) { - dropzoneLayer.appendChild(makeBox(opIndex, false)); - } - - wireIndex++; - } - }); - - // Add dropzones after the last target - while (wireIndex < wireData.length) { - dropzoneLayer.appendChild(makeBox(columnOps.components.length, true)); - dropzoneLayer.appendChild(makeBox(columnOps.components.length, false)); - - wireIndex++; - } - } - - // This assumes column indexes are continuous - const endColIndex = colArray.length; - - // Add remaining dropzones to allow users to add gates to the end of the circuit - for (let wireIndex = 0; wireIndex < wireData.length; wireIndex++) { - const dropzone = makeDropzoneBox( - endColIndex, - 0, - colArray, - wireData, - wireIndex, - true, - ); - // Note: the last column should have the shape of an inter-column dropzone, but - // we don't want to attach the inter-column logic to it. - dropzone.setAttribute("data-dropzone-inter-column", "false"); - dropzoneLayer.appendChild(dropzone); - } - - return dropzoneLayer; -}; - -/** - * Computes a sorted array of { xOffset, colWidth } for each column index. - * The array index corresponds to the column index. - * - * @param container The circuit container element. - * @returns Array where arr[colIndex] = { xOffset, colWidth } - */ -const getColumnOffsetsAndWidths = ( - container: HTMLElement, -): { xOffset: number; colWidth: number }[] => { - const elems = getHostElems(container); - - if (elems.length === 0) { - return []; - } - - // Compute column widths - const colWidths = elems.reduce( - (acc, elem) => { - const widthAttr = elem.getAttribute("data-width"); - if (!widthAttr) return acc; - const elemWidth: number = Number(widthAttr); - if (isNaN(elemWidth) || elemWidth <= 0) return acc; - - const location = findLocation(elem); - if (!location) return acc; - const indexes = locationStringToIndexes(location); - if (indexes.length != 1) return acc; - const [colIndex] = indexes[0]; - if (!acc[colIndex]) { - acc[colIndex] = Math.max(minGateWidth, elemWidth); - } else { - acc[colIndex] = Math.max(acc[colIndex], elemWidth); - } - return acc; - }, - {} as Record, - ); - - // Find the max colIndex to size the array - const maxColIndex = Math.max(...Object.keys(colWidths).map(Number), 0); - - let xOffset = startX - gatePadding; - const result: { xOffset: number; colWidth: number }[] = []; - for (let colIndex = 0; colIndex <= maxColIndex; colIndex++) { - const colWidth = colWidths[colIndex] ?? minGateWidth; - result[colIndex] = { xOffset, colWidth }; - xOffset += colWidth + gatePadding * 2; - } - return result; -}; - -/** - * Create a dropzone box element. - * - * @param colIndex The index of the column where the dropzone is located. - * @param opIndex The index of the operation within the column. - * @param colArray An array of objects containing xOffset and colWidth for each column. - * @param wireData The array of wire Y positions. - * @param wireIndex The index of the wire for which the dropzone is created. - * @param interColumn Whether the dropzone is between columns. - * - * @returns The created dropzone SVG element. - */ -const makeDropzoneBox = ( - colIndex: number, - opIndex: number, - colArray: { xOffset: number; colWidth: number }[], - wireData: number[], - wireIndex: number, - interColumn: boolean, -): SVGElement => { - const wireY = wireData[wireIndex]; - let xOffset: number, colWidth: number; - - if (colArray[colIndex]) { - ({ xOffset, colWidth } = colArray[colIndex]); - } else { - // Compute offset for a hypothetical new last column - const last = colArray[colArray.length - 1]; - if (last) { - xOffset = last.xOffset + last.colWidth + gatePadding * 2; - } else { - // If there are no columns at all, start at initial offset - xOffset = startX - gatePadding; - } - colWidth = minGateWidth; - } - - const paddingY = 20; - let dropzone; - if (interColumn) { - dropzone = box( - xOffset - gatePadding * 2, - wireY - paddingY, - gatePadding * 4, - paddingY * 2, - "dropzone", - ); - } else { - dropzone = box( - xOffset + gatePadding, - wireY - paddingY, - colWidth, - paddingY * 2, - "dropzone", - ); - } - dropzone.setAttribute("data-dropzone-location", `${colIndex},${opIndex}`); - dropzone.setAttribute("data-dropzone-wire", `${wireIndex}`); - dropzone.setAttribute("data-dropzone-inter-column", `${interColumn}`); - return dropzone; -}; - -export { - createDropzones, - createGateGhost, - createQubitLabelGhost, - createWireDropzone, - removeAllWireDropzones, - getColumnOffsetsAndWidths, - makeDropzoneBox, -}; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/contextMenu.ts b/source/npm/qsharp/ux/circuit-vis/editor/contextMenu.ts new file mode 100644 index 00000000000..db9b3f3375c --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/contextMenu.ts @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + _isMultiTargetOrGroup, + removeControl, +} from "../actions/circuitActions.js"; +import { + deleteOperationWithConfirmation, + promptForArguments, +} from "./prompts.js"; +import { CircuitEvents } from "./events.js"; +import { findGateElem } from "./domUtils.js"; +import { findOperation } from "../utils.js"; + +/** + * Adds a context menu to a host element in the circuit visualization. + * + * @param circuitEvents The CircuitEvents instance to handle circuit-related events. + * @param hostElem The SVG element representing a gate component to which the context menu will be added. + */ +const addContextMenuToHostElem = ( + circuitEvents: CircuitEvents, + hostElem: SVGGraphicsElement, +) => { + hostElem?.addEventListener("contextmenu", (ev: MouseEvent) => { + ev.preventDefault(); + + // Remove any existing context menu + const existingContextMenu = document.querySelector(".context-menu"); + if (existingContextMenu) { + document.body.removeChild(existingContextMenu); + } + + const gateElem = findGateElem(hostElem); + if (!gateElem) return; + const selectedLocation = gateElem.getAttribute("data-location"); + const selectedOperation = findOperation( + circuitEvents.componentGrid, + selectedLocation, + ); + if (!selectedOperation || !selectedLocation) return; + + const contextMenu = document.createElement("div"); + contextMenu.classList.add("context-menu"); + contextMenu.style.top = `${ev.clientY + window.scrollY}px`; + contextMenu.style.left = `${ev.clientX + window.scrollX}px`; + contextMenu.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + contextMenu.addEventListener("mouseup", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + const dataWireStr = hostElem.getAttribute("data-wire"); + const dataWire = dataWireStr != null ? parseInt(dataWireStr) : null; + const isControl = + hostElem.classList.contains("control-dot") && dataWire != null; + + const deleteOption = _createContextMenuItem("Delete", () => { + // Route through the prompt-aware wrapper so deleting a measurement with downstream consumers + // confirms first. + deleteOperationWithConfirmation( + circuitEvents.model, + selectedLocation, + circuitEvents.renderFn, + ); + }); + + if ( + selectedOperation.kind === "measurement" || + selectedOperation.kind === "ket" + ) { + contextMenu.appendChild(deleteOption); + } else if (isControl) { + // Hide "Remove control" when the parent op is multi-target / a group, mirroring the + // action-layer gating in `_isMultiTargetOrGroup`. Existing controls can still be moved via + // control-drag. + if (!_isMultiTargetOrGroup(selectedOperation)) { + const removeControlOption = _createContextMenuItem( + "Remove control", + () => { + removeControl(circuitEvents.model, selectedOperation, dataWire); + circuitEvents.renderFn(); + }, + ); + contextMenu.appendChild(removeControlOption!); + } + } else { + const adjointOption = _createContextMenuItem("Toggle Adjoint", () => { + if (selectedOperation.kind !== "unitary") return; + selectedOperation.isAdjoint = !selectedOperation.isAdjoint; + circuitEvents.renderFn(); + }); + + // Multi-target unitaries and groups don't get Add / Remove Control: groups carry no quantum + // controls and multi-target bodies have no canonical control attachment point. Mirrors + // `_isMultiTargetOrGroup` at the action layer. + const allowControlAuthoring = !_isMultiTargetOrGroup(selectedOperation); + + // Groups (any op with `children`) don't get "Toggle Adjoint": adjointing a group would have + // to propagate the marker through the subtree, and groups with a measurement or Reset aren't + // adjointable at all. + const allowAdjoint = selectedOperation.children == null; + + const addControlOption = _createContextMenuItem("Add Control", () => { + if (selectedOperation.kind !== "unitary") return; + circuitEvents._startAddingControl(selectedOperation); + }); + + let removeControlOption: HTMLDivElement | undefined; + if ( + allowControlAuthoring && + selectedOperation.controls && + selectedOperation.controls.length > 0 + ) { + removeControlOption = _createContextMenuItem("Remove Control", () => { + circuitEvents._startRemovingControl(selectedOperation); + }); + contextMenu.appendChild(removeControlOption); + } + + const promptArgOption = _createContextMenuItem("Edit Argument", () => { + promptForArguments( + selectedOperation.params!, + selectedOperation.args, + ).then((args) => { + if (args.length > 0) { + selectedOperation.args = args; + } else { + selectedOperation.args = undefined; + } + circuitEvents.renderFn(); + }); + }); + + if (selectedOperation.gate == "X") { + if (allowControlAuthoring) { + contextMenu.appendChild(addControlOption); + } + if (removeControlOption) { + contextMenu.appendChild(removeControlOption); + } + contextMenu.appendChild(deleteOption); + } else { + if (allowAdjoint) { + contextMenu.appendChild(adjointOption); + } + if (allowControlAuthoring) { + contextMenu.appendChild(addControlOption); + } + if (removeControlOption) { + contextMenu.appendChild(removeControlOption); + } + if ( + selectedOperation.params !== undefined && + selectedOperation.params.length > 0 + ) { + contextMenu.appendChild(promptArgOption); + } + contextMenu.appendChild(deleteOption); + } + } + + document.body.appendChild(contextMenu); + + document.addEventListener( + "click", + () => { + if (document.body.contains(contextMenu)) { + document.body.removeChild(contextMenu); + } + }, + { once: true }, + ); + }); +}; + +/** + * Create a context menu item + * @param text - The text to display in the menu item + * @param onClick - The function to call when the menu item is clicked + * @returns The created menu item element + */ +const _createContextMenuItem = ( + text: string, + onClick: () => void, +): HTMLDivElement => { + const menuItem = document.createElement("div"); + menuItem.classList.add("context-menu-option"); + menuItem.textContent = text; + menuItem.addEventListener("click", onClick); + return menuItem; +}; + +export { addContextMenuToHostElem }; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts b/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts new file mode 100644 index 00000000000..20aa7dc0ebc --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/controllers/dragController.ts @@ -0,0 +1,591 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ComponentGrid, Unitary } from "../../data/circuit.js"; +import { + addControl, + addOperation, + collectExternalProducerLocations, + moveOperation, + removeControl, +} from "../../actions/circuitActions.js"; +import { + deleteOperationWithConfirmation, + moveOperationWithConfirmation, +} from "../prompts.js"; +import { + createGateGhost, + createWireDropzone, + removeAllWireDropzones, +} from "../draggable.js"; +import { + beginToolboxDrag, + resetTransient, +} from "../../actions/interactionActions.js"; +import { InteractionContext } from "./interactionContext.js"; +import { Location } from "../../data/location.js"; +import { promptForArguments } from "../prompts.js"; +import { QubitController } from "./qubitController.js"; +import { enableAutoScroll } from "./scrollController.js"; +import { toolboxGateDictionary } from "../toolboxGates.js"; +import { getGateElems, getToolboxElems } from "../domUtils.js"; +import { + deepEqual, + findOperation, + getGateLocationString, +} from "../../utils.js"; + +/** + * `DragController` — owns the gate drag-and-drop surface: gate-drag, toolbox-drag, dropzone commit, + * document-level cleanup/cancel, ghost element creation, and the wire-pick dropzones for the + * add-control / remove-control flow the context menu invokes. + * + * These flows share one dropzone overlay, one ghost element, the same `interaction` flags, and the + * same document-level mouseup that classifies a drag as commit, cancel, or drag-out-delete — so + * they live in a single controller. + * + * Holds a `QubitController` reference for the one document-mouseup path that detects a qubit-label + * drag-off and calls `removeQubitLineWithConfirmation`. + */ +export class DragController { + constructor( + private readonly ctx: InteractionContext, + private readonly qubitController: QubitController, + ) { + this.installLayerListeners(); + this.installGateListeners(); + this.installToolboxListeners(); + this.installDropzoneListeners(); + this.installDocumentListeners(); + } + + dispose(): void { + this.uninstallToolboxListeners(); + this.uninstallDocumentListeners(); + } + + /** + * Begin the wire-pick flow that lets the user click a wire to add a control to + * `selectedOperation`. Called from the context menu. + */ + startAddingControl(selectedOperation: Unitary) { + this.ctx.interaction.selectedOperation = selectedOperation; + this.ctx.container.classList.add("adding-control"); + this.ctx.ghostQubitLayer.style.display = "block"; + + for (let wireIndex = 0; wireIndex < this.ctx.wireData.length; wireIndex++) { + // Only pure-quantum target/control entries (`result === undefined`) disqualify a wire. A + // classical-ref entry `{qubit, result}` on the M-owning wire doesn't make it a quantum target + // or control, so a quantum control can still be added there. + const isTarget = this.ctx.interaction.selectedOperation?.targets.some( + (target) => target.qubit === wireIndex && target.result === undefined, + ); + const isControl = this.ctx.interaction.selectedOperation?.controls?.some( + (control) => + control.qubit === wireIndex && control.result === undefined, + ); + if (isTarget || isControl) continue; + + const dropzone = createWireDropzone( + this.ctx.circuitSvg, + this.ctx.wireData, + wireIndex, + ); + dropzone.addEventListener("mousedown", (ev: MouseEvent) => + ev.stopPropagation(), + ); + dropzone.addEventListener("click", () => + this.commitAddControl(wireIndex), + ); + this.ctx.overlayLayer.appendChild(dropzone); + } + } + + /** + * Begin the wire-pick flow that lets the user click a control dot to remove it. Called from the + * context menu. + */ + startRemovingControl(selectedOperation: Unitary) { + this.ctx.interaction.selectedOperation = selectedOperation; + this.ctx.container.classList.add("removing-control"); + + this.ctx.interaction.selectedOperation.controls?.forEach((control) => { + // Skip classical-ref controls: a `{qubit, result}` control is the group's classical + // dependency on a producing M, with no quantum control-dot to click. + if (control.result !== undefined) return; + const dropzone = createWireDropzone( + this.ctx.circuitSvg, + this.ctx.wireData, + control.qubit, + ); + dropzone.addEventListener("mousedown", (ev: MouseEvent) => + ev.stopPropagation(), + ); + dropzone.addEventListener("click", () => { + if ( + this.ctx.interaction.selectedOperation == null || + this.ctx.interaction.selectedOperation.kind !== "unitary" + ) + return; + const successful = removeControl( + this.ctx.model, + this.ctx.interaction.selectedOperation, + control.qubit, + ); + this.ctx.interaction.selectedOperation = null; + this.ctx.container.classList.remove("removing-control"); + if (successful) this.ctx.renderFn(); + }); + this.ctx.overlayLayer.appendChild(dropzone); + }); + } + + /****************************** + * Listener installation * + * *****************************/ + + private installLayerListeners(): void { + // Container mouseup hides editor overlay layers (dropzones, ghost-qubit). Done at this level, + // not on circuitSvg, because the user might release the mouse over the toolbox or chrome. + this.ctx.container.addEventListener("mouseup", () => { + if (this.ctx.model.qubits.length !== 0) { + this.ctx.ghostQubitLayer.style.display = "none"; + } + this.ctx.dropzoneLayer.style.display = "none"; + // Reset per-dropzone visibility marks left by `hideInvalidDropzones`, so a drag that doesn't + // re-render (canceled, or a no-op drop) doesn't leave the next drag with stale `display: + // none` marks. + this.showAllDropzones(); + }); + + // Track whether the most recent mouseup landed on the circuit surface itself; consumed by the + // document mouseup to decide drag-out-delete vs commit. + this.ctx.circuitSvg.addEventListener("mouseup", () => { + this.ctx.interaction.mouseUpOnCircuit = true; + }); + + // Suppress native context menu inside the editor. + this.ctx.container.addEventListener("contextmenu", (ev: MouseEvent) => { + ev.preventDefault(); + }); + } + + private installGateListeners(): void { + const elems = getGateElems(this.ctx.container); + elems.forEach((elem) => { + elem?.addEventListener("mousedown", (ev: MouseEvent) => + this.onGateMouseDown(ev, elem), + ); + + // Arg-button: in-place argument editing for parameterized gates. + const argButtons = elem.querySelectorAll(".arg-button"); + argButtons.forEach((argButton) => { + argButton.classList.add("edit-mode"); + argButton.addEventListener("click", () => + this.onArgButtonClick(argButton), + ); + }); + }); + } + + private installToolboxListeners(): void { + const elems = getToolboxElems(this.ctx.container); + elems.forEach((elem) => { + elem.addEventListener("mousedown", this.onToolboxMouseDown); + }); + } + + private uninstallToolboxListeners(): void { + const elems = getToolboxElems(this.ctx.container); + elems.forEach((elem) => { + elem.removeEventListener("mousedown", this.onToolboxMouseDown); + }); + } + + private installDropzoneListeners(): void { + const dropzoneElems = + this.ctx.dropzoneLayer.querySelectorAll(".dropzone"); + dropzoneElems.forEach((dropzoneElem) => { + dropzoneElem.addEventListener("mouseup", this.onDropzoneMouseUp); + }); + } + + private installDocumentListeners(): void { + document.addEventListener("mouseup", this.onDocumentMouseUp); + document.addEventListener("mousedown", this.onDocumentMouseDown); + } + + private uninstallDocumentListeners(): void { + document.removeEventListener("mouseup", this.onDocumentMouseUp); + document.removeEventListener("mousedown", this.onDocumentMouseDown); + } + + /****************************** + * Handlers * + * *****************************/ + + private onGateMouseDown = (ev: MouseEvent, elem: SVGGraphicsElement) => { + // Allow dragging even when initiated on the arg-button — capture the wire from the sibling host + // element so the drag knows which qubit is the "from" wire. + const argButtonElem = (ev.target as HTMLElement).closest(".arg-button"); + if (argButtonElem) { + const siblingWithWire = + argButtonElem.parentElement?.querySelector("[data-wire]"); + if (siblingWithWire) { + const selectedWireStr = siblingWithWire.getAttribute("data-wire"); + this.ctx.interaction.selectedWire = + selectedWireStr != null ? parseInt(selectedWireStr) : null; + } + } + + let selectedLocation = null; + if ( + elem.getAttribute("data-expanded") !== "true" || + this.ctx.interaction.movingControl + ) { + // Looked up via `findOperation` against the model so subsequent edits operate on the live op, + // not a stale snapshot. + // + // The `movingControl` carve-out covers grabbing a control dot on an expanded group: those + // dots are direct children of the group's `data-expanded="true"` node (child gate elems + // stopPropagation first), so without this branch the early-return below would leave + // `selectedOperation` null and the drag would never start. + selectedLocation = elem.getAttribute("data-location"); + this.ctx.interaction.selectedOperation = findOperation( + this.ctx.model.componentGrid, + selectedLocation, + ); + } + if (ev.button !== 0) return; + ev.stopPropagation(); + removeAllWireDropzones(this.ctx.circuitSvg); + if ( + this.ctx.interaction.selectedOperation === null || + this.ctx.interaction.selectedWire === null || + !selectedLocation + ) + return; + + this.spawnGhost(ev); + + // Make sure the selectedOperation has location data — downstream drop logic reads it via + // getGateLocationString(). + if (this.ctx.interaction.selectedOperation.dataAttributes == null) { + this.ctx.interaction.selectedOperation.dataAttributes = { + location: selectedLocation, + }; + } else { + this.ctx.interaction.selectedOperation.dataAttributes["location"] = + selectedLocation; + } + + // Hide dropzones whose drop would invert producer-before-consumer ordering for any classical + // register the selected op consumes from outside its own subtree. See `hideInvalidDropzones`. + this.hideInvalidDropzones(selectedLocation); + + this.ctx.container.classList.add("moving"); + this.ctx.ghostQubitLayer.style.display = "block"; + this.ctx.dropzoneLayer.style.display = "block"; + }; + + private onArgButtonClick = async (argButton: SVGElement) => { + if (this.ctx.interaction.selectedOperation == null) return; + const params = this.ctx.interaction.selectedOperation.params; + const displayArgs = argButton.textContent || ""; + if (params) { + const args = await promptForArguments(params, [displayArgs]); + if (args.length > 0) { + this.ctx.interaction.selectedOperation.args = args; + this.ctx.renderFn(); + } + } + }; + + private onToolboxMouseDown = (ev: MouseEvent) => { + if (ev.button !== 0) return; + this.ctx.container.classList.add("moving"); + this.ctx.ghostQubitLayer.style.display = "block"; + this.ctx.dropzoneLayer.style.display = "block"; + const elem = ev.currentTarget as HTMLElement; + const type = elem.getAttribute("data-type"); + if (type == null) return; + beginToolboxDrag(this.ctx.interaction, toolboxGateDictionary[type]); + this.spawnGhost(ev); + }; + + private onDropzoneMouseUp = async (ev: MouseEvent) => { + const dropzoneElem = ev.currentTarget as SVGRectElement; + const copying = ev.ctrlKey; + // Snapshot for the no-op deepEqual short-circuit at the end. + const originalGrid = JSON.parse( + JSON.stringify(this.ctx.model.componentGrid), + ) as ComponentGrid; + // Set when a code path delegates rendering to a prompt-aware wrapper + // (`moveOperationWithConfirmation`), which owns its own renderFn call; the trailing deepEqual + // block then skips its own to avoid double-rendering. + let mutationHandledByWrapper = false; + const targetLoc = dropzoneElem.getAttribute("data-dropzone-location"); + const insertNewColumn = + dropzoneElem.getAttribute("data-dropzone-inter-column") == "true" || + false; + const targetWireStr = dropzoneElem.getAttribute("data-dropzone-wire"); + const targetWire = targetWireStr != null ? parseInt(targetWireStr) : null; + + if ( + targetLoc == null || + targetWire == null || + this.ctx.interaction.selectedOperation == null + ) + return; + const sourceLocation = getGateLocationString( + this.ctx.interaction.selectedOperation, + ); + + // Shift-extend dropzones offer drop targets on wires outside the destination group's current + // span. The action layer treats the target location string as authoritative (it re-derives + // ancestor `.targets` from post-move children), so no special routing is needed here. + + if (sourceLocation == null) { + // Source has no location → it's a fresh drop from the toolbox. Prompt for any required args + // before committing. + if ( + this.ctx.interaction.selectedOperation.params != undefined && + (this.ctx.interaction.selectedOperation.args === undefined || + this.ctx.interaction.selectedOperation.args.length === 0) + ) { + const args = await promptForArguments( + this.ctx.interaction.selectedOperation.params, + ); + if (!args || args.length === 0) { + return; + } + // Deep-copy the toolbox prototype before mutating it. + this.ctx.interaction.selectedOperation = JSON.parse( + JSON.stringify(this.ctx.interaction.selectedOperation), + ); + if (this.ctx.interaction.selectedOperation == null) return; + this.ctx.interaction.selectedOperation.args = args; + } + + addOperation( + this.ctx.model, + this.ctx.interaction.selectedOperation, + targetLoc, + targetWire, + insertNewColumn, + ); + } else if (sourceLocation && this.ctx.interaction.selectedWire != null) { + if (copying) { + if ( + this.ctx.interaction.movingControl && + this.ctx.interaction.selectedOperation.kind === "unitary" + ) { + addControl( + this.ctx.model, + this.ctx.interaction.selectedOperation, + targetWire, + ); + moveOperation( + this.ctx.model, + sourceLocation, + targetLoc, + this.ctx.interaction.selectedWire, + targetWire, + this.ctx.interaction.movingControl, + insertNewColumn, + ); + } else { + addOperation( + this.ctx.model, + this.ctx.interaction.selectedOperation, + targetLoc, + targetWire, + insertNewColumn, + ); + } + } else { + // Regular move path. Routes through the prompt-aware wrapper so moving a measurement with + // downstream classical consumers surfaces a confirmation dialog. The wrapper owns the + // renderFn call on both branches, so skip the trailing deepEqual block via + // `mutationHandledByWrapper`. + moveOperationWithConfirmation( + this.ctx.model, + sourceLocation, + targetLoc, + this.ctx.interaction.selectedWire, + targetWire, + this.ctx.interaction.movingControl, + insertNewColumn, + this.ctx.renderFn, + ); + mutationHandledByWrapper = true; + } + } + + this.ctx.interaction.selectedOperation = null; + resetTransient(this.ctx.interaction); + + if ( + !mutationHandledByWrapper && + !deepEqual(originalGrid, this.ctx.model.componentGrid) + ) { + this.ctx.renderFn(); + } + }; + + private onDocumentMouseDown = () => { + removeAllWireDropzones(this.ctx.circuitSvg); + }; + + private onDocumentMouseUp = (ev: MouseEvent) => { + const copying = ev.ctrlKey; + this.ctx.container.classList.remove("moving", "copying"); + // Drag-out-delete: a drag that ended outside the circuit (and wasn't a Ctrl-copy) deletes the + // source. + if ( + !this.ctx.interaction.mouseUpOnCircuit && + this.ctx.interaction.dragging && + !copying + ) { + const selectedLocation = this.ctx.interaction.selectedOperation + ? getGateLocationString(this.ctx.interaction.selectedOperation) + : null; + if ( + this.ctx.interaction.selectedOperation != null && + selectedLocation != null + ) { + // A placed gate (not from the toolbox) was dragged off-circuit. + if ( + this.ctx.interaction.movingControl && + this.ctx.interaction.selectedOperation.kind === "unitary" && + this.ctx.interaction.selectedOperation.controls != null && + this.ctx.interaction.selectedWire != null + ) { + // Detached just the control we were dragging. + removeControl( + this.ctx.model, + this.ctx.interaction.selectedOperation, + this.ctx.interaction.selectedWire, + ); + this.ctx.renderFn(); + } else { + // Drag-out-delete. Routes through the prompt-aware wrapper so deleting a measurement with + // downstream classical consumers confirms first; the wrapper owns renderFn on both + // branches. + deleteOperationWithConfirmation( + this.ctx.model, + selectedLocation, + this.ctx.renderFn, + ); + } + } else if (this.ctx.interaction.selectedWire != null) { + // A qubit label was dragged off-circuit → ask the qubit controller (which owns the prompt + + // render flow). + this.qubitController.removeQubitLineWithConfirmation( + this.ctx.interaction.selectedWire, + ); + } + } + + resetTransient(this.ctx.interaction); + }; + + /** + * Bind the ghost element + auto-scroll to a fresh drag. Shared by gate-mousedown and + * toolbox-mousedown; the qubit controller has its own ghost path (`createQubitLabelGhost`). + */ + private spawnGhost(ev: MouseEvent): void { + if (this.ctx.interaction.selectedOperation == null) return; + this.ctx.interaction.dragging = true; + enableAutoScroll(this.ctx.circuitSvg, this.ctx.interaction); + createGateGhost( + ev, + this.ctx.container, + this.ctx.interaction.selectedOperation, + this.ctx.interaction.movingControl, + ); + } + + /** + * Hide every dropzone that would, if used as the drop target for the currently-dragged op, invert + * the "producer measurement comes before its classical consumer" ordering. Invalid dropzones get + * `display: none` so they neither paint nor catch mouseup. + * + * A classically-conditional unitary carries `(qubit, result)` references to a producing M; + * dropping it before that M points at a classical register that doesn't exist yet at the + * consumer's position, which crashes the renderer or yields a broken circuit. + * + * Producers internal to the dragged subtree don't constrain the drop — they travel with the + * consumer. See [`collectExternalProducerLocations`](../../actions/circuitActions.ts). + * + * Pairs with the `moveOperation` safety-net refusal: this filter is the user-facing surface; the + * action-layer refusal catches drops that slip through. + */ + private hideInvalidDropzones(selectedLocation: string): void { + // Reset every dropzone to visible first so stale marks from a previous drag don't bleed into + // this one. (Belt-and-suspenders with the layer-mouseup reset in `installLayerListeners`.) + this.showAllDropzones(); + + const externalProducerLocs = collectExternalProducerLocations( + this.ctx.model.componentGrid, + selectedLocation, + ); + if (externalProducerLocs.length === 0) return; + + const producerLocs = externalProducerLocs.map((s) => Location.parse(s)); + + const dropzones = + this.ctx.dropzoneLayer.querySelectorAll(".dropzone"); + dropzones.forEach((dz) => { + const targetLocStr = dz.getAttribute("data-dropzone-location"); + if (targetLocStr == null) return; + const targetLoc = Location.parse(targetLocStr); + // Hide if any external producer is NOT in a strictly earlier column than this drop target. + // Column-strict (not plain document order) also catches a consumer promoted to a higher level + // that lands in the same outer column as its producer. + for (const pLoc of producerLocs) { + if (!pLoc.inEarlierColumnThan(targetLoc)) { + dz.style.display = "none"; + return; + } + } + }); + } + + /** + * Clear every per-dropzone `display` mark, restoring CSS-default visibility. Shared by + * `hideInvalidDropzones` and the layer-mouseup teardown so no drag inherits stale marks. + */ + private showAllDropzones(): void { + const dropzones = + this.ctx.dropzoneLayer.querySelectorAll(".dropzone"); + dropzones.forEach((dz) => { + dz.style.display = ""; + }); + } + + /** + * Final step of `startAddingControl`: add the control, tear down the add-control UI, and + * re-render. The action layer (`addControl` → `_resolveSpanChange`) owns the post-widening + * cascade — column splits, ancestor `.targets` refresh, sibling shifts — so this wrapper does not + * duplicate any of it. + */ + private commitAddControl(wireIndex: number): void { + if ( + this.ctx.interaction.selectedOperation == null || + this.ctx.interaction.selectedOperation.kind !== "unitary" + ) + return; + const successful = addControl( + this.ctx.model, + this.ctx.interaction.selectedOperation, + wireIndex, + ); + this.ctx.interaction.selectedOperation = null; + this.ctx.container.classList.remove("adding-control"); + this.ctx.ghostQubitLayer.style.display = "none"; + if (!successful) return; + + this.ctx.renderFn(); + } +} diff --git a/source/npm/qsharp/ux/circuit-vis/editor/controllers/interactionContext.ts b/source/npm/qsharp/ux/circuit-vis/editor/controllers/interactionContext.ts new file mode 100644 index 00000000000..2c539634fbc --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/controllers/interactionContext.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { CircuitModel } from "../../data/circuitModel.js"; +import { InteractionState } from "../../actions/interactionState.js"; +import { LayoutMap } from "../../renderer/layoutMap.js"; + +/** + * `InteractionContext` — shared dependencies passed to every editor controller. The single object + * is built once in `CircuitEvents`'s constructor and handed to each controller; controllers + * read/write the same `model` / `interaction` and dispatch to the same `renderFn` so they all + * observe a consistent view of the editor. + * + * Controllers are translation-only: pointer/keyboard event listeners that turn raw DOM events into + * `*Actions.*` calls. They hold no state of their own — everything mutable lives on `model` (Data + * layer) or `interaction` (ephemeral session state). + * + * Fields are mutable on purpose. `wireData` is grown/shrunk by qubit-line edits; `circuitSvg` etc. + * are re-resolved on each `enableEvents` re-run. The context object itself is meant to be built + * once per `CircuitEvents` instance, not per event. + */ +export interface InteractionContext { + /** The Data layer. Owns componentGrid, qubits, qubitUseCounts. */ + readonly model: CircuitModel; + /** Ephemeral session state — selection, drag flags, etc. */ + readonly interaction: InteractionState; + /** Geometry from the layout pass, indexed by hierarchical scope. */ + readonly layoutMap: LayoutMap; + /** Outer host element (the editor's container). */ + readonly container: HTMLElement; + /** The rendered `svg.qviz` root. */ + readonly circuitSvg: SVGElement; + /** + * The editor-only overlay group inside `svg.qviz`. Holds every editor-owned DOM node (dropzones, + * ghost qubit row, etc.). Controllers append overlay elements here instead of to `circuitSvg`, + * keeping the renderer-owned SVG children purely presentational. + */ + readonly overlayLayer: SVGGElement; + /** Editor-only overlay layer for inter-column / on-column dropzones. */ + readonly dropzoneLayer: SVGGElement; + /** Editor-only overlay layer for the ghost qubit wire. */ + readonly ghostQubitLayer: SVGGElement; + /** + * Wire Y positions in absolute svg coords. Mutable because qubit-line removals splice an entry + * out (see `QubitController`). + */ + wireData: number[]; + /** Triggers a renderer re-run; controllers call after model edits. */ + readonly renderFn: () => void; +} diff --git a/source/npm/qsharp/ux/circuit-vis/editor/controllers/keyboardController.ts b/source/npm/qsharp/ux/circuit-vis/editor/controllers/keyboardController.ts new file mode 100644 index 00000000000..9059daff384 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/controllers/keyboardController.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { getGateLocationString } from "../../utils.js"; +import { InteractionContext } from "./interactionContext.js"; + +/** + * `KeyboardController` — translates document-level keyboard events into editor-state changes. Today + * the only behavior is the Ctrl-toggle that swaps the `moving` / `copying` CSS classes on the + * container while a gate is selected, so the cursor and ghost preview reflect the current drop + * semantics. + * + * Owns its document `keydown` / `keyup` listeners; `dispose()` removes them. No state of its own — + * the only mutable signal it consults is whether `interaction.selectedOperation` has a location + * string. + */ +export class KeyboardController { + constructor(private readonly ctx: InteractionContext) { + document.addEventListener("keydown", this.onKeyDown); + document.addEventListener("keyup", this.onKeyUp); + } + + dispose(): void { + document.removeEventListener("keydown", this.onKeyDown); + document.removeEventListener("keyup", this.onKeyUp); + } + + /** + * Ctrl-down while a placed (non-toolbox) gate is selected switches the cursor/ghost into "copy" + * mode. Picks up the location off the selected op rather than tracking selection separately. + */ + readonly onKeyDown = (ev: KeyboardEvent) => { + if (!ev.ctrlKey) return; + if (!this.hasSelectedLocation()) return; + this.ctx.container.classList.remove("moving"); + this.ctx.container.classList.add("copying"); + }; + + /** Ctrl-up flips back to "move" mode. */ + readonly onKeyUp = (ev: KeyboardEvent) => { + if (!ev.ctrlKey) return; + if (!this.hasSelectedLocation()) return; + this.ctx.container.classList.remove("copying"); + this.ctx.container.classList.add("moving"); + }; + + private hasSelectedLocation(): boolean { + const op = this.ctx.interaction.selectedOperation; + if (op == null) return false; + return getGateLocationString(op) != null; + } +} diff --git a/source/npm/qsharp/ux/circuit-vis/editor/controllers/qubitController.ts b/source/npm/qsharp/ux/circuit-vis/editor/controllers/qubitController.ts new file mode 100644 index 00000000000..07e1ea601a0 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/controllers/qubitController.ts @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + moveQubit, + removeQubitWithDependents, +} from "../../actions/circuitActions.js"; +import { createQubitLabelGhost, createWireDropzone } from "../draggable.js"; +import { InteractionContext } from "./interactionContext.js"; +import { trackTemporaryDropzone } from "../../actions/interactionActions.js"; +import { createConfirmPrompt } from "../prompts.js"; +import { enableAutoScroll } from "./scrollController.js"; +import { getQubitLabelElems } from "../domUtils.js"; + +/** + * `QubitController` — owns qubit-line interactions. + * + * Two surfaces: + * + * 1. **Drag a qubit label.** Mousedown on a qubit-label spawns swap and insert-between dropzones + * along every other wire; mouseup on one dispatches `moveQubit` (Action layer) and re-renders. + * 2. **Remove a qubit line.** `removeQubitLineWithConfirmation` is invoked from two callers: (a) + * the context menu (via `CircuitEvents`'s thin delegate, kept for backward compat), and (b) the + * drag controller's document-mouseup handler when a qubit label is dragged off the circuit. + * + * No `dispose()` — the qubit-label elements live inside the SVG, which is replaced wholesale on + * each `enableEvents` re-run, so their listeners die with the element. + */ +export class QubitController { + constructor(private readonly ctx: InteractionContext) { + this.installLabelListeners(); + } + + /** + * Remove a qubit line, prompting first if it has any operations attached. Public because the drag + * controller's drag-out-delete path needs to invoke it from a different mouseup handler. + */ + removeQubitLineWithConfirmation(qubitIdx: number): void { + const numOperations = this.ctx.model.qubitUseCounts[qubitIdx]; + + const doRemove = () => { + removeQubitWithDependents(this.ctx.model, qubitIdx); + this.ctx.wireData.splice(qubitIdx, 1); + this.ctx.renderFn(); + }; + + if (numOperations === 0) { + doRemove(); + return; + } + + const message = + numOperations === 1 + ? `There is 1 operation associated with this qubit line. Do you want to remove it?` + : `There are ${numOperations} operations associated with this qubit line. Do you want to remove them?`; + createConfirmPrompt(message, (confirmed) => { + if (!confirmed) return; + doRemove(); + }); + } + + private installLabelListeners(): void { + const elems = getQubitLabelElems(this.ctx.container); + elems.forEach((elem) => { + elem.addEventListener("mousedown", (ev: MouseEvent) => + this.onLabelMouseDown(ev, elem), + ); + elem.style.pointerEvents = "all"; + }); + } + + private onLabelMouseDown(ev: MouseEvent, elem: SVGTextElement): void { + ev.stopPropagation(); + this.ctx.interaction.selectedOperation = null; + this.spawnGhost(ev, elem); + + const sourceIndexStr = elem.getAttribute("data-wire"); + const sourceWire = sourceIndexStr != null ? parseInt(sourceIndexStr) : null; + if (sourceWire == null) return; + this.ctx.interaction.selectedWire = sourceWire; + + // Dropzones ON each wire (skip self). Exclude the trailing ghost wire — it's a placeholder for + // adding new qubits, not a real swap target. + for ( + let targetWire = 0; + targetWire < this.ctx.wireData.length - 1; + targetWire++ + ) { + if (targetWire === sourceWire) continue; + const dropzone = createWireDropzone( + this.ctx.circuitSvg, + this.ctx.wireData, + targetWire, + ); + dropzone.addEventListener("mouseup", () => + this.commitMove(sourceWire, targetWire, false), + ); + trackTemporaryDropzone(this.ctx.interaction, dropzone); + this.ctx.overlayLayer.appendChild(dropzone); + } + + // Dropzones BETWEEN wires (including before-first and after-last, but not after the ghost + // wire). Skip the source's own bracket positions since "insert between" at them is a no-op. + for (let i = 0; i <= this.ctx.wireData.length - 1; i++) { + if (i === sourceWire || i === sourceWire + 1) continue; + const dropzone = createWireDropzone( + this.ctx.circuitSvg, + this.ctx.wireData, + i, + true, + ); + dropzone.addEventListener("mouseup", () => + this.commitMove(sourceWire, i, true), + ); + trackTemporaryDropzone(this.ctx.interaction, dropzone); + this.ctx.overlayLayer.appendChild(dropzone); + } + } + + private commitMove( + sourceWire: number, + targetWire: number, + isBetween: boolean, + ): void { + moveQubit(this.ctx.model, sourceWire, targetWire, isBetween); + this.ctx.renderFn(); + } + + private spawnGhost(ev: MouseEvent, elem: SVGTextElement): void { + this.ctx.interaction.dragging = true; + enableAutoScroll(this.ctx.circuitSvg, this.ctx.interaction); + createQubitLabelGhost(ev, this.ctx.container, elem); + } +} diff --git a/source/npm/qsharp/ux/circuit-vis/editor/controllers/scrollController.ts b/source/npm/qsharp/ux/circuit-vis/editor/controllers/scrollController.ts new file mode 100644 index 00000000000..71c30ad1b71 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/controllers/scrollController.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { InteractionState } from "../../actions/interactionState.js"; + +/** + * `enableAutoScroll` — install document-level mousemove listener that scrolls the nearest + * scrollable ancestor of `circuitSvg` whenever the cursor approaches an edge. Self-removes on the + * next `mouseup`. + * + * Invoked at drag start by both the gate-drag flow and the qubit-label drag flow, hence the + * standalone shape. + * + * @param circuitSvg The rendered `svg.qviz` root; used as the starting point for the + * scrollable-ancestor search. + * @param interaction Session state. Reads/writes `disableLeftAutoScroll` so that the drag flow can + * opt out of left-edge auto-scroll once the user has moved the cursor far enough right. + */ +export const enableAutoScroll = ( + circuitSvg: SVGElement, + interaction: InteractionState, +): void => { + const scrollSpeed = 10; // Pixels per frame + const edgeThreshold = 50; // Distance from the edge to trigger scrolling + + const getScrollableAncestor = (element: Element): HTMLElement => { + let currentElement: Element | null = element; + while (currentElement) { + const overflowY = window.getComputedStyle(currentElement).overflowY; + const overflowX = window.getComputedStyle(currentElement).overflowX; + if ( + overflowY === "auto" || + overflowY === "scroll" || + overflowX === "auto" || + overflowX === "scroll" + ) { + return currentElement as HTMLElement; + } + currentElement = currentElement.parentElement; + } + return document.documentElement; + }; + + const scrollableAncestor = getScrollableAncestor(circuitSvg); + + const onMouseMove = (ev: MouseEvent) => { + const rect = scrollableAncestor.getBoundingClientRect(); + + const topBoundary = rect.top; + const bottomBoundary = rect.bottom; + const leftBoundary = rect.left; + const rightBoundary = rect.right; + + // If the mouse has moved past the left boundary, re-enable left auto-scroll + if ( + interaction.disableLeftAutoScroll && + ev.clientX > leftBoundary + 3 * edgeThreshold + ) { + interaction.disableLeftAutoScroll = false; + } + + if (ev.clientY < topBoundary + edgeThreshold) { + scrollableAncestor.scrollTop -= scrollSpeed; + } else if (ev.clientY > bottomBoundary - edgeThreshold) { + scrollableAncestor.scrollTop += scrollSpeed; + } + + if ( + !interaction.disableLeftAutoScroll && + ev.clientX < leftBoundary + edgeThreshold + ) { + scrollableAncestor.scrollLeft -= scrollSpeed; + } else if (ev.clientX > rightBoundary - edgeThreshold) { + scrollableAncestor.scrollLeft += scrollSpeed; + } + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); +}; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/controllers/selectionController.ts b/source/npm/qsharp/ux/circuit-vis/editor/controllers/selectionController.ts new file mode 100644 index 00000000000..b6bbeeb0982 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/controllers/selectionController.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { CircuitEvents } from "../events.js"; +import { addContextMenuToHostElem } from "../contextMenu.js"; +import { InteractionContext } from "./interactionContext.js"; +import { getHostElems, parseWireYs } from "../domUtils.js"; +import { pickClosestWireIndex } from "../../utils.js"; + +/** + * `SelectionController` — owns mousedown on **host elements** (the inner clickable bits of a gate: + * control dots, target circles, targets/measure crosses), and attaches the context menu. + * + * Intentionally small: it only captures the wire under the cursor (so the drag controller knows + * which qubit is being grabbed) and flags whether the grab was on a control dot (so a drag can + * detach just that control instead of the whole gate). + * + * The actual gate-drag start lives in `DragController`, which listens for mousedown on the outer + * `.gate` element. Both fire during the same physical click — the host listener runs first (deeper + * in the DOM) so `selectedWire` is set by the time the drag controller's gate handler runs. + * + * The context menu still receives the full `CircuitEvents` because `addContextMenuToHostElem` + * expects it. + * + * No `dispose()` — host elements live inside the SVG, replaced wholesale on each `enableEvents` + * re-run. + */ +export class SelectionController { + constructor( + private readonly ctx: InteractionContext, + private readonly events: CircuitEvents, + ) { + this.installHostListeners(); + } + + private installHostListeners(): void { + const elems = getHostElems(this.ctx.container); + elems.forEach((elem) => { + elem.addEventListener("mousedown", (ev: MouseEvent) => + this.onHostMouseDown(ev, elem), + ); + addContextMenuToHostElem(this.events, elem); + }); + } + + private onHostMouseDown(ev: MouseEvent, elem: SVGGraphicsElement): void { + if (ev.button !== 0) return; + if (elem.classList.contains("control-dot")) { + this.ctx.interaction.movingControl = true; + } + this.ctx.interaction.selectedWire = this.pickSelectedWire(ev, elem); + } + + /** + * Resolve "which wire did the user grab?" for a click on a host element. The grabbed wire is the + * handle that [`_moveY`](../../actions/circuitActions.ts) slides by `targetWire - sourceWire`. + * + * Two paths: + * + * 1. **Single-wire host elem** (control dots, target circles, measurement crosses, ket boxes, + * single-target unitary bodies). The static `data-wire` set by + * [`_addDataWires`](../draggable.ts) is exactly right. + * + * 2. **Multi-wire host elem** (group body, SWAP, multi-qubit measurement). The static + * `data-wire` is always the topmost wire of the span, which would degrade unit-shift to "pin + * top wire to drop wire". Instead, project the click into SVG coords and pick the closest + * wire-Y via [`pickClosestWireIndex`](../../utils.ts). + * + * Fallback: if `getScreenCTM()` returns `null` or the closest-wire lookup fails, fall back to the + * static `data-wire` attribute. + */ + private pickSelectedWire( + ev: MouseEvent, + elem: SVGGraphicsElement, + ): number | null { + const fallback = (): number | null => { + const attr = elem.getAttribute("data-wire"); + return attr != null ? parseInt(attr) : null; + }; + + const wireYs = parseWireYs(elem); + // Single-wire / unknown spans go straight to the static attr. + if (wireYs.length <= 1) return fallback(); + + // `circuitSvg` is typed as the looser `SVGElement` in `InteractionContext` but is always the + // root `` at runtime. Cast locally to reach `getScreenCTM` without widening the type. + const svg = this.ctx.circuitSvg as unknown as SVGSVGElement; + const ctm = svg.getScreenCTM(); + if (ctm == null) return fallback(); + const pt = new DOMPoint(ev.clientX, ev.clientY); + const svgPt = pt.matrixTransform(ctm.inverse()); + + const wireIndex = pickClosestWireIndex(svgPt.y, wireYs, this.ctx.wireData); + return wireIndex >= 0 ? wireIndex : fallback(); + } +} diff --git a/source/npm/qsharp/ux/circuit-vis/editor/domUtils.ts b/source/npm/qsharp/ux/circuit-vis/editor/domUtils.ts new file mode 100644 index 00000000000..0787c28be40 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/domUtils.ts @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// DOM-query helpers for the editor View layer. Everything here reaches into a rendered SVG/HTML +// tree, so these are intentionally kept out of the pure `utils.ts` module that the data and action +// layers depend on. + +/** + * Find the surrounding gate element of a host element. + * + * @param hostElem The SVG element representing the host element. + * @returns The surrounding gate element or null if not found. + */ +const findGateElem = (hostElem: SVGElement): SVGElement | null => { + return hostElem.closest("[data-location]"); +}; + +/** + * Get list of y values based on circuit wires. + * + * @param container The HTML container element containing the circuit visualization. + * @returns An array of y values corresponding to the circuit wires. + */ +const getWireData = (container: HTMLElement): number[] => { + const wireElems = container.querySelectorAll(".qubit-wire"); + const wireData = Array.from(wireElems).map((wireElem) => { + return Number(wireElem.getAttribute("y1")); + }); + return wireData; +}; + +/** + * Get list of toolbox items. + * + * @param container The HTML container element containing the toolbox items. + * @returns An array of SVG graphics elements representing the toolbox items. + */ +const getToolboxElems = (container: HTMLElement): SVGGraphicsElement[] => { + return Array.from( + container.querySelectorAll("[toolbox-item]"), + ); +}; + +/** + * Get list of host elements that dropzones can be attached to. + * + * @param container The HTML container element containing the circuit visualization. + * @returns An array of SVG graphics elements representing the host elements. + */ +const getHostElems = (container: HTMLElement): SVGGraphicsElement[] => { + const circuitSvg = container.querySelector("svg.qviz"); + return circuitSvg != null + ? Array.from( + circuitSvg.querySelectorAll( + '[class^="gate-"]:not(.gate-control, .gate-swap), .control-dot, .oplus, .cross', + ), + ) + : []; +}; + +/** + * Get list of gate elements from the circuit, but not the toolbox. + * + * @param container The HTML container element containing the circuit visualization. + * @returns An array of SVG graphics elements representing the gate elements. + */ +const getGateElems = (container: HTMLElement): SVGGraphicsElement[] => { + const circuitSvg = container.querySelector("svg.qviz"); + return circuitSvg != null + ? Array.from(circuitSvg.querySelectorAll(".gate")) + : []; +}; + +/** + * Get list of qubit label elements for drag-and-drop. + * + * @param container The HTML container element containing the circuit visualization. + * @returns An array of SVGTextElement representing the qubit labels. + */ +const getQubitLabelElems = (container: HTMLElement): SVGTextElement[] => { + const circuitSvg = container.querySelector("svg.qviz"); + if (!circuitSvg) return []; + const labelGroup = circuitSvg.querySelector("g.qubit-input-states"); + if (!labelGroup) return []; + return Array.from(labelGroup.querySelectorAll("text")); +}; + +/** + * Parse a host element's `data-wire-ys` attribute into a number array. The renderer writes the + * wire-Y coordinates the element visually spans onto this attribute as a JSON array of numbers (see + * [`gateFormatter.ts`](../renderer/formatters/gateFormatter.ts)). + * + * Returns `[]` when the attribute is missing or malformed — same convention `_wireYs` in + * [`draggable.ts`](draggable.ts) follows. Lives here so the selection / drag controllers can read + * host-element wire spans without duplicating the parse. + */ +const parseWireYs = (elem: Element): number[] => { + const wireYsAttr = elem.getAttribute("data-wire-ys"); + if (!wireYsAttr) return []; + try { + const parsed = JSON.parse(wireYsAttr); + if (Array.isArray(parsed) && parsed.every((y) => typeof y === "number")) { + return parsed; + } + } catch { + // Fall through to empty array — caller decides how to handle. + } + return []; +}; + +export { + findGateElem, + getWireData, + getToolboxElems, + getHostElems, + getGateElems, + getQubitLabelElems, + parseWireYs, +}; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts b/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts new file mode 100644 index 00000000000..d3d81af21e8 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/draggable.ts @@ -0,0 +1,746 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { ComponentGrid, Operation } from "../data/circuit.js"; +import { + gateHeight, + gatePadding, + minGateWidth, + regLineStart, + startX, +} from "../renderer/constants.js"; +import { box, controlDot, line } from "../renderer/formatters/formatUtils.js"; +import { formatGate } from "../renderer/formatters/gateFormatter.js"; +import { qubitInput } from "../renderer/formatters/inputFormatter.js"; +import { LayoutMap, LayoutScope } from "../renderer/layoutMap.js"; +import { Location } from "../data/location.js"; +import { toRenderData } from "./standaloneRenderData.js"; +import { Sqore } from "../sqore.js"; +import { getHostElems, getToolboxElems, getWireData } from "./domUtils.js"; +import { getQuantumWireRange } from "../utils.js"; + +/** Register height is the height of a single gate including the padding on the top and bottom. */ +const registerHeight: number = gateHeight + gatePadding * 2; + +interface Context { + container: HTMLElement; + svg: SVGElement; + operationGrid: ComponentGrid; + /** + * Geometry from the layout pass. Source of truth for dropzone positioning. See + * [`layoutMap.ts`](../renderer/layoutMap.ts). + */ + layoutMap: LayoutMap; +} + +/** + * Create dropzone elements for dragging on the circuit. + * + * Every editor-only DOM node lives inside a single `` group attached as + * the last child of `svg.qviz`, so the renderer-owned children (gates, wires, labels) stay purely + * presentational. + * + * @param container HTML element for rendering visualization into + * @param sqore Sqore object + * @param layoutMap Geometry captured during the layout pass + * @returns The editor overlay `` so callers can attach further editor-only DOM (e.g. wire + * dropzones spawned during a drag). + */ +const createDropzones = ( + container: HTMLElement, + sqore: Sqore, + layoutMap: LayoutMap, +): SVGGElement => { + const svg = container.querySelector("svg.qviz") as SVGElement; + + const overlay = document.createElementNS( + "http://www.w3.org/2000/svg", + "g", + ) as SVGGElement; + overlay.classList.add("editor-overlay"); + // Append last so the overlay paints over the rendered content. + svg.appendChild(overlay); + + const context: Context = { + container, + svg, + operationGrid: sqore.circuit.componentGrid, + layoutMap, + }; + _addStyles(container); + _addDataWires(container); + + // Layer z-order inside the overlay (later children paint on top): + // 1. ghost-qubit-layer — the trailing add-a-qubit row. + // 2. dropzone-layer — catches mouseup for gate drops. + // Wire dropzones spawned during a drag append to `overlay` directly, keeping them above both + // static layers. + overlay.appendChild(_ghostQubitLayer(context)); + overlay.appendChild(_dropzoneLayer(context)); + + return overlay; +}; + +/** + * Creates a ghost element for dragging operations in the circuit visualization. + * + * @param ev The mouse event that triggered the creation of the ghost element. + * @param container The HTML container element where the ghost element will be appended. + * @param selectedOperation The operation that is being dragged. + * @param isControl A boolean indicating if the ghost element is for a control operation. + */ +const createGateGhost = ( + ev: MouseEvent, + container: HTMLElement, + selectedOperation: Operation, + isControl: boolean, +) => { + const ghost = isControl + ? controlDot(20, 20, []) + : (() => { + const ghostRenderData = toRenderData(selectedOperation, 0, 0); + return formatGate(ghostRenderData).cloneNode(true) as SVGElement; + })(); + + _createGhostElement(container, ev, ghost, isControl); +}; + +/** + * Creates a ghost element for dragging a qubit line label. + * + * @param ev The mouse event that triggered the drag. + * @param container The HTML container element where the ghost will be appended. + * @param labelElem The SVGTextElement representing the qubit label to be cloned (including any tspans or formatting). + */ +const createQubitLabelGhost = ( + ev: MouseEvent, + container: HTMLElement, + labelElem: SVGTextElement, +) => { + const ghostGate: Operation = { + kind: "unitary", + gate: "?", // This will be replaced by the label elem + targets: [], + }; + const ghostRenderData = toRenderData(ghostGate, 0, 0); + const ghost = formatGate(ghostRenderData) as SVGElement; + + // Replace the placeholder text with the label element + const placeholderText = ghost.querySelector(".qs-maintext"); + if (placeholderText) { + // Remove all children from placeholderText + while (placeholderText.firstChild) { + placeholderText.removeChild(placeholderText.firstChild); + } + // Clone and append each child from labelElem + for (const child of Array.from(labelElem.childNodes)) { + placeholderText.appendChild(child.cloneNode(true)); + } + placeholderText.setAttribute( + "font-size", + labelElem.getAttribute("font-size") || "16", + ); + } + + _createGhostElement(container, ev, ghost, false); +}; + +/** + * Creates and appends a draggable "ghost" element to the DOM for visual feedback during drag operations. + * + * @param container The HTML container element to which the ghost element will be appended. + * @param ev The MouseEvent that triggered the drag, used to position the ghost. + * @param ghost The SVGElement representing the visual ghost to be dragged. + * @param isControl Boolean indicating if the ghost is for a control operation (affects sizing). + */ +const _createGhostElement = ( + container: HTMLElement, + ev: MouseEvent, + ghost: SVGElement, + isControl: boolean, +) => { + // Generate svg element to wrap around ghost element + const svgElem = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgElem.append(ghost); + + // Generate div element to wrap around svg element + const divElem = document.createElement("div"); + divElem.classList.add("ghost"); + divElem.appendChild(svgElem); + divElem.style.position = "fixed"; + + if (container) { + container.appendChild(divElem); + + // Now that the element is appended to the DOM, get its dimensions + const [ghostWidth, ghostHeight] = isControl + ? [40, 40] + : (() => { + const ghostRect = ghost.getBoundingClientRect(); + return [ghostRect.width, ghostRect.height]; + })(); + + const updateDivLeftTop = (ev: MouseEvent) => { + divElem.style.left = `${ev.clientX - ghostWidth / 2}px`; + divElem.style.top = `${ev.clientY - ghostHeight / 2}px`; + }; + + updateDivLeftTop(ev); + + const cleanup = () => { + container.removeEventListener("mousemove", updateDivLeftTop); + document.removeEventListener("mouseup", cleanup); + if (divElem.parentNode) { + divElem.parentNode.removeChild(divElem); + } + }; + + container.addEventListener("mousemove", updateDivLeftTop); + document.addEventListener("mouseup", cleanup); + } else { + console.error("container not found"); + } +}; + +/** + * Create a dropzone element that spans the length of the wire. + * + * @param circuitSvg The SVG element representing the circuit. + * @param wireData An array of y values corresponding to the circuit wires. + * @param wireIndex The index of the wire or the "between" position. + * @param isBetween If true, creates a dropzone between wires. + * @returns The created dropzone SVG element. + */ +const createWireDropzone = ( + circuitSvg: SVGElement, + wireData: number[], + wireIndex: number, + isBetween: boolean = false, +): SVGElement => { + const svgWidth = Number(circuitSvg.getAttribute("width")); + const paddingY = 20; + let wireY: number; + + if (isBetween) { + // Dropzone BETWEEN wires (including before first and after last) + if (wireIndex === wireData.length) { + wireY = wireData[wireData.length - 1] + registerHeight / 2; + } else { + wireY = wireData[wireIndex] - registerHeight / 2; + } + } else { + // Dropzone ON the wire + wireY = wireData[wireIndex]; + } + + const dropzone = box( + 0, + wireY - paddingY, + svgWidth, + paddingY * 2, + "dropzone-full-wire", + ); + dropzone.setAttribute("data-dropzone-wire", `${wireIndex}`); + + return dropzone; +}; + +/** + * Remove all wire dropzones. + * + * @param circuitSvg The SVG element representing the circuit. + */ +const removeAllWireDropzones = (circuitSvg: SVGElement) => { + const dropzones = circuitSvg.querySelectorAll(".dropzone-full-wire"); + dropzones.forEach((elem) => { + elem.parentNode?.removeChild(elem); + }); +}; + +/** + * Add data-wire to all host elements + */ +const _addDataWires = (container: HTMLElement) => { + const elems = getHostElems(container); + elems.forEach((elem) => { + const wireYs = _wireYs(elem); + // i.e. wireYs = [40], wireData returns [40, 100, 140, 180] dataWire will return 0, which is the + // index of 40 in wireData + const dataWire = getWireData(container).findIndex((y) => + wireYs.includes(y), + ); + if (dataWire !== -1) { + elem.setAttribute("data-wire", `${dataWire}`); + } + }); +}; + +/** + * Create a list of wires that element is spanning on i.e. Gate 'Foo' spans on wire 0 (y=40), 1 + * (y=100), and 2 (y=140) Function returns [40, 100, 140] + */ +const _wireYs = (elem: SVGGraphicsElement): number[] => { + const wireYsAttr = elem.getAttribute("data-wire-ys"); + if (wireYsAttr) { + try { + const wireYs = JSON.parse(wireYsAttr); + if (Array.isArray(wireYs) && wireYs.every((y) => typeof y === "number")) { + return wireYs; + } + } catch { + console.warn(`Invalid data-wire-ys attribute: ${wireYsAttr}`); + } + } + return []; +}; + +/** + * Add custom styles specific to this module + */ +const _addStyles = (container: HTMLElement): void => { + const elems = getHostElems(container); + elems.forEach((elem) => { + if (_wireYs(elem).length < 2) elem.style.cursor = "grab"; + }); + + const toolBoxElems = getToolboxElems(container); + toolBoxElems.forEach((elem) => { + elem.style.cursor = "grab"; + }); +}; + +/** + * Create the ghost-qubit layer — the trailing add-a-qubit row that appears below the last real wire + * while a drag is in progress. + * + * Returns the layer ready to be attached by the caller. The one side effect kept here is extending + * the SVG's `height` / `viewBox` to make room for the trailing ghost wire — a renderer-side + * dimension, so it lives at the SVG root rather than on the overlay. + */ +const _ghostQubitLayer = (context: Context) => { + const { container, svg } = context; + + const wireData = getWireData(container); + + const svgHeight = Number(svg.getAttribute("height") || svg.clientHeight || 0); + const svgWidth = Number(svg.getAttribute("width") || svg.clientWidth || 800); + const ghostY = svgHeight; + + const ghostLayer = document.createElementNS( + "http://www.w3.org/2000/svg", + "g", + ); + ghostLayer.classList.add("ghost-qubit-layer"); + ghostLayer.style.display = "none"; + + const ghostWire = line( + regLineStart, + ghostY, + svgWidth, + ghostY, + "qubit-wire ghost-opacity", + ); + + const ghostLabel = qubitInput( + ghostY, + wireData.length, + wireData.length.toString(), + ); + ghostLabel.classList.add("ghost-opacity"); + ghostLayer.appendChild(ghostWire); + ghostLayer.appendChild(ghostLabel); + + // Extend the rendered SVG so the trailing ghost row is visible. (Touches the SVG root, not the + // overlay — a renderer-side dimension.) + context.svg.setAttribute("height", (svgHeight + registerHeight).toString()); + svg.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight + registerHeight}`); + + return ghostLayer; +}; + +/** + * Create dropzone layer with all dropzones populated. + * + * Walks the component grid recursively: top-level columns get dropzones, and any expanded group's + * body gets nested dropzones with hierarchical location strings (e.g. `0,0-1,2`). Those strings are + * exactly what `findParentArray` / `addOperation` / `moveOperation` already understand. + * + * Coordinates come from `context.layoutMap` — the same numbers the layout pass computed when + * rendering the gates. Geometry is never recovered from SVG attributes. + */ +const _dropzoneLayer = (context: Context) => { + const dropzoneLayer = document.createElementNS( + "http://www.w3.org/2000/svg", + "g", + ); + dropzoneLayer.classList.add("dropzone-layer"); + dropzoneLayer.style.display = "none"; + + const { container, operationGrid, layoutMap } = context; + const wireData = getWireData(container); + + // Recurse from the top-level scope. Wire extent at top level covers every wire; nested scopes + // pass a tightened extent matching their group's [minTarget, maxTarget]. Each scope's + // trailing-append column is emitted by `_populateDropzonesForGrid` itself. + _populateDropzonesForGrid( + dropzoneLayer, + layoutMap, + operationGrid, + "", + wireData, + 0, + wireData.length, + ); + + return dropzoneLayer; +}; + +/** + * Append a trailing-column band of dropzones (one per wire in `[minWire, maxWire)`) just past the + * rightmost column of a single scope — either the top-level grid or an expanded group's children + * grid. + * + * Each emitted dropzone is shaped like the existing left-edge inter-column band (so it visually + * reads as "I'm extending this scope to the right"), but tagged + * `data-dropzone-inter-column="false"` so the drop handler treats it as a normal drop. The `_addOp` + * action takes care of synthesizing the new column when the target column index is one past the + * rightmost. + * + * Together with the leading-column band that already falls out of the `_populateDropzonesForGrid` + * loop at `colIndex=0`, this gives every expanded group a one-column-of-reach extend-sideways + * gesture on both edges, no modifier required. + * + * Idempotent w.r.t. wire extent: at the top level, `[minWire, maxWire)` is `[0, wireData.length)`. + * For nested scopes it's the parent group's own wire span, so the trailing column can't escape the + * group's vertical bounds. + */ +const _appendTrailingColumnForScope = ( + dropzoneLayer: SVGElement, + scope: LayoutScope, + wireData: number[], + minWire: number, + maxWire: number, + pathPrefix: string, +): void => { + const trailingColIndex = scope.columnXOffsets.length; + const ctx: DropzoneContext = { scope, wireData, pathPrefix }; + for (let wireIndex = minWire; wireIndex < maxWire; wireIndex++) { + const dropzone = makeDropzoneBox(ctx, { + colIndex: trailingColIndex, + opIndex: 0, + wireIndex, + interColumn: true, + }); + dropzone.setAttribute("data-dropzone-inter-column", "false"); + dropzoneLayer.appendChild(dropzone); + } +}; + +/** + * Emit dropzones for one scope (top-level grid or one expanded group's children grid) into + * `dropzoneLayer`, then recurse for each expanded group inside this scope. + * + * Coordinates are sourced from `layoutMap.scopes.get(pathPrefix)`, which holds the *exact* + * per-column x-offsets and widths the layout pass computed for this scope. + * + * @param dropzoneLayer Mutable accumulator — every dropzone produced at any depth is appended + * here. + * @param layoutMap Geometry from the layout pass. Looked up by `pathPrefix` to find this + * scope's column offsets/widths. + * @param grid The grid of components for this scope. + * @param pathPrefix Hierarchical location prefix for this scope. `""` at top level; `"0,0"` for + * the children of the top-level op at column 0 / opIndex 0; `"0,0-1,2"` for grandchildren; etc. + * Doubles as the `LayoutMap.scopes` key. + * @param wireData Full circuit wire-Y array (wires don't get reindexed inside groups; child + * operations still reference circuit-wide qubit IDs). + * @param minWire Inclusive lower bound on wire indices this scope is allowed to produce + * dropzones for. At top level this is `0`; for nested scopes it's the parent group's top wire so + * a drop inside `Foo` (which spans wires 0-1) can never land on wire 2. + * @param maxWire Exclusive upper bound, mirror of `minWire`. + */ +const _populateDropzonesForGrid = ( + dropzoneLayer: SVGElement, + layoutMap: LayoutMap, + grid: ComponentGrid, + pathPrefix: string, + wireData: number[], + minWire: number, + maxWire: number, +): void => { + const scope = layoutMap.scopes.get(pathPrefix); + // Defensive fallback: if the scope wasn't recorded (shouldn't happen for any expanded scope, + // since `processOperations` records each one it processes), skip rather than emit garbage + // dropzones. + if (scope == null) return; + + const ctx: DropzoneContext = { scope, wireData, pathPrefix }; + + // The LayoutMap's column count matches the rendered grid (one entry per processed column); using + // `Math.max` is just defensive against unexpected mismatch. + const colCount = Math.max(scope.columnXOffsets.length, grid.length); + + for (let colIndex = 0; colIndex < colCount; colIndex++) { + const columnOps = grid[colIndex]; + if (columnOps == null) continue; + + // Precompute which wires this column's ops actually occupy. A central dropzone at an occupied + // wire would visually sit on top of a gate (or its connecting lines), even if the gate belongs + // to a different op than the one being iterated — so the "is this wire safe for a central + // drop?" question can't be answered from a single op in isolation. + // + // We also need a per-wire `opIndex` for the dropzone's location string. The action layer treats + // `opIndex` as the array position to insert at (`Array.splice(opIndex, 0, op)`); the renderer + // doesn't depend on array order for layout. So: + // + // - Owned wire → use the owning op's opIndex. Drops "onto" the gate insert at the gate's + // array position. + // - Unowned wire → use `components.length`. Drops in a gap append to the column's array. + // + // Walk ops in declared order; first claimant of a wire wins (overlapping ops in one column + // shouldn't occur — the action layer's `_addOp` splits them into separate columns — but + // defensive anyway). + // + // Quantum-only span: a classically-controlled op back-references the producing measurement's + // qubit via `.controls`, but doesn't render any body on that wire (only a small + // classical-control circle sits on the row). Treating that wire as occupied would suppress the + // central dropzone there, leaving the visually-empty area at the group's column un-droppable + // for top-level inserts. + const occupiedWires = new Set(); + const wireOwnerOpIndex = new Map(); + columnOps.components.forEach((op, opIndex) => { + const [minT, maxT] = getQuantumWireRange(op); + for (let w = minT; w <= maxT; w++) { + occupiedWires.add(w); + if (!wireOwnerOpIndex.has(w)) { + wireOwnerOpIndex.set(w, opIndex); + } + } + }); + + // Wire-by-wire pass. The previous algorithm accumulated a monotonically-increasing `wireIndex` + // across ops; that assumed ops were sorted by `minTarget`, which the compiler often violates + // (it tends to emit ops in execution order rather than wire order). Iterating wires directly + // removes that assumption and emits the same boxes for the sorted-by-wire common case. + for (let wireIndex = minWire; wireIndex < maxWire; wireIndex++) { + const opIndex = + wireOwnerOpIndex.get(wireIndex) ?? columnOps.components.length; + + // Inter-column band: always emit. It's a narrow vertical strip on the left edge of the column + // ("insert a new column before this one"); even when it slightly overlaps a gate's body it + // doesn't visually conflict with the gate icon. + dropzoneLayer.appendChild( + makeDropzoneBox(ctx, { + colIndex, + opIndex, + wireIndex, + interColumn: true, + }), + ); + + // Central full-width box: emit only at wires NOT occupied by any op in this column. This is + // the fix for the phantom dropzone bug — without the column-wide occupancy check, an op's own + // "above-me" wires (`wireIndex < minTarget`) could be occupied by a different op later in the + // column, and the central box would sit on top of that op's gate. + if (!occupiedWires.has(wireIndex)) { + dropzoneLayer.appendChild( + makeDropzoneBox(ctx, { + colIndex, + opIndex, + wireIndex, + interColumn: false, + }), + ); + } + } + + // Recurse into expanded children. Decoupled from the wire loop above because recursion depends + // only on the op's identity and wire extent, not on `wireIndex`. + // + // The recursion's wire extent matches the group's own [minTarget, maxTarget] (inclusive), + // ensuring nested dropzones can never escape the parent group. Drive the is-this-expanded + // decision off the LayoutMap rather than `isExpandedGroup(op)`: `op` here belongs to + // `sqore.circuit.componentGrid` (the original), while expand flags from + // `expandOperationsToDepth`, `expandIfSingleOperation`, and the user's expand-chevron clicks + // are applied to the per-render deep copy only — never to the original. The LayoutMap, built + // from that deep copy, is the authoritative record of which groups were rendered expanded. + columnOps.components.forEach((op, opIndex) => { + const childKey = composeLocation(pathPrefix, colIndex, opIndex); + if (op.children != null && layoutMap.scopes.has(childKey)) { + // Quantum-only span: a classically-controlled group's `.controls` carries the producing + // measurement's qubit as a back-reference, but that qubit isn't a member wire of the group. + // Including it here would make drops onto that qubit (and adds from the toolbox) silently + // land inside the group; the user has to shift-drag to extend the group to a new wire. + const [minTarget, maxTarget] = getQuantumWireRange(op); + _populateDropzonesForGrid( + dropzoneLayer, + layoutMap, + op.children, + childKey, + wireData, + minTarget, + maxTarget + 1, + ); + } + }); + } + + // Trailing-append column for this scope. At the top level this is the "add a brand-new column + // past the rightmost" affordance; for an expanded group it's the right-edge extend-sideways band + // that mirrors the leading-column band emitted at `colIndex=0` of the column loop above. Runs + // once per scope, after the column loop, so it sits at the same recursion depth as the children + // walk. + _appendTrailingColumnForScope( + dropzoneLayer, + scope, + wireData, + minWire, + maxWire, + pathPrefix, + ); +}; + +/** + * Half-width of an inter-column dropzone band, in svg units. The band straddles the gap between two + * columns; total band width is `INTER_COLUMN_HALF_WIDTH * 2`. + */ +const INTER_COLUMN_HALF_WIDTH = gatePadding * 2; + +/** Vertical padding above/below each dropzone, in svg units. */ +const DROPZONE_PADDING_Y = 20; + +/** + * Geometry for one column inside one scope. Either looks up an existing column from `LayoutScope`, + * or synthesizes a "trailing" position past the rightmost column for the append-new-column + * dropzone. + * + * Returned `colStartX` is the column's left edge in absolute svg coords — the same value as + * `_fillRenderDataX`'s `colStartX[i]`. + */ +const columnGeometry = ( + scope: LayoutScope, + colIndex: number, +): { colStartX: number; colWidth: number } => { + if (colIndex < scope.columnXOffsets.length) { + return { + colStartX: scope.columnXOffsets[colIndex], + colWidth: scope.columnWidths[colIndex] ?? minGateWidth, + }; + } + // Synthesize a column past the rightmost. Spacing matches the historical accumulator + // (`gatePadding * 2` between columns). + const lastIndex = scope.columnXOffsets.length - 1; + if (lastIndex >= 0) { + const lastStart = scope.columnXOffsets[lastIndex]; + const lastWidth = scope.columnWidths[lastIndex] ?? minGateWidth; + return { + colStartX: lastStart + lastWidth + gatePadding * 2, + colWidth: minGateWidth, + }; + } + return { colStartX: startX, colWidth: minGateWidth }; +}; + +/** + * Compose a hierarchical dropzone location string for `(prefix, col, op)`. + * + * composeLocation("", 0, 1) -> "0,1" composeLocation("0,0", 1, 2) -> "0,0-1,2" + * + * Thin wrapper over `Location` for the wire-format dropzone attrs. + */ +const composeLocation = ( + prefix: string, + colIndex: number, + opIndex: number, +): string => Location.parse(prefix).child(colIndex, opIndex).toString(); + +/** + * The layout context shared by every dropzone drawn for one scope: the scope's geometry, the + * model-address prefix that scope maps to, and the global wire-Y table. All three are invariant + * across the wires and columns of a single scope, so a caller builds this once and reuses it for + * each dropzone it emits. + */ +interface DropzoneContext { + /** + * Layout geometry for the scope. Source of truth — comes straight from + * `LayoutMap.scopes.get(pathPrefix)`. + */ + scope: LayoutScope; + /** Wire Y positions in absolute svg coords (whole-circuit table). */ + wireData: number[]; + /** + * Hierarchical scope prefix; `""` (the default) at top level. Composed into + * `data-dropzone-location` so `findParentArray` walks into the right `children` grid on drop. + */ + pathPrefix?: string; +} + +/** + * The single cell a dropzone targets within its `DropzoneContext`, plus which of the two dropzone + * shapes to draw there. + */ +interface DropzoneTarget { + /** + * Column the dropzone belongs to. May equal `scope.columnXOffsets.length` to address the + * trailing-append column past the rightmost. + */ + colIndex: number; + /** Operation index inside the column. */ + opIndex: number; + /** Index into `wireData` for this dropzone's row. */ + wireIndex: number; + /** + * `true` for the narrow band straddling the left edge of `colIndex`; `false` for the full-width + * box covering the column. + */ + interColumn: boolean; +} + +/** + * Create a dropzone box element for `target` within the scope described by `ctx`. + */ +const makeDropzoneBox = ( + ctx: DropzoneContext, + target: DropzoneTarget, +): SVGElement => { + const { scope, wireData, pathPrefix = "" } = ctx; + const { colIndex, opIndex, wireIndex, interColumn } = target; + const wireY = wireData[wireIndex]; + const { colStartX, colWidth } = columnGeometry(scope, colIndex); + + const dropzone = interColumn + ? // Inter-column band: centered on the left edge of `colIndex`, + // i.e. on the gap between this column and the previous one. + box( + colStartX - INTER_COLUMN_HALF_WIDTH - gatePadding, + wireY - DROPZONE_PADDING_Y, + INTER_COLUMN_HALF_WIDTH * 2, + DROPZONE_PADDING_Y * 2, + "dropzone", + ) + : // On-column box: covers exactly `[colStartX, colStartX + colWidth]`, + // which is the gate's bounding box width-wise. + box( + colStartX, + wireY - DROPZONE_PADDING_Y, + colWidth, + DROPZONE_PADDING_Y * 2, + "dropzone", + ); + + dropzone.setAttribute( + "data-dropzone-location", + composeLocation(pathPrefix, colIndex, opIndex), + ); + dropzone.setAttribute("data-dropzone-wire", `${wireIndex}`); + dropzone.setAttribute("data-dropzone-inter-column", `${interColumn}`); + return dropzone; +}; + +export { + createDropzones, + createGateGhost, + createQubitLabelGhost, + createWireDropzone, + makeDropzoneBox, + removeAllWireDropzones, +}; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/events.ts b/source/npm/qsharp/ux/circuit-vis/editor/events.ts new file mode 100644 index 00000000000..6ae70342a67 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/events.ts @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Circuit, ComponentGrid, Qubit, Unitary } from "../data/circuit.js"; +import { CircuitModel } from "../data/circuitModel.js"; +import { DragController } from "./controllers/dragController.js"; +import { InteractionContext } from "./controllers/interactionContext.js"; +import { InteractionState } from "../actions/interactionState.js"; +import { KeyboardController } from "./controllers/keyboardController.js"; +import { LayoutMap } from "../renderer/layoutMap.js"; +import { QubitController } from "./controllers/qubitController.js"; +import { SelectionController } from "./controllers/selectionController.js"; +import { Sqore } from "../sqore.js"; +import { getWireData } from "./domUtils.js"; + +let events: CircuitEvents | null = null; +let currentCircuitSvg: SVGElement | null = null; + +/** + * Creates and attaches the events that allow editing of the circuit. + * + * @param container HTML element for rendering visualization into + * @param sqore Sqore object + * @param layoutMap Geometry from the layout pass + */ +const enableEvents = ( + container: HTMLElement, + sqore: Sqore, + layoutMap: LayoutMap, + useRefresh: () => void, +): void => { + if (events != null) { + events.dispose(); + } + events = new CircuitEvents(container, sqore, layoutMap, useRefresh); + + // Lets other modules avoid reading a stale model during a re-render where the SVG was replaced + // but enableEvents hasn't run yet. + currentCircuitSvg = container.querySelector("svg.qviz") as SVGElement | null; + + // Signal that the model is ready so state-viz can re-render without polling. + try { + const CustomEventCtor = (globalThis as any).CustomEvent as + | (new (type: string, init?: CustomEventInit) => CustomEvent) + | undefined; + if (CustomEventCtor && typeof container.dispatchEvent === "function") { + container.dispatchEvent( + new CustomEventCtor("qsharp:circuit:modelReady", { bubbles: true }), + ); + } + } catch { + // ignore + } +}; + +/** + * `CircuitEvents` — thin coordinator for the editor's View layer. + * + * Pure wiring: builds the shared `InteractionContext` and instantiates one focused controller per + * slice of pointer / keyboard interaction. Controllers own their listeners and lifecycle; + * `dispose()` chains through to them. The event logic lives in `controllers/`. + * + * Compatibility shims kept on this class: + * + * - `componentGrid` / `qubits` / `qubitUseCounts` getters delegate to `model` for + * `getCurrentCircuitModel` and `contextMenu.ts`. + * - `_startAddingControl` / `_startRemovingControl` delegate to the drag controller so the context + * menu can invoke them by name. + */ +class CircuitEvents { + /** The Data layer. See [circuitModel.ts](../data/circuitModel.ts). */ + readonly model: CircuitModel; + /** Ephemeral session state. See [interactionState.ts](../actions/interactionState.ts). */ + readonly interaction: InteractionState = new InteractionState(); + readonly renderFn: () => void; + + /** Convenience — read by `getCurrentCircuitModel` + `contextMenu.ts`. */ + get componentGrid(): ComponentGrid { + return this.model.componentGrid; + } + get qubits(): Qubit[] { + return this.model.qubits; + } + get qubitUseCounts(): number[] { + return this.model.qubitUseCounts; + } + + private readonly keyboard: KeyboardController; + private readonly drag: DragController; + // Held only because DragController injects it for the qubit-drag-out-delete path. + private readonly qubit: QubitController; + // No public methods; exists to install host-element listeners on construction. + private readonly selection: SelectionController; + + constructor( + container: HTMLElement, + sqore: Sqore, + layoutMap: LayoutMap, + useRefresh: () => void, + ) { + this.renderFn = useRefresh; + this.model = new CircuitModel(sqore.circuit); + this.model.removeTrailingUnusedQubits(); + + const circuitSvg = container.querySelector("svg.qviz") as SVGElement; + // The editor overlay (and its dropzone + ghost-qubit sub-layers) is built by createDropzones + // before enableEvents runs. + const overlayLayer = container.querySelector( + ".editor-overlay", + ) as SVGGElement; + const dropzoneLayer = container.querySelector( + ".dropzone-layer", + ) as SVGGElement; + const ghostQubitLayer = container.querySelector( + ".ghost-qubit-layer", + ) as SVGGElement; + + if (this.qubits.length === 0) { + ghostQubitLayer.style.display = "block"; + } + + // Build the shared context once and hand it to every controller. `wireData` is read from the + // DOM since the ghost wire's y is only available there at this point. + const ctx: InteractionContext = { + model: this.model, + interaction: this.interaction, + layoutMap, + container, + circuitSvg, + overlayLayer, + dropzoneLayer, + ghostQubitLayer, + wireData: getWireData(container), + renderFn: useRefresh, + }; + + // Order matters: DragController takes the QubitController. + this.qubit = new QubitController(ctx); + this.drag = new DragController(ctx, this.qubit); + this.keyboard = new KeyboardController(ctx); + this.selection = new SelectionController(ctx, this); + } + + /** Disposes the controllers that own document-level listeners. */ + dispose() { + this.keyboard.dispose(); + this.drag.dispose(); + } + + /** + * Begin the wire-pick add-control flow. Delegates to the drag controller; kept here because + * `contextMenu.ts` invokes it by name. + */ + _startAddingControl(selectedOperation: Unitary) { + this.drag.startAddingControl(selectedOperation); + } + + /** + * Begin the wire-pick remove-control flow. Delegates to the drag controller; kept here because + * `contextMenu.ts` invokes it by name. + */ + _startRemovingControl(selectedOperation: Unitary) { + this.drag.startRemovingControl(selectedOperation); + } +} + +export { enableEvents, CircuitEvents }; + +// Returns the current circuit model, but only if it matches the currently-rendered SVG — prevents +// state-viz from computing against a previous render's model mid-re-render. +export function getCurrentCircuitModel( + expectedSvg?: SVGElement | null, +): Circuit | null { + if (events == null) return null; + if (expectedSvg && currentCircuitSvg && expectedSvg !== currentCircuitSvg) { + return null; + } + return { qubits: events.qubits, componentGrid: events.componentGrid }; +} diff --git a/source/npm/qsharp/ux/circuit-vis/editor/installEditor.ts b/source/npm/qsharp/ux/circuit-vis/editor/installEditor.ts new file mode 100644 index 00000000000..3b125356259 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/installEditor.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { LayoutMap } from "../renderer/layoutMap.js"; +import type { EditorHandlers, Sqore } from "../sqore.js"; +import { createDropzones } from "./draggable.js"; +import { enableEvents } from "./events.js"; +import { mountEditorShell } from "./shell.js"; + +/** + * Editor-mode bootstrap: takes a freshly-rendered circuit and installs every editor concern around + * it in one call — editor-only DOM (toolbox, state-viz panel, dropzones, ghost-qubit row), pointer + * + keyboard interaction (see [events.ts](events.ts)), and the host's edit notification. + * + * Sqore re-invokes this on every `renderCircuit`, so the helpers it calls are idempotent on re-call + * (e.g. [mountEditorShell](shell.ts) reuses pre-existing DOM). + * + * @param container HTML element holding the rendered circuit. + * @param sqore Sqore instance — needed by `enableEvents` and to emit minimized-circuit data via + * `editCallback`. + * @param layoutMap Geometry from the layout pass. See [layoutMap.ts](../renderer/layoutMap.ts). + * @param editor Editor handlers from the host (edit/run/state-viz callbacks). + * @param refresh Re-render closure — typically `() => sqore.renderCircuit(container)`, passed + * through to `enableEvents`. + */ +const installEditor = ( + container: HTMLElement, + sqore: Sqore, + layoutMap: LayoutMap, + editor: EditorHandlers, + refresh: () => void, +): void => { + createDropzones(container, sqore, layoutMap); + mountEditorShell( + container, + editor.computeStateVizColumnsForCircuitModel, + editor.runCallback, + ); + enableEvents(container, sqore, layoutMap, refresh); + editor.editCallback(sqore.minimizeCircuits(sqore.circuitGroup)); +}; + +export { installEditor }; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/prompts.ts b/source/npm/qsharp/ux/circuit-vis/editor/prompts.ts new file mode 100644 index 00000000000..b69d39c0bdc --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/prompts.ts @@ -0,0 +1,417 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Home for the editor's prompt dialogs: the confirm-dialog and text-input primitives, plus the +// delete/move confirmation flows and the argument-collection flow that use them. + +import { + collectMeasurementConsumers, + moveMeasurementWithDependents, + moveOperation, + removeMeasurementWithDependents, + removeOperation, +} from "../actions/circuitActions.js"; +import { CircuitModel } from "../data/circuitModel.js"; +import { Location } from "../data/location.js"; +import { Operation, Parameter } from "../data/circuit.js"; +import { findOperation } from "../utils.js"; +import { + isValidAngleExpression, + normalizeAngleExpression, +} from "../angleExpression.js"; + +/** + * Confirm-dialog primitive used by destructive editor flows (currently only "remove a qubit line + * that has operations attached"). + * + * Standalone so individual controllers can use it without depending on the full `CircuitEvents` + * class. + * + * @param message - Text shown in the prompt body. + * @param callback - Invoked with `true` on OK, `false` on Cancel. + */ +export const createConfirmPrompt = ( + message: string, + callback: (confirmed: boolean) => void, +) => { + const overlay = document.createElement("div"); + overlay.classList.add("prompt-overlay"); + overlay.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + const confirmContainer = document.createElement("div"); + confirmContainer.classList.add("prompt-container"); + + const messageElem = document.createElement("div"); + messageElem.classList.add("prompt-message"); + messageElem.textContent = message; + + const buttonsContainer = document.createElement("div"); + buttonsContainer.classList.add("prompt-buttons"); + + const okButton = document.createElement("button"); + okButton.classList.add("prompt-button"); + okButton.textContent = "OK"; + okButton.addEventListener("click", () => { + callback(true); + document.body.removeChild(overlay); + document.removeEventListener("keydown", handleGlobalKeyDown, true); + }); + + const cancelButton = document.createElement("button"); + cancelButton.classList.add("prompt-button"); + cancelButton.textContent = "Cancel"; + cancelButton.addEventListener("click", () => { + callback(false); + document.body.removeChild(overlay); + document.removeEventListener("keydown", handleGlobalKeyDown, true); + }); + + // Handle Enter (commit) and Escape (cancel) globally while the prompt is open. Capture-phase so + // we don't fight any descendant handlers in the editor surface. + const handleGlobalKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + okButton.click(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelButton.click(); + } + }; + document.addEventListener("keydown", handleGlobalKeyDown, true); + + buttonsContainer.appendChild(okButton); + buttonsContainer.appendChild(cancelButton); + + confirmContainer.appendChild(messageElem); + confirmContainer.appendChild(buttonsContainer); + + overlay.appendChild(confirmContainer); + document.body.appendChild(overlay); + + // Drop focus from whatever was focused so Enter/Escape go through our document-level handler + // instead of any input that had it. + if (document.activeElement) { + (document.activeElement as HTMLElement).blur(); + } +}; + +/** + * Delete an operation. If the op is a measurement with downstream classical consumers, prompt the + * user first; on confirm, the measurement is removed along with every dependent op. The + * non-measurement / no-consumer paths pass straight through to + * [`removeOperation`](../actions/circuitActions.ts). + * + * `renderFn` runs once on every path that mutates the model. On cancel, nothing mutates and + * `renderFn` is NOT called. + */ +export const deleteOperationWithConfirmation = ( + model: CircuitModel, + location: string, + renderFn: () => void, +): void => { + const op = findOperation(model.componentGrid, location); + if (op != null && op.kind === "measurement") { + const consumers = collectMeasurementConsumers( + model.componentGrid, + location, + ); + if (consumers.length > 0) { + const n = consumers.length; + const message = + n === 1 + ? `Deleting this measurement will also delete 1 dependent operation that references its classical result. Continue?` + : `Deleting this measurement will also delete ${n} dependent operations that reference its classical result. Continue?`; + createConfirmPrompt(message, (confirmed) => { + if (!confirmed) return; + removeMeasurementWithDependents( + model, + location, + consumers.map((c) => c.op), + ); + renderFn(); + }); + return; + } + } + removeOperation(model, location); + renderFn(); +}; + +/** + * Move an operation. If the op is a measurement with downstream classical consumers, prompt before + * committing: on confirm, the move remaps the classical refs of consumers that stay after the M's + * new column and cascade-deletes any that would end up at-or-before it. Non-measurement / + * no-consumer paths pass straight through to [`moveOperation`](../actions/circuitActions.ts). + * + * `movingControl` MUST be threaded through unchanged. The drag controller routes every non-clone + * drag through here, including control-dot drags on ordinary unitaries; hardcoding `false` would + * make `_moveY`'s single-leg branch rewrite the op onto the control's wire (turning CNOT(target=q1, + * ctrl=q0) into a self-controlled X on q0). The M-consumer path passes `false` to + * `moveMeasurementWithDependents` since Ms have no `controls`. + */ +export const moveOperationWithConfirmation = ( + model: CircuitModel, + sourceLocation: string, + targetLocation: string, + sourceWire: number, + targetWire: number, + movingControl: boolean, + insertNewColumn: boolean, + renderFn: () => void, +): void => { + const sourceOp = findOperation(model.componentGrid, sourceLocation); + if (sourceOp != null && sourceOp.kind === "measurement") { + const consumers = collectMeasurementConsumers( + model.componentGrid, + sourceLocation, + ); + if (consumers.length > 0) { + // Partition consumers by whether the M's new column comes strictly before them. Runs in + // pre-move coordinates, which is sound since splicing doesn't change relative column + // ordering. + const targetLocParsed = Location.parse(targetLocation); + const survivors: { op: Operation; location: string }[] = []; + const invalidated: { op: Operation; location: string }[] = []; + for (const c of consumers) { + const cLoc = Location.parse(c.location); + if (targetLocParsed.inEarlierColumnThan(cLoc)) { + survivors.push(c); + } else { + invalidated.push(c); + } + } + + const message = _buildMoveMConsumerMessage( + survivors.length, + invalidated.length, + ); + createConfirmPrompt(message, (confirmed) => { + if (!confirmed) return; + moveMeasurementWithDependents( + model, + sourceLocation, + targetLocation, + sourceWire, + targetWire, + insertNewColumn, + invalidated.map((c) => c.op), + ); + renderFn(); + }); + return; + } + } + moveOperation( + model, + sourceLocation, + targetLocation, + sourceWire, + targetWire, + movingControl, + insertNewColumn, + ); + renderFn(); +}; + +/** + * Build the body text for the M-move confirmation prompt. Emits a move-only, delete-only, or + * combined clause depending on which consumer buckets are non-empty, pluralized per-clause. + */ +const _buildMoveMConsumerMessage = ( + survivors: number, + invalidated: number, +): string => { + const opWord = (n: number): string => + n === 1 ? "1 dependent operation" : `${n} dependent operations`; + const willBeUpdated = + survivors === 1 + ? `${opWord(survivors)} will be updated to reference this measurement's new wire` + : `${opWord(survivors)} will be updated to reference this measurement's new wire`; + const willBeDeleted = + invalidated === 1 + ? `${opWord(invalidated)} would end up before this measurement in document order and will be deleted` + : `${opWord(invalidated)} would end up before this measurement in document order and will be deleted`; + + if (survivors > 0 && invalidated > 0) { + return `Moving this measurement: ${willBeUpdated}; ${willBeDeleted}. Continue?`; + } + if (survivors > 0) { + return `Moving this measurement: ${willBeUpdated}. Continue?`; + } + // invalidated > 0 (the caller only enters this branch when consumers.length > 0, so at least one + // bucket is non-empty). + return `Moving this measurement: ${willBeDeleted}. Continue?`; +}; + +/** + * Prompt the user for argument values. + * @param params - The parameters for which the user needs to provide values. + * @param defaultArgs - The default values for the parameters, if any. + * @returns A Promise that resolves with the user-provided arguments as an array of strings. + */ +export const promptForArguments = ( + params: Parameter[], + defaultArgs: string[] = [], +): Promise => { + return new Promise((resolve) => { + const collectedArgs: string[] = []; + let currentIndex = 0; + + const promptNext = () => { + if (currentIndex >= params.length) { + resolve(collectedArgs); + return; + } + + const param = params[currentIndex]; + const defaultValue = defaultArgs[currentIndex] || ""; + + _createInputPrompt( + `Enter value for parameter "${param.name}":`, + (userInput) => { + if (userInput !== null) { + collectedArgs.push(userInput); + currentIndex++; + promptNext(); + } else { + resolve(defaultArgs); // User canceled the prompt + } + }, + defaultValue, + isValidAngleExpression, + 'Examples: "2.0 * π" or "π / 2.0"', + ); + }; + + promptNext(); + }); +}; + +/** + * Create a user input prompt element + * @param message - The message to display in the prompt + * @param callback - The callback function to handle the user input + * @param defaultValue - The default value to display in the input element + * @param validateInput - A function to validate the user input + * @param placeholder - The placeholder text for the input element + */ +const _createInputPrompt = ( + message: string, + callback: (input: string | null) => void, + defaultValue: string = "", + validateInput: (input: string) => boolean = () => true, + placeholder: string = "", +) => { + // Create the prompt overlay + const overlay = document.createElement("div"); + overlay.classList.add("prompt-overlay"); + overlay.addEventListener("contextmenu", (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + // Create the prompt container + const promptContainer = document.createElement("div"); + promptContainer.classList.add("prompt-container"); + + // Create the message element + const messageElem = document.createElement("div"); + messageElem.classList.add("prompt-message"); + messageElem.textContent = message; + + // Create the input element + const inputElem = document.createElement("input"); + inputElem.classList.add("prompt-input"); + inputElem.type = "text"; + inputElem.value = defaultValue; + inputElem.placeholder = placeholder; + + // Create the buttons container + const buttonsContainer = document.createElement("div"); + buttonsContainer.classList.add("prompt-buttons"); + + // Create the OK button + const okButton = document.createElement("button"); + okButton.classList.add("prompt-button"); + okButton.textContent = "OK"; + + // Function to validate input and toggle the OK button + const validateAndToggleOkButton = () => { + const processedInput = normalizeAngleExpression(inputElem.value); + const isValid = validateInput(processedInput); + okButton.disabled = !isValid; + }; + + // Add input event listener for validation + inputElem.addEventListener("input", validateAndToggleOkButton); + + // Handle Enter key when input is focused + inputElem.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !okButton.disabled) { + event.preventDefault(); + okButton.click(); + } + }); + + okButton.disabled = !validateInput(normalizeAngleExpression(defaultValue)); + okButton.addEventListener("click", () => { + callback(normalizeAngleExpression(inputElem.value)); + document.body.removeChild(overlay); + document.removeEventListener("keydown", handleGlobalKeyDown, true); + }); + + // Create the π button + const piButton = document.createElement("button"); + piButton.textContent = "π"; + piButton.classList.add("pi-button", "prompt-button"); + piButton.addEventListener("click", () => { + const cursorPosition = inputElem.selectionStart || 0; + const textBefore = inputElem.value.substring(0, cursorPosition); + const textAfter = inputElem.value.substring(cursorPosition); + inputElem.value = `${textBefore}π${textAfter}`; + inputElem.focus(); + inputElem.setSelectionRange(cursorPosition + 1, cursorPosition + 1); // Move cursor after "π" + validateAndToggleOkButton(); + }); + + // Create the Cancel button + const cancelButton = document.createElement("button"); + cancelButton.classList.add("prompt-button"); + cancelButton.textContent = "Cancel"; + cancelButton.addEventListener("click", () => { + callback(null); + document.body.removeChild(overlay); + document.removeEventListener("keydown", handleGlobalKeyDown, true); + }); + + // Handle Escape key globally while prompt is open + const handleGlobalKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + cancelButton.click(); + } + }; + document.addEventListener("keydown", handleGlobalKeyDown, true); + + // Append buttons to the container + buttonsContainer.appendChild(piButton); + buttonsContainer.appendChild(okButton); + buttonsContainer.appendChild(cancelButton); + + // Append elements to the prompt container + promptContainer.appendChild(messageElem); + promptContainer.appendChild(inputElem); + promptContainer.appendChild(buttonsContainer); + + // Append the prompt container to the overlay + overlay.appendChild(promptContainer); + + // Append the overlay to the document body + document.body.appendChild(overlay); + + // Focus the input element + inputElem.focus(); +}; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/shell.ts b/source/npm/qsharp/ux/circuit-vis/editor/shell.ts new file mode 100644 index 00000000000..4357266888e --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/shell.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import type { Circuit } from "../data/circuit.js"; +import { ensureStateVisualization } from "../state-viz/stateVizController.js"; +import type { StateColumn } from "../state-viz/stateViz.js"; +import type { PrepareStateVizOptions } from "../state-viz/worker/stateVizPrep.js"; +import { createToolboxElement } from "./toolbox.js"; + +/** + * Build the editor-mode DOM shell around the rendered circuit: + * + * - Wraps the circuit SVG in `.circuit-wrapper`. + * - Prepends the toolbox panel (with optional Run button). + * - Shows an "empty circuit" hint when the wires group is empty. + * - Tags the container/wrapper with editor layout classes. + * - Mounts the state-visualization panel (when the host supports it). + * + * Idempotent — safe to re-call on every render; pre-existing elements are reused so the SVG element + * identity stays stable. Called only in editor mode, once per `renderCircuit`. + * + * @param container HTML element holding the rendered circuit. + * @param computeStateVizColumnsForCircuitModel Optional state-viz compute callback. When provided, + * enables the state-viz panel. + * @param runCallback Optional Run-button click handler. When omitted, no Run button is + * rendered. + */ +const mountEditorShell = ( + container: HTMLElement, + computeStateVizColumnsForCircuitModel?: ( + model: Circuit, + opts?: PrepareStateVizOptions, + ) => Promise, + runCallback?: () => void, +): void => { + const { wrapper, circuit } = getOrCreateCircuitWrapper(container); + removeEmptyCircuitMessage(wrapper); + attachToolboxPanelIfMissing(container, () => + createToolboxElement(runCallback), + ); + addEmptyCircuitMessageIfEmpty(wrapper, circuit); + applyCircuitEditorLayoutClasses(container, wrapper); + + ensureStateVisualization(container, computeStateVizColumnsForCircuitModel); +}; + +/* ----- private helpers ----- */ + +const getOrCreateCircuitWrapper = (container: HTMLElement) => { + let wrapper: HTMLElement | null = container.querySelector(".circuit-wrapper"); + const circuit = container.querySelector("svg.qviz") as SVGElement | null; + if (circuit == null) { + throw new Error("No circuit found in the container"); + } + if (!wrapper) { + wrapper = document.createElement("div"); + wrapper.className = "circuit-wrapper"; + wrapper.appendChild(circuit); + container.appendChild(wrapper); + } else if (circuit.parentElement !== wrapper) { + wrapper.appendChild(circuit); + } + + return { wrapper, circuit }; +}; + +const attachToolboxPanelIfMissing = ( + container: HTMLElement, + createToolboxFn: () => HTMLElement, +): void => { + if (container.querySelector(".panel") != null) return; + // The toolbox sits inside a `.panel` div, prepended so it lives to the left of the circuit + // wrapper in the editor's flex layout. + const panelElem = document.createElement("div"); + panelElem.className = "panel"; + panelElem.appendChild(createToolboxFn()); + container.prepend(panelElem); +}; + +const removeEmptyCircuitMessage = (wrapper: HTMLElement): void => { + const prevMsg = wrapper.querySelector(".empty-circuit-message"); + if (prevMsg) prevMsg.remove(); +}; + +const addEmptyCircuitMessageIfEmpty = ( + wrapper: HTMLElement, + circuit: SVGElement, +): void => { + const wiresGroup = circuit?.querySelector(".wires"); + if (!wiresGroup || wiresGroup.children.length === 0) { + const emptyMsg = document.createElement("div"); + emptyMsg.className = "empty-circuit-message"; + emptyMsg.textContent = + "Your circuit is empty. Drag gates from the toolbox to get started!"; + wrapper.appendChild(emptyMsg); + } +}; + +const applyCircuitEditorLayoutClasses = ( + container: HTMLElement, + wrapper: HTMLElement, +): void => { + container.classList.add("circuit-editor-container"); + wrapper.classList.add("circuit-wrapper"); +}; + +export { mountEditorShell }; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/standaloneRenderData.ts b/source/npm/qsharp/ux/circuit-vis/editor/standaloneRenderData.ts new file mode 100644 index 00000000000..85af379d513 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/standaloneRenderData.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Operation } from "../data/circuit.js"; +import { gateHeight, minGateWidth } from "../renderer/constants.js"; +import { GateRenderData, GateType } from "../renderer/gateRenderData.js"; +import { getMinGateWidth } from "../renderer/gateWidth.js"; + +/** + * Build a `GateRenderData` for a single operation rendered at (`x`, `y`) without consulting real + * register positions. + * + * The main render path (see [process.ts](../renderer/process.ts)) derives gate geometry from the + * surrounding circuit. The editor needs to render gates outside that context — toolbox icons and + * the drag ghost in [draggable.ts](draggable.ts) — so this helper fakes a single wire centered in + * the gate body. Width still goes through `getMinGateWidth`, so a toolbox icon matches the same + * gate dropped onto the circuit. + * + * Limited gate-kind support — only what the toolbox + drag ghost use. + * + * @param operation Operation to render. `undefined` returns an `Invalid`-typed render data the + * caller can treat as a placeholder. + * @param x x coordinate at the gate's top-left. + * @param y y coordinate at the gate's top-left. + * @returns GateRenderData object. + */ +const toRenderData = ( + operation: Operation | undefined, + x: number, + y: number, +): GateRenderData => { + const target = y + 1 + gateHeight / 2; // offset by 1 for top padding + const renderData: GateRenderData = { + type: GateType.Invalid, + isExpanded: false, + x: x + 1 + minGateWidth / 2, // offset by 1 for left padding + controlsY: [], + targetsY: [target], + label: "", + width: -1, + topPadding: 0, + bottomPadding: 0, + }; + + if (operation === undefined) return renderData; + + switch (operation.kind) { + case "unitary": { + const { gate, controls } = operation; + + if (gate === "SWAP") { + renderData.type = GateType.Swap; + } else if (controls && controls.length > 0) { + renderData.type = + gate === "X" ? GateType.Cnot : GateType.ControlledUnitary; + renderData.label = gate; + if (gate !== "X") { + renderData.targetsY = [[target]]; + } + } else if (gate === "X") { + renderData.type = GateType.X; + renderData.label = gate; + } else { + renderData.type = GateType.Unitary; + renderData.label = gate; + renderData.targetsY = [[target]]; + } + break; + } + case "measurement": + renderData.type = GateType.Measure; + renderData.controlsY = [target]; + break; + case "ket": + renderData.type = GateType.Ket; + renderData.label = operation.gate; + renderData.targetsY = [[target]]; + break; + } + + if (operation.args !== undefined && operation.args.length > 0) + renderData.displayArgs = operation.args[0]; + + renderData.width = getMinGateWidth(renderData); + renderData.x = x + 1 + renderData.width / 2; // offset by 1 for left padding + + return renderData; +}; + +export { toRenderData }; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/toolbox.ts b/source/npm/qsharp/ux/circuit-vis/editor/toolbox.ts new file mode 100644 index 00000000000..300b6e01ac2 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/toolbox.ts @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { + gateHeight, + horizontalGap, + minGateWidth, + verticalGap, +} from "../renderer/constants.js"; +import { formatGate } from "../renderer/formatters/gateFormatter.js"; +import { toRenderData } from "./standaloneRenderData.js"; +import { GateDictionary, toolboxGateDictionary } from "./toolboxGates.js"; + +/** + * Build the toolbox panel: a `
` holding a 2-column grid of gate icons + * plus an optional Run button. + * + * The Run button only renders when `runCallback` is provided — hosts that can't run circuits omit + * it entirely. [shell.ts](shell.ts) wraps the returned element in the outer `
`. + * + * @param runCallback Optional Run-button click handler. + * @returns HTML element for the toolbox. + */ +const createToolboxElement = (runCallback?: () => void): HTMLElement => { + // Generate gate elements in a 2-column grid + let prefixX = 0; + let prefixY = 0; + const gateElems = Object.keys(toolboxGateDictionary).map((key, index) => { + const { width: gateWidth } = toRenderData(toolboxGateDictionary[key], 0, 0); + + // Reset prefixX every 2 gates and start a new row + if (index % 2 === 0 && index !== 0) { + prefixX = 0; + prefixY += gateHeight + verticalGap; + } + + const gateElem = _gate( + toolboxGateDictionary, + key.toString(), + prefixX, + prefixY, + ); + prefixX += gateWidth + horizontalGap; + return gateElem; + }); + + // Generate svg container to store gate elements + const svgElem = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svgElem.classList.add("toolbox-panel-svg"); + _childrenSvg(svgElem, gateElems); + + // Append run button only when the host provided a click handler. Hosts that can't run circuits + // omit the callback and get no button. + let totalSvgHeight: number; + if (runCallback != null) { + const runButtonGroup = _createRunButton( + prefixY + gateHeight + 20, + runCallback, + ); + svgElem.appendChild(runButtonGroup); + totalSvgHeight = prefixY + 2 * gateHeight + 32; // gates + button + padding + } else { + totalSvgHeight = prefixY + gateHeight + 16; // gates + padding (no button) + } + + // Size SVG to content height so the toolbox panel can scroll when window is short + svgElem.setAttribute("height", totalSvgHeight.toString()); + svgElem.setAttribute("width", "100%"); + + // Generate toolbox panel + const toolboxElem = _elem("div", "toolbox-panel"); + _children(toolboxElem, [_title("Toolbox")]); + toolboxElem.appendChild(svgElem); + + return toolboxElem; +}; + +/** + * Build the Run button. Created visible and pre-wired — callers only get this far if they actually + * want a Run button. + * + * @param buttonY Y coordinate for the top of the button. + * @param onClick Click handler. + * @returns SVG group element containing the run button. + */ +const _createRunButton = ( + buttonY: number, + onClick: () => void, +): SVGGElement => { + const buttonWidth = minGateWidth * 2 + horizontalGap; + const buttonHeight = gateHeight; + const buttonX = 1; + + const runButtonGroup = document.createElementNS( + "http://www.w3.org/2000/svg", + "g", + ); + runButtonGroup.setAttribute("class", "svg-run-button"); + runButtonGroup.setAttribute("tabindex", "0"); + runButtonGroup.setAttribute("role", "button"); + + // Rectangle background + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", buttonX.toString()); + rect.setAttribute("y", buttonY.toString()); + rect.setAttribute("width", buttonWidth.toString()); + rect.setAttribute("height", buttonHeight.toString()); + rect.setAttribute("class", "svg-run-button-rect"); + + // Text label + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("x", (buttonX + buttonWidth / 2).toString()); + text.setAttribute("y", (buttonY + buttonHeight / 2).toString()); + text.setAttribute("class", "svg-run-button-text"); + text.textContent = "Run"; + + runButtonGroup.appendChild(rect); + runButtonGroup.appendChild(text); + + runButtonGroup.addEventListener("click", onClick); + return runButtonGroup; +}; + +/** + * Build a single toolbox gate icon by routing the toolbox's prototype `Operation` through the same + * gate formatter the main render path uses, keeping toolbox icons in lockstep with dropped gates. + * + * @param gateDictionary - The dictionary containing gate operations. + * @param type - The toolbox key. Example: `"H"` or `"X"`. + * @param x - The x coordinate at the starting point from the left. + * @param y - The y coordinate at the starting point from the top. + * @returns The generated SVG element representing the gate. + * @throws Will throw an error if the gate type is not available in the dictionary. + */ +const _gate = ( + gateDictionary: GateDictionary, + type: string, + x: number, + y: number, +): SVGElement => { + const gate = gateDictionary[type]; + if (gate == null) throw new Error(`Gate ${type} not available`); + const renderData = toRenderData(gate, x, y); + renderData.dataAttributes = { type: type }; + const gateElem = formatGate(renderData).cloneNode(true) as SVGElement; + gateElem.setAttribute("toolbox-item", "true"); + + return gateElem; +}; + +/* ----- small DOM helpers, private to this file ----- */ + +const _elem = (tag: string, className?: string): HTMLElement => { + const elem = document.createElement(tag); + if (className) { + elem.className = className; + } + return elem; +}; + +const _children = ( + parentElem: HTMLElement, + childElems: HTMLElement[], +): HTMLElement => { + childElems.map((elem) => parentElem.appendChild(elem)); + return parentElem; +}; + +const _childrenSvg = ( + parentElem: SVGElement, + childElems: SVGElement[], +): SVGElement => { + childElems.map((elem) => parentElem.appendChild(elem)); + return parentElem; +}; + +const _title = (text: string): HTMLElement => { + const titleElem = _elem("h2"); + titleElem.className = "title"; + titleElem.textContent = text; + return titleElem; +}; + +export { createToolboxElement }; diff --git a/source/npm/qsharp/ux/circuit-vis/editor/toolboxGates.ts b/source/npm/qsharp/ux/circuit-vis/editor/toolboxGates.ts new file mode 100644 index 00000000000..ed62bc6bc49 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/editor/toolboxGates.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { Ket, Measurement, Operation, Unitary } from "../data/circuit.js"; + +/** + * Maps a toolbox key (e.g. `"RX"`, `"Reset"`) to the prototype `Operation` that gets dropped into + * the circuit when the user drags that toolbox item. Keys are also used as `data-type` attributes + * on the rendered toolbox SVG nodes. + */ +interface GateDictionary { + [index: string]: Operation; +} + +const _makeUnitary = (gate: string): Unitary => ({ + kind: "unitary", + gate, + targets: [], +}); + +const _makeMeasurement = (gate: string): Measurement => ({ + kind: "measurement", + gate, + qubits: [], + results: [], +}); + +const _makeKet = (gate: string): Ket => ({ + kind: "ket", + gate, + targets: [], +}); + +/** + * The default toolbox gate set. Order here is the order the gates appear in the toolbox grid + * (left-to-right, top-to-bottom, 2 columns). + * + * To add a new toolbox gate: pick a stable key (used as `data-type` on the SVG node and as the + * lookup key in `dragController.ts`), map it to a prototype `Operation`, and both the toolbox view + * and the drag handlers will pick it up automatically. + */ +const toolboxGateDictionary: GateDictionary = { + RX: _makeUnitary("Rx"), + X: _makeUnitary("X"), + RY: _makeUnitary("Ry"), + Y: _makeUnitary("Y"), + RZ: _makeUnitary("Rz"), + Z: _makeUnitary("Z"), + S: _makeUnitary("S"), + T: _makeUnitary("T"), + H: _makeUnitary("H"), + SX: _makeUnitary("SX"), + Reset: _makeKet("0"), + Measure: _makeMeasurement("Measure"), +}; + +toolboxGateDictionary["RX"].params = [{ name: "theta", type: "Double" }]; +toolboxGateDictionary["RY"].params = [{ name: "theta", type: "Double" }]; +toolboxGateDictionary["RZ"].params = [{ name: "theta", type: "Double" }]; + +export { toolboxGateDictionary, type GateDictionary }; diff --git a/source/npm/qsharp/ux/circuit-vis/events.ts b/source/npm/qsharp/ux/circuit-vis/events.ts deleted file mode 100644 index 0f6bd45ed7b..00000000000 --- a/source/npm/qsharp/ux/circuit-vis/events.ts +++ /dev/null @@ -1,1095 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { - Circuit, - ComponentGrid, - Operation, - Qubit, - Unitary, -} from "./circuit.js"; -import { Sqore } from "./sqore.js"; -import { toolboxGateDictionary } from "./panel.js"; -import { - getGateLocationString, - findOperation, - getToolboxElems, - getGateElems, - getHostElems, - getWireData, - locationStringToIndexes, - findParentArray, - deepEqual, - getQubitLabelElems, - getMinMaxRegIdx, -} from "./utils.js"; -import { addContextMenuToHostElem, promptForArguments } from "./contextMenu.js"; -import { - removeTrailingUnusedQubits, - addControl, - addOperation, - findAndRemoveOperations, - moveOperation, - removeControl, - removeOperation, - resolveOverlappingOperations, -} from "./circuitManipulation.js"; -import { - createGateGhost, - createQubitLabelGhost, - createWireDropzone, - getColumnOffsetsAndWidths, - makeDropzoneBox, - removeAllWireDropzones, -} from "./draggable.js"; -import { getOperationRegisters } from "../../src/utils.js"; - -let events: CircuitEvents | null = null; -let currentCircuitSvg: SVGElement | null = null; - -/** - * Creates and attaches the events that allow editing of the circuit. - * - * @param container HTML element for rendering visualization into - * @param sqore Sqore object - */ -const enableEvents = ( - container: HTMLElement, - sqore: Sqore, - useRefresh: () => void, -): void => { - if (events != null) { - events.dispose(); - } - events = new CircuitEvents(container, sqore, useRefresh); - - // Track which rendered SVG the current `events` instance is associated with. - // This lets other modules avoid reading a stale model during a re-render where - // the SVG has been replaced but `enableEvents` hasn't run yet. - currentCircuitSvg = container.querySelector("svg.qviz") as SVGElement | null; - - // Signal that the circuit model (events + model snapshot) is now ready. - // The state visualization uses this to re-render without relying on polling. - try { - const CustomEventCtor = (globalThis as any).CustomEvent as - | (new (type: string, init?: CustomEventInit) => CustomEvent) - | undefined; - if (CustomEventCtor && typeof container.dispatchEvent === "function") { - container.dispatchEvent( - new CustomEventCtor("qsharp:circuit:modelReady", { bubbles: true }), - ); - } - } catch { - // ignore - } -}; - -class CircuitEvents { - renderFn: () => void; - componentGrid: ComponentGrid; - qubits: Qubit[]; - qubitUseCounts: number[]; - private circuitSvg: SVGElement; - private dropzoneLayer: SVGGElement; - private ghostQubitLayer: SVGGElement; - private temporaryDropzones: SVGElement[] = []; - private wireData: number[]; - private columnXData: { xOffset: number; colWidth: number }[]; - private selectedOperation: Operation | null = null; - private selectedWire: number | null = null; - private movingControl: boolean = false; - private mouseUpOnCircuit: boolean = false; - private dragging: boolean = false; - private disableLeftAutoScroll: boolean = false; - - constructor( - private container: HTMLElement, - sqore: Sqore, - useRefresh: () => void, - ) { - this.renderFn = useRefresh; - - this.circuitSvg = container.querySelector("svg.qviz") as SVGElement; - this.dropzoneLayer = container.querySelector( - ".dropzone-layer", - ) as SVGGElement; - this.ghostQubitLayer = container.querySelector( - ".ghost-qubit-layer", - ) as SVGGElement; - - this.componentGrid = sqore.circuit.componentGrid; - this.qubits = sqore.circuit.qubits; - this.qubitUseCounts = new Array(this.qubits.length).fill(0); - for (const column of this.componentGrid) { - for (const op of column.components) { - this.incrementQubitUseCountForOp(op); - } - } - - this.wireData = getWireData(this.container); - this.columnXData = getColumnOffsetsAndWidths(this.container); - removeTrailingUnusedQubits(this); - - if (this.qubits.length === 0) { - this.ghostQubitLayer.style.display = "block"; - } - - this._addContextMenuEvent(); - this._addDropzoneLayerEvents(); - this._addHostElementsEvents(); - this._addGateElementsEvents(); - this._addToolboxElementsEvents(); - this._addDropzoneElementsEvents(); - this._addQubitLineEvents(); - this._addDocumentEvents(); - } - - /** - * Dispose the CircuitEvents instance and remove event listeners - */ - dispose() { - this._removeToolboxElementsEvents(); - this._removeDocumentEvents(); - } - - /*************************** - * Events Adding Functions * - ***************************/ - - documentKeydownHandler = (ev: KeyboardEvent) => { - const selectedLocation = this.selectedOperation - ? getGateLocationString(this.selectedOperation) - : null; - if (ev.ctrlKey && selectedLocation) { - this.container.classList.remove("moving"); - this.container.classList.add("copying"); - } - }; - - documentKeyupHandler = (ev: KeyboardEvent) => { - const selectedLocation = this.selectedOperation - ? getGateLocationString(this.selectedOperation) - : null; - if (ev.ctrlKey && selectedLocation) { - this.container.classList.remove("copying"); - this.container.classList.add("moving"); - } - }; - - documentMousedownHandler = () => { - removeAllWireDropzones(this.circuitSvg); - }; - - documentMouseupHandler = (ev: MouseEvent) => { - const copying = ev.ctrlKey; - this.container.classList.remove("moving", "copying"); - // Handle deleting operations that have been dragged outside the circuit - if (!this.mouseUpOnCircuit && this.dragging && !copying) { - const selectedLocation = this.selectedOperation - ? getGateLocationString(this.selectedOperation) - : null; - if (this.selectedOperation != null && selectedLocation != null) { - // We are dragging a gate with a location (not from toolbox) outside the circuit - // If we are moving a control, remove it from the selectedOperation - if ( - this.movingControl && - this.selectedOperation.kind === "unitary" && - this.selectedOperation.controls != null && - this.selectedWire != null - ) { - removeControl(this, this.selectedOperation, this.selectedWire); - } else { - // Otherwise, remove the selectedOperation - removeOperation(this, selectedLocation); - } - this.renderFn(); - } else if (this.selectedWire != null) { - // If we are dragging a qubit line, remove it - this.removeQubitLineWithConfirmation(this.selectedWire); - } - } - - this._resetState(); - }; - - /** - * Resets the internal state of the CircuitEvents instance after a drag or interaction. - * Clears selection, flags, and removes any temporary dropzones from the DOM. - * Note that this does not clear the selectedOperation as that is needed to be persistent - * for context-menu selections. - */ - _resetState() { - this.selectedWire = null; - this.movingControl = false; - this.mouseUpOnCircuit = false; - this.dragging = false; - this.disableLeftAutoScroll = false; - - for (const dropzone of this.temporaryDropzones) { - if (dropzone.parentNode) { - dropzone.parentNode.removeChild(dropzone); - } - } - this.temporaryDropzones = []; - } - - /** - * Enable auto-scrolling when dragging near the edges of the container - */ - _enableAutoScroll() { - const scrollSpeed = 10; // Pixels per frame - const edgeThreshold = 50; // Distance from the edge to trigger scrolling - - // Utility function to find the nearest scrollable ancestor - const getScrollableAncestor = (element: Element): HTMLElement => { - let currentElement: Element | null = element; - while (currentElement) { - const overflowY = window.getComputedStyle(currentElement).overflowY; - const overflowX = window.getComputedStyle(currentElement).overflowX; - if ( - overflowY === "auto" || - overflowY === "scroll" || - overflowX === "auto" || - overflowX === "scroll" - ) { - return currentElement as HTMLElement; - } - currentElement = currentElement.parentElement; - } - return document.documentElement; // Fallback to the root element - }; - - const scrollableAncestor = getScrollableAncestor(this.circuitSvg); - - const onMouseMove = (ev: MouseEvent) => { - const rect = scrollableAncestor.getBoundingClientRect(); - - const topBoundary = rect.top; - const bottomBoundary = rect.bottom; - const leftBoundary = rect.left; - const rightBoundary = rect.right; - - // If the mouse has moved past the left boundary, we want to re-enable left auto-scrolling - if ( - this.disableLeftAutoScroll && - ev.clientX > leftBoundary + 3 * edgeThreshold - ) { - this.disableLeftAutoScroll = false; - } - - // Check if the cursor is near the edges - if (ev.clientY < topBoundary + edgeThreshold) { - // Scroll up - scrollableAncestor.scrollTop -= scrollSpeed; - } else if (ev.clientY > bottomBoundary - edgeThreshold) { - // Scroll down - scrollableAncestor.scrollTop += scrollSpeed; - } - - if ( - !this.disableLeftAutoScroll && - ev.clientX < leftBoundary + edgeThreshold - ) { - // Scroll left - scrollableAncestor.scrollLeft -= scrollSpeed; - } else if (ev.clientX > rightBoundary - edgeThreshold) { - // Scroll right - scrollableAncestor.scrollLeft += scrollSpeed; - } - }; - - const onMouseUp = () => { - // Remove the mousemove listener when dragging stops - document.removeEventListener("mousemove", onMouseMove); - document.removeEventListener("mouseup", onMouseUp); - }; - - // Add the mousemove listener when dragging starts - document.addEventListener("mousemove", onMouseMove); - document.addEventListener("mouseup", onMouseUp); - } - - /** - * Add events for document - */ - _addDocumentEvents() { - document.addEventListener("keydown", this.documentKeydownHandler); - document.addEventListener("keyup", this.documentKeyupHandler); - document.addEventListener("mouseup", this.documentMouseupHandler); - document.addEventListener("mousedown", this.documentMousedownHandler); - } - - /** - * Remove events for document - */ - _removeDocumentEvents() { - document.removeEventListener("keydown", this.documentKeydownHandler); - document.removeEventListener("keyup", this.documentKeyupHandler); - document.removeEventListener("mouseup", this.documentMouseupHandler); - document.removeEventListener("mousedown", this.documentMousedownHandler); - } - - /** - * Add events specifically for dropzoneLayer - */ - _addDropzoneLayerEvents() { - this.container.addEventListener("mouseup", () => { - if (this.qubits.length !== 0) { - this.ghostQubitLayer.style.display = "none"; - } - this.dropzoneLayer.style.display = "none"; - }); - - this.circuitSvg.addEventListener("mouseup", () => { - this.mouseUpOnCircuit = true; - }); - } - - /** - * Disable contextmenu default behaviors - */ - _addContextMenuEvent() { - this.container.addEventListener("contextmenu", (ev: MouseEvent) => { - ev.preventDefault(); - }); - } - - /** - * Add events for circuit objects in the circuit - */ - _addHostElementsEvents() { - const elems = getHostElems(this.container); - elems.forEach((elem) => { - elem.addEventListener("mousedown", (ev: MouseEvent) => { - if (ev.button !== 0) return; - if (elem.classList.contains("control-dot")) { - this.movingControl = true; - } - const selectedWireStr = elem.getAttribute("data-wire"); - this.selectedWire = - selectedWireStr != null ? parseInt(selectedWireStr) : null; - }); - - addContextMenuToHostElem(this, elem); - }); - } - - /** - * Add events for circuit objects in the circuit - */ - _addGateElementsEvents() { - const elems = getGateElems(this.container); - elems.forEach((elem) => { - elem?.addEventListener("mousedown", (ev: MouseEvent) => { - // Allow dragging even when initiated on the arg-button - const argButtonElem = (ev.target as HTMLElement).closest(".arg-button"); - if (argButtonElem) { - // Find the sibling element with the data-wire attribute - const siblingWithWire = - argButtonElem.parentElement?.querySelector("[data-wire]"); - if (siblingWithWire) { - const selectedWireStr = siblingWithWire.getAttribute("data-wire"); - this.selectedWire = - selectedWireStr != null ? parseInt(selectedWireStr) : null; - } - } - - let selectedLocation = null; - if (elem.getAttribute("data-expanded") !== "true") { - selectedLocation = elem.getAttribute("data-location"); - this.selectedOperation = findOperation( - this.componentGrid, - selectedLocation, - ); - } - if (ev.button !== 0) return; - ev.stopPropagation(); - removeAllWireDropzones(this.circuitSvg); - if ( - this.selectedOperation === null || - this.selectedWire === null || - !selectedLocation - ) - return; - - // Add temporary dropzones specific to this operation - const [minTarget, maxTarget] = getMinMaxRegIdx(this.selectedOperation); - for (let wire = minTarget; wire <= maxTarget; wire++) { - if (wire === this.selectedWire) continue; - const indexes = locationStringToIndexes(selectedLocation); - const [colIndex, opIndex] = indexes[indexes.length - 1]; - const dropzone = makeDropzoneBox( - colIndex, - opIndex, - this.columnXData, - this.wireData, - wire, - false, - ); - dropzone.addEventListener("mouseup", this.dropzoneMouseupHandler); - this.temporaryDropzones.push(dropzone); - this.dropzoneLayer.appendChild(dropzone); - } - - this._createGhostElement(ev); - - // Make sure the selectedOperation has location data - if (this.selectedOperation.dataAttributes == null) { - this.selectedOperation.dataAttributes = { - location: selectedLocation, - }; - } else { - this.selectedOperation.dataAttributes["location"] = selectedLocation; - } - - this.container.classList.add("moving"); - this.ghostQubitLayer.style.display = "block"; - this.dropzoneLayer.style.display = "block"; - }); - - // Enable arg-button behavior - const argButtons = elem.querySelectorAll(".arg-button"); - argButtons.forEach((argButton) => { - argButton.classList.add("edit-mode"); - - // Add click event to trigger promptForArguments - argButton.addEventListener("click", async () => { - if (this.selectedOperation == null) return; - const params = this.selectedOperation.params; - const displayArgs = argButton.textContent || ""; - if (params) { - const args = await promptForArguments(params, [displayArgs]); - if (args.length > 0) { - this.selectedOperation.args = args; - this.renderFn(); - } - } - }); - }); - }); - } - - toolboxMousedownHandler = (ev: MouseEvent) => { - if (ev.button !== 0) return; - this.container.classList.add("moving"); - this.ghostQubitLayer.style.display = "block"; - this.dropzoneLayer.style.display = "block"; - const elem = ev.currentTarget as HTMLElement; - const type = elem.getAttribute("data-type"); - if (type == null) return; - this.selectedOperation = toolboxGateDictionary[type]; - this.disableLeftAutoScroll = true; - this._createGhostElement(ev); - }; - - /** - * Add events for gates in the toolbox - */ - _addToolboxElementsEvents() { - const elems = getToolboxElems(this.container); - elems.forEach((elem) => { - elem.addEventListener("mousedown", this.toolboxMousedownHandler); - }); - } - - /** - * Remove events for gates in the toolbox - */ - _removeToolboxElementsEvents() { - const elems = getToolboxElems(this.container); - elems.forEach((elem) => { - elem.removeEventListener("mousedown", this.toolboxMousedownHandler); - }); - } - - dropzoneMouseupHandler = async (ev: MouseEvent) => { - const dropzoneElem = ev.currentTarget as SVGRectElement; - const copying = ev.ctrlKey; - // Create a deep copy of the component grid - const originalGrid = JSON.parse( - JSON.stringify(this.componentGrid), - ) as ComponentGrid; - const targetLoc = dropzoneElem.getAttribute("data-dropzone-location"); - const insertNewColumn = - dropzoneElem.getAttribute("data-dropzone-inter-column") == "true" || - false; - const targetWireStr = dropzoneElem.getAttribute("data-dropzone-wire"); - const targetWire = targetWireStr != null ? parseInt(targetWireStr) : null; - - if ( - targetLoc == null || - targetWire == null || - this.selectedOperation == null - ) - return; - const sourceLocation = getGateLocationString(this.selectedOperation); - - if (sourceLocation == null) { - if ( - this.selectedOperation.params != undefined && - (this.selectedOperation.args === undefined || - this.selectedOperation.args.length === 0) - ) { - // Prompt for arguments and wait for user input - const args = await promptForArguments(this.selectedOperation.params); - if (!args || args.length === 0) { - // User canceled the prompt, exit early - return; - } - - // Create a deep copy of the source operation - this.selectedOperation = JSON.parse( - JSON.stringify(this.selectedOperation), - ); - if (this.selectedOperation == null) return; - - // Assign the arguments to the selected operation - this.selectedOperation.args = args; - } - - // Add a new operation from the toolbox - addOperation( - this, - this.selectedOperation, - targetLoc, - targetWire, - insertNewColumn, - ); - } else if (sourceLocation && this.selectedWire != null) { - if (copying) { - if (this.movingControl && this.selectedOperation.kind === "unitary") { - addControl(this, this.selectedOperation, targetWire); - moveOperation( - this, - sourceLocation, - targetLoc, - this.selectedWire, - targetWire, - this.movingControl, - insertNewColumn, - ); - } else { - addOperation( - this, - this.selectedOperation, - targetLoc, - targetWire, - insertNewColumn, - ); - } - } else { - moveOperation( - this, - sourceLocation, - targetLoc, - this.selectedWire, - targetWire, - this.movingControl, - insertNewColumn, - ); - } - } - - this.selectedOperation = null; - this._resetState(); - - if (!deepEqual(originalGrid, this.componentGrid)) this.renderFn(); - }; - - /** - * Add events for dropzone elements - */ - _addDropzoneElementsEvents() { - const dropzoneElems = - this.dropzoneLayer.querySelectorAll(".dropzone"); - dropzoneElems.forEach((dropzoneElem) => { - dropzoneElem.addEventListener("mouseup", this.dropzoneMouseupHandler); - }); - } - - /** - * Handler for mouseup on qubit line dropzones. - * @param sourceWire The index of the source wire (being dragged). - * @param targetWire The index of the target wire (drop location). - * @param isBetween If true, creates a dropzone between wires. - */ - qubitDropzoneMouseupHandler = ( - sourceWire: number, - targetWire: number, - isBetween: boolean, - ) => { - if (sourceWire === targetWire || sourceWire == null || targetWire == null) - return; - - // Helper to move an array element - function moveArrayElement(arr: T[], from: number, to: number) { - const el = arr.splice(from, 1)[0]; - arr.splice(to, 0, el); - } - - // Update qubits array - if (isBetween) { - // Moving sourceWire to just before targetWire - let insertAt = targetWire; - // If moving down and passing over itself, adjust index - if (sourceWire < insertAt) insertAt--; - moveArrayElement(this.qubits, sourceWire, insertAt); - moveArrayElement(this.qubitUseCounts, sourceWire, insertAt); - } else { - // Swap sourceWire and targetWire - [this.qubits[sourceWire], this.qubits[targetWire]] = [ - this.qubits[targetWire], - this.qubits[sourceWire], - ]; - [this.qubitUseCounts[sourceWire], this.qubitUseCounts[targetWire]] = [ - this.qubitUseCounts[targetWire], - this.qubitUseCounts[sourceWire], - ]; - } - - // Update qubit ids to match their new positions - this.qubits.forEach((q, idx) => { - q.id = idx; - }); - - // Update all operations in componentGrid to reflect new qubit order - for (const column of this.componentGrid) { - for (const op of column.components) { - getOperationRegisters(op).forEach((reg) => { - if (isBetween) { - // Move: update qubit indices - if (reg.qubit === sourceWire) { - reg.qubit = sourceWire < targetWire ? targetWire - 1 : targetWire; - } else if ( - sourceWire < targetWire && - reg.qubit > sourceWire && - reg.qubit < targetWire - ) { - reg.qubit -= 1; - } else if ( - sourceWire > targetWire && - reg.qubit >= targetWire && - reg.qubit < sourceWire - ) { - reg.qubit += 1; - } - } else { - // Swap: swap indices - if (reg.qubit === sourceWire) reg.qubit = targetWire; - else if (reg.qubit === targetWire) reg.qubit = sourceWire; - } - }); - } - // Sort operations in this column by their lowest-numbered register - column.components.sort((a, b) => { - const aRegs = getOperationRegisters(a); - const bRegs = getOperationRegisters(b); - const aMin = Math.min(...aRegs.map((r) => r.qubit)); - const bMin = Math.min(...bRegs.map((r) => r.qubit)); - return aMin - bMin; - }); - } - - // Resolve overlapping operations into their own columns - resolveOverlappingOperations(this.componentGrid); - removeTrailingUnusedQubits(this); - this.renderFn(); - }; - - /** - * Add events for qubit line labels - */ - _addQubitLineEvents() { - const elems = getQubitLabelElems(this.container); - elems.forEach((elem) => { - elem.addEventListener("mousedown", (ev: MouseEvent) => { - ev.stopPropagation(); - this.selectedOperation = null; - this._createQubitLabelGhost(ev, elem); - - const sourceIndexStr = elem.getAttribute("data-wire"); - const sourceWire = - sourceIndexStr != null ? parseInt(sourceIndexStr) : null; - if (sourceWire == null) return; - this.selectedWire = sourceWire; - - // Dropzones ON each wire (skip self) - // Exclude ghost qubit line (last wire) - for ( - let targetWire = 0; - targetWire < this.wireData.length - 1; // Exclude ghost - targetWire++ - ) { - if (targetWire === sourceWire) continue; - const dropzone = createWireDropzone( - this.circuitSvg, - this.wireData, - targetWire, - ); - dropzone.addEventListener("mouseup", () => - this.qubitDropzoneMouseupHandler(sourceWire, targetWire, false), - ); - this.temporaryDropzones.push(dropzone); - this.circuitSvg.appendChild(dropzone); - } - - // Dropzones BETWEEN wires (including before first and after last) - // Exclude after ghost qubit line - for (let i = 0; i <= this.wireData.length - 1; i++) { - // Optionally, skip if dropping "between" at the source wire's own position - if (i === sourceWire || i === sourceWire + 1) continue; - const dropzone = createWireDropzone( - this.circuitSvg, - this.wireData, - i, - true, - ); - dropzone.addEventListener("mouseup", () => - this.qubitDropzoneMouseupHandler(sourceWire, i, true), - ); - this.temporaryDropzones.push(dropzone); - this.circuitSvg.appendChild(dropzone); - } - }); - elem.style.pointerEvents = "all"; - }); - } - - /** - * Removes a qubit line by index, with confirmation if it has associated operations. - * @param qubitIdx The index of the qubit to remove. - */ - removeQubitLineWithConfirmation(qubitIdx: number) { - // Count number of operations associated with the qubit line - const numOperations = this.qubitUseCounts[qubitIdx]; - - const doRemove = () => { - // Remove the qubit - this.qubits.splice(qubitIdx, 1); - this.qubitUseCounts.splice(qubitIdx, 1); - this.wireData.splice(qubitIdx, 1); - removeTrailingUnusedQubits(this); - - // Update all remaining operation references - for (const column of this.componentGrid) { - for (const op of column.components) { - getOperationRegisters(op).forEach((reg) => { - if (reg.qubit > qubitIdx) reg.qubit -= 1; - }); - } - } - - // Update qubit ids to match their new positions - this.qubits.forEach((q, idx) => { - q.id = idx; - }); - - this.renderFn(); - }; - - if (numOperations === 0) { - doRemove(); - } else { - const message = - numOperations === 1 - ? `There is 1 operation associated with this qubit line. Do you want to remove it?` - : `There are ${numOperations} operations associated with this qubit line. Do you want to remove them?`; - _createConfirmPrompt(message, (confirmed) => { - if (confirmed) { - findAndRemoveOperations(this, (op: Operation) => - getOperationRegisters(op).some((reg) => reg.qubit == qubitIdx), - ); - doRemove(); - } - }); - } - } - - /***************** - * Misc. * - *****************/ - - /** - * Increment the use count for qubits used by the operation. - * @param op The operation to increment use counts for. - */ - incrementQubitUseCountForOp(op: Operation) { - getOperationRegisters(op).forEach((reg) => { - if ( - reg.result === undefined && - reg.qubit >= 0 && - reg.qubit < this.qubitUseCounts.length - ) { - this.qubitUseCounts[reg.qubit]++; - } - }); - } - - /** - * Decrement the use count for qubits used by the operation. - * @param op The operation to decrement the use count for. - */ - decrementQubitUseCountForOp(op: Operation) { - getOperationRegisters(op).forEach((reg) => { - if ( - reg.result === undefined && - reg.qubit >= 0 && - reg.qubit < this.qubitUseCounts.length - ) { - this.qubitUseCounts[reg.qubit]--; - } - }); - } - - /** - * Creates a ghost element for visual feedback during dragging. - * This function initializes the dragging state, enables auto-scrolling, - * and creates a visual representation of the selected operation. - * - * @param ev - The mouse event that triggered the creation of the ghost element. - */ - _createGhostElement(ev: MouseEvent) { - if (this.selectedOperation == null) return; - this.dragging = true; - this._enableAutoScroll(); - createGateGhost( - ev, - this.container, - this.selectedOperation, - this.movingControl, - ); - } - - _createQubitLabelGhost(ev: MouseEvent, elem: SVGTextElement) { - this.dragging = true; - this._enableAutoScroll(); - createQubitLabelGhost(ev, this.container, elem); - } - - /** - * Start the process of adding a control to the selected operation. - * This function creates dropzones for each wire that isn't already a target or control. - * - * @param selectedOperation - The unitary operation to which the control will be added. - * @param selectedLocation - The location string of the selected operation. - */ - _startAddingControl(selectedOperation: Unitary, selectedLocation: string) { - this.selectedOperation = selectedOperation; - this.container.classList.add("adding-control"); - this.ghostQubitLayer.style.display = "block"; - - // Create dropzones for each wire that isn't already a target or control - for (let wireIndex = 0; wireIndex < this.wireData.length; wireIndex++) { - const isTarget = this.selectedOperation?.targets.some( - (target) => target.qubit === wireIndex, - ); - const isControl = this.selectedOperation?.controls?.some( - (control) => control.qubit === wireIndex, - ); - - if (!isTarget && !isControl) { - const dropzone = createWireDropzone( - this.circuitSvg, - this.wireData, - wireIndex, - ); - dropzone.addEventListener("mousedown", (ev: MouseEvent) => - ev.stopPropagation(), - ); - dropzone.addEventListener("click", () => { - if ( - this.selectedOperation != null && - this.selectedOperation.kind === "unitary" - ) { - const successful = addControl( - this, - this.selectedOperation, - wireIndex, - ); - this.selectedOperation = null; - this.container.classList.remove("adding-control"); - this.ghostQubitLayer.style.display = "none"; - if (successful) { - const indexes = locationStringToIndexes(selectedLocation); - const [columnIndex, position] = indexes[indexes.length - 1]; - const selectedOperationParent = findParentArray( - this.componentGrid, - selectedLocation, - ); - if (!selectedOperationParent) return; - - const [minTarget, maxTarget] = getMinMaxRegIdx(selectedOperation); - selectedOperationParent[columnIndex].components.forEach( - (op, opIndex) => { - if (opIndex === position) return; // Don't check the selected operation against itself - const [minOp, maxOp] = getMinMaxRegIdx(op); - // Check if selectedOperation's range overlaps with op's range - if ( - (minOp >= minTarget && minOp <= maxTarget) || - (maxOp >= minTarget && maxOp <= maxTarget) || - (minTarget >= minOp && minTarget <= minOp) || - (maxTarget >= maxOp && maxTarget <= maxOp) - ) { - // If they overlap, move the operation - selectedOperationParent[columnIndex].components.splice( - position, - 1, - ); - // Not sure if this check is needed as we already know there - // should be other operations in this column. - if ( - selectedOperationParent[columnIndex].components.length === - 0 - ) { - selectedOperationParent.splice(columnIndex, 1); - } - - selectedOperationParent.splice(columnIndex, 0, { - components: [selectedOperation], - }); - } - }, - ); - - this.renderFn(); - } - } - }); - this.circuitSvg.appendChild(dropzone); - } - } - } - - /** - * Start the process of removing a control from the selected operation. - * This function creates dropzones only for wires that the selected operation has a control. - * - * @param selectedOperation - The unitary operation from which the control will be removed. - */ - _startRemovingControl(selectedOperation: Unitary) { - this.selectedOperation = selectedOperation; - this.container.classList.add("removing-control"); - - // Create dropzones only for wires that the selectedOperation has a control - this.selectedOperation.controls?.forEach((control) => { - const dropzone = createWireDropzone( - this.circuitSvg, - this.wireData, - control.qubit, - ); - dropzone.addEventListener("mousedown", (ev: MouseEvent) => - ev.stopPropagation(), - ); - dropzone.addEventListener("click", () => { - if ( - this.selectedOperation != null && - this.selectedOperation.kind === "unitary" - ) { - const successful = removeControl( - this, - this.selectedOperation, - control.qubit, - ); - this.selectedOperation = null; - this.container.classList.remove("removing-control"); - if (successful) { - this.renderFn(); - } - } - }); - this.circuitSvg.appendChild(dropzone); - }); - } -} - -/** - * Create a confirm dialog box - * @param message - The message to display in the confirm dialog - * @param callback - The callback function to handle the user's response (true for OK, false for Cancel) - */ -const _createConfirmPrompt = ( - message: string, - callback: (confirmed: boolean) => void, -) => { - // Create the confirm overlay - const overlay = document.createElement("div"); - overlay.classList.add("prompt-overlay"); - overlay.addEventListener("contextmenu", (e) => { - e.preventDefault(); - e.stopPropagation(); - }); - - // Create the confirm container - const confirmContainer = document.createElement("div"); - confirmContainer.classList.add("prompt-container"); - - // Create the message element - const messageElem = document.createElement("div"); - messageElem.classList.add("prompt-message"); - messageElem.textContent = message; - - // Create the buttons container - const buttonsContainer = document.createElement("div"); - buttonsContainer.classList.add("prompt-buttons"); - - // Create the OK button - const okButton = document.createElement("button"); - okButton.classList.add("prompt-button"); - okButton.textContent = "OK"; - okButton.addEventListener("click", () => { - callback(true); // User confirmed - document.body.removeChild(overlay); - document.removeEventListener("keydown", handleGlobalKeyDown, true); - }); - - // Create the Cancel button - const cancelButton = document.createElement("button"); - cancelButton.classList.add("prompt-button"); - cancelButton.textContent = "Cancel"; - cancelButton.addEventListener("click", () => { - callback(false); // User canceled - document.body.removeChild(overlay); - document.removeEventListener("keydown", handleGlobalKeyDown, true); - }); - - // Handle Enter and Escape keys globally while prompt is open - const handleGlobalKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - okButton.click(); - } else if (event.key === "Escape") { - event.preventDefault(); - cancelButton.click(); - } - }; - document.addEventListener("keydown", handleGlobalKeyDown, true); - - buttonsContainer.appendChild(okButton); - buttonsContainer.appendChild(cancelButton); - - confirmContainer.appendChild(messageElem); - confirmContainer.appendChild(buttonsContainer); - - overlay.appendChild(confirmContainer); - document.body.appendChild(overlay); - - // Remove focus from any currently focused element - if (document.activeElement) { - (document.activeElement as HTMLElement).blur(); - } -}; - -export { enableEvents, CircuitEvents }; - -// Provide access to the current circuit model, but only if it matches the -// currently-rendered SVG element. This prevents state visualization from -// computing against the previous render's model during a re-render. -export function getCurrentCircuitModel( - expectedSvg?: SVGElement | null, -): Circuit | null { - if (events == null) return null; - if (expectedSvg && currentCircuitSvg && expectedSvg !== currentCircuitSvg) { - return null; - } - return { qubits: events.qubits, componentGrid: events.componentGrid }; -} diff --git a/source/npm/qsharp/ux/circuit-vis/index.ts b/source/npm/qsharp/ux/circuit-vis/index.ts index 34a3604d7d0..ec106d13f46 100644 --- a/source/npm/qsharp/ux/circuit-vis/index.ts +++ b/source/npm/qsharp/ux/circuit-vis/index.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license. import { DrawOptions, Sqore } from "./sqore.js"; -import { CircuitGroup } from "./circuit.js"; +import { CircuitGroup } from "./data/circuit.js"; /** * Render `circuit` into `container` at the specified layer depth. @@ -15,10 +15,10 @@ import { CircuitGroup } from "./circuit.js"; * - `editor`: When provided, enables editing behaviors and requires: * - `editCallback`: Called when the circuit is edited. * - `runCallback` (optional): When provided, enables the Run button. - * - `computeStateVizColumnsForCircuitModel` (optional): When provided, - * delegates async state visualization computation to the host, which - * is necessary for large circuits and/or when using a Web Worker (e.g. in VS Code). - * When omitted, state visualization will be computed on the main thread. + * - `computeStateVizColumnsForCircuitModel` (optional): When provided, delegates async state + * visualization computation to the host, which is necessary for large circuits and/or when + * using a Web Worker (e.g. in VS Code). When omitted, state visualization will be computed + * on the main thread. */ export const draw = ( circuitGroup: CircuitGroup, @@ -26,6 +26,11 @@ export const draw = ( options: DrawOptions = {}, ): { userSetZoomLevel: (zoomLevel: number) => void; + /** + * Replace the rendered circuit in place, preserving per-session view state (e.g. user + * expand/collapse choices). See [`Sqore.updateCircuit`](sqore.ts). + */ + updateCircuit: (circuitGroup: CircuitGroup) => void; } => { const sqore = new Sqore(circuitGroup, options); sqore.draw(container); @@ -34,6 +39,7 @@ export const draw = ( sqore.zoomOnResize = false; sqore.updateZoomLevel(zoomLevel); }, + updateCircuit: (group: CircuitGroup) => sqore.updateCircuit(group), }; }; @@ -47,4 +53,4 @@ export type { Column, Qubit, Operation, -} from "./circuit.js"; +} from "./data/circuit.js"; diff --git a/source/npm/qsharp/ux/circuit-vis/panel.ts b/source/npm/qsharp/ux/circuit-vis/panel.ts deleted file mode 100644 index 5551f52b3e6..00000000000 --- a/source/npm/qsharp/ux/circuit-vis/panel.ts +++ /dev/null @@ -1,437 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -import { - Ket, - Measurement, - Operation, - Unitary, - type Circuit, -} from "./circuit.js"; -import { ensureStateVisualization } from "./state-viz/stateVizController.js"; -import type { StateColumn } from "./state-viz/stateViz.js"; -import type { PrepareStateVizOptions } from "./state-viz/worker/stateVizPrep.js"; -import { - gateHeight, - horizontalGap, - minGateWidth, - verticalGap, -} from "./constants.js"; -import { formatGate } from "./formatters/gateFormatter.js"; -import { GateType, GateRenderData } from "./gateRenderData.js"; -import { getMinGateWidth } from "./utils.js"; - -const getOrCreateCircuitWrapper = (container: HTMLElement) => { - let wrapper: HTMLElement | null = container.querySelector(".circuit-wrapper"); - const circuit = container.querySelector("svg.qviz") as SVGElement | null; - if (circuit == null) { - throw new Error("No circuit found in the container"); - } - if (!wrapper) { - wrapper = document.createElement("div"); - wrapper.className = "circuit-wrapper"; - wrapper.appendChild(circuit); - container.appendChild(wrapper); - } else if (circuit.parentElement !== wrapper) { - wrapper.appendChild(circuit); - } - - return { wrapper, circuit }; -}; - -const attachToolboxPanelIfMissing = ( - container: HTMLElement, - createToolboxPanel: () => HTMLElement, -): void => { - if (container.querySelector(".panel") != null) return; - container.prepend(createToolboxPanel()); -}; - -const removeEmptyCircuitMessage = (wrapper: HTMLElement): void => { - const prevMsg = wrapper.querySelector(".empty-circuit-message"); - if (prevMsg) prevMsg.remove(); -}; - -const addEmptyCircuitMessageIfEmpty = ( - wrapper: HTMLElement, - circuit: SVGElement, -): void => { - const wiresGroup = circuit?.querySelector(".wires"); - if (!wiresGroup || wiresGroup.children.length === 0) { - const emptyMsg = document.createElement("div"); - emptyMsg.className = "empty-circuit-message"; - emptyMsg.textContent = - "Your circuit is empty. Drag gates from the toolbox to get started!"; - wrapper.appendChild(emptyMsg); - } -}; - -const applyCircuitEditorLayoutClasses = ( - container: HTMLElement, - wrapper: HTMLElement, -): void => { - container.classList.add("circuit-editor-container"); - wrapper.classList.add("circuit-wrapper"); -}; - -/** - * Create a panel for the circuit visualization. - * @param container HTML element for rendering visualization into - * @param computeStateVizColumnsForCircuitModel Optional callback to compute - * state visualization columns from a circuit model, which enables state - * visualization features when provided. - */ -const createPanel = ( - container: HTMLElement, - computeStateVizColumnsForCircuitModel?: ( - model: Circuit, - opts?: PrepareStateVizOptions, - ) => Promise, -): void => { - const { wrapper, circuit } = getOrCreateCircuitWrapper(container); - removeEmptyCircuitMessage(wrapper); - attachToolboxPanelIfMissing(container, _panel); - addEmptyCircuitMessageIfEmpty(wrapper, circuit); - applyCircuitEditorLayoutClasses(container, wrapper); - - ensureStateVisualization(container, computeStateVizColumnsForCircuitModel); -}; - -/** - * Enable the run button in the toolbox panel. - * This function makes the run button visible and adds a click event listener. - * @param container HTML element containing the toolbox panel - * @param callback Callback function to execute when the run button is clicked - */ -const enableRunButton = ( - container: HTMLElement, - callback: () => void, -): void => { - const runButton = container.querySelector(".svg-run-button"); - if (runButton && runButton.getAttribute("visibility") !== "visible") { - runButton.setAttribute("visibility", "visible"); - runButton.addEventListener("click", callback); - } -}; - -/** - * Function to produce panel element - * @returns HTML element for panel - */ -const _panel = (): HTMLElement => { - const panelElem = _elem("div"); - panelElem.className = "panel"; - _children(panelElem, [_createToolbox()]); - return panelElem; -}; - -/** - * Function to produce toolbox element - * @returns HTML element for toolbox - */ -const _createToolbox = (): HTMLElement => { - // Generate gate elements in a 3xN grid - let prefixX = 0; - let prefixY = 0; - const gateElems = Object.keys(toolboxGateDictionary).map((key, index) => { - const { width: gateWidth } = toRenderData(toolboxGateDictionary[key], 0, 0); - - // Increment prefixX for every gate, and reset after 2 gates (2 columns) - if (index % 2 === 0 && index !== 0) { - prefixX = 0; - prefixY += gateHeight + verticalGap; - } - - const gateElem = _gate( - toolboxGateDictionary, - key.toString(), - prefixX, - prefixY, - ); - prefixX += gateWidth + horizontalGap; - return gateElem; - }); - - // Generate svg container to store gate elements - const svgElem = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svgElem.classList.add("toolbox-panel-svg"); - _childrenSvg(svgElem, gateElems); - - // Append run button - const runButtonGroup = _createRunButton(prefixY + gateHeight + 20); - svgElem.appendChild(runButtonGroup); - - // Size SVG to content height so the toolbox panel can scroll when window is short - const totalSvgHeight = prefixY + 2 * gateHeight + 32; // gates + button + padding - svgElem.setAttribute("height", totalSvgHeight.toString()); - svgElem.setAttribute("width", "100%"); - - // Generate toolbox panel - const toolboxElem = _elem("div", "toolbox-panel"); - _children(toolboxElem, [_title("Toolbox")]); - toolboxElem.appendChild(svgElem); - - return toolboxElem; -}; - -/** - * Function to create the run button in the toolbox panel - * @param buttonY Y coordinate for the top of the button - * @returns SVG group element containing the run button - */ -const _createRunButton = (buttonY: number): SVGGElement => { - const buttonWidth = minGateWidth * 2 + horizontalGap; - const buttonHeight = gateHeight; - const buttonX = 1; - - const runButtonGroup = document.createElementNS( - "http://www.w3.org/2000/svg", - "g", - ); - runButtonGroup.setAttribute("class", "svg-run-button"); - runButtonGroup.setAttribute("tabindex", "0"); - runButtonGroup.setAttribute("role", "button"); - - // Rectangle background - const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - rect.setAttribute("x", buttonX.toString()); - rect.setAttribute("y", buttonY.toString()); - rect.setAttribute("width", buttonWidth.toString()); - rect.setAttribute("height", buttonHeight.toString()); - rect.setAttribute("class", "svg-run-button-rect"); - - // Text label - const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); - text.setAttribute("x", (buttonX + buttonWidth / 2).toString()); - text.setAttribute("y", (buttonY + buttonHeight / 2).toString()); - text.setAttribute("class", "svg-run-button-text"); - text.textContent = "Run"; - - // Add elements to group - runButtonGroup.appendChild(rect); - runButtonGroup.appendChild(text); - - // The run button should be hidden by default - runButtonGroup.setAttribute("visibility", "hidden"); - return runButtonGroup; -}; - -/** - * Factory function to produce HTML element - * @param tag Tag name - * @param className Class name - * @returns HTML element - */ -const _elem = (tag: string, className?: string): HTMLElement => { - const _elem = document.createElement(tag); - if (className) { - _elem.className = className; - } - return _elem; -}; - -/** - * Append all child elements to a parent HTML element - * @param parentElem Parent HTML element - * @param childElems Array of HTML child elements - * @returns Parent HTML element with all children appended - */ -const _children = ( - parentElem: HTMLElement, - childElems: HTMLElement[], -): HTMLElement => { - childElems.map((elem) => parentElem.appendChild(elem)); - return parentElem; -}; - -/** - * Append all child elements to a parent SVG element - * @param parentElem Parent SVG element - * @param childElems Array of SVG child elements - * @returns Parent SVG element with all children appended - */ -const _childrenSvg = ( - parentElem: SVGElement, - childElems: SVGElement[], -): SVGElement => { - childElems.map((elem) => parentElem.appendChild(elem)); - return parentElem; -}; - -/** - * Function to produce title element - * @param text Text - * @returns Title element - */ -const _title = (text: string): HTMLElement => { - const titleElem = _elem("h2"); - titleElem.className = "title"; - titleElem.textContent = text; - return titleElem; -}; - -/** - * Wrapper to generate render data based on _opToRenderData with mock registers and limited support - * @param operation Operation object - * @param x x coordinate at starting point from the left - * @param y y coordinate at starting point from the top - * @returns GateRenderData object - */ -const toRenderData = ( - operation: Operation | undefined, - x: number, - y: number, -): GateRenderData => { - const target = y + 1 + gateHeight / 2; // offset by 1 for top padding - const renderData: GateRenderData = { - type: GateType.Invalid, - isExpanded: false, - x: x + 1 + minGateWidth / 2, // offset by 1 for left padding - controlsY: [], - targetsY: [target], - label: "", - width: -1, - topPadding: 0, - bottomPadding: 0, - }; - - if (operation === undefined) return renderData; - - switch (operation.kind) { - case "unitary": { - const { gate, controls } = operation; - - if (gate === "SWAP") { - renderData.type = GateType.Swap; - } else if (controls && controls.length > 0) { - renderData.type = - gate === "X" ? GateType.Cnot : GateType.ControlledUnitary; - renderData.label = gate; - if (gate !== "X") { - renderData.targetsY = [[target]]; - } - } else if (gate === "X") { - renderData.type = GateType.X; - renderData.label = gate; - } else { - renderData.type = GateType.Unitary; - renderData.label = gate; - renderData.targetsY = [[target]]; - } - break; - } - case "measurement": - renderData.type = GateType.Measure; - renderData.controlsY = [target]; - break; - case "ket": - renderData.type = GateType.Ket; - renderData.label = operation.gate; - renderData.targetsY = [[target]]; - break; - } - - if (operation.args !== undefined && operation.args.length > 0) - renderData.displayArgs = operation.args[0]; - - renderData.width = getMinGateWidth(renderData); - renderData.x = x + 1 + renderData.width / 2; // offset by 1 for left padding - - return renderData; -}; - -/** - * Generate an SVG gate element for the Toolbox panel based on the type of gate. - * This function retrieves the operation render data from the gate dictionary, - * formats the gate, and returns the corresponding SVG element. - * - * @param gateDictionary - The dictionary containing gate operations. - * @param type - The type of gate. Example: 'H' or 'X'. - * @param x - The x coordinate at the starting point from the left. - * @param y - The y coordinate at the starting point from the top. - * @returns The generated SVG element representing the gate. - * @throws Will throw an error if the gate type is not available in the dictionary. - */ -const _gate = ( - gateDictionary: GateDictionary, - type: string, - x: number, - y: number, -): SVGElement => { - const gate = gateDictionary[type]; - if (gate == null) throw new Error(`Gate ${type} not available`); - const renderData = toRenderData(gate, x, y); - renderData.dataAttributes = { type: type }; - const gateElem = formatGate(renderData).cloneNode(true) as SVGElement; - gateElem.setAttribute("toolbox-item", "true"); - - return gateElem; -}; - -/** - * Interface for gate dictionary - */ -interface GateDictionary { - [index: string]: Operation; -} - -/** - * Function to create a unitary operation - * - * @param gate - The name of the gate - * @returns Unitary operation object - */ -const _makeUnitary = (gate: string): Unitary => { - return { - kind: "unitary", - gate: gate, - targets: [], - }; -}; - -/** - * Function to create a measurement operation - * - * @param gate - The name of the gate - * @returns Unitary operation object - */ -const _makeMeasurement = (gate: string): Measurement => { - return { - kind: "measurement", - gate: gate, - qubits: [], - results: [], - }; -}; - -const _makeKet = (gate: string): Ket => { - return { - kind: "ket", - gate: gate, - targets: [], - }; -}; - -/** - * Object for default gate dictionary - */ -const toolboxGateDictionary: GateDictionary = { - RX: _makeUnitary("Rx"), - X: _makeUnitary("X"), - RY: _makeUnitary("Ry"), - Y: _makeUnitary("Y"), - RZ: _makeUnitary("Rz"), - Z: _makeUnitary("Z"), - S: _makeUnitary("S"), - T: _makeUnitary("T"), - H: _makeUnitary("H"), - SX: _makeUnitary("SX"), - Reset: _makeKet("0"), - Measure: _makeMeasurement("Measure"), -}; - -toolboxGateDictionary["RX"].params = [{ name: "theta", type: "Double" }]; -toolboxGateDictionary["RY"].params = [{ name: "theta", type: "Double" }]; -toolboxGateDictionary["RZ"].params = [{ name: "theta", type: "Double" }]; - -export { createPanel, enableRunButton, toolboxGateDictionary, toRenderData }; diff --git a/source/npm/qsharp/ux/circuit-vis/constants.ts b/source/npm/qsharp/ux/circuit-vis/renderer/constants.ts similarity index 100% rename from source/npm/qsharp/ux/circuit-vis/constants.ts rename to source/npm/qsharp/ux/circuit-vis/renderer/constants.ts diff --git a/source/npm/qsharp/ux/circuit-vis/formatters/formatUtils.ts b/source/npm/qsharp/ux/circuit-vis/renderer/formatters/formatUtils.ts similarity index 100% rename from source/npm/qsharp/ux/circuit-vis/formatters/formatUtils.ts rename to source/npm/qsharp/ux/circuit-vis/renderer/formatters/formatUtils.ts diff --git a/source/npm/qsharp/ux/circuit-vis/formatters/gateFormatter.ts b/source/npm/qsharp/ux/circuit-vis/renderer/formatters/gateFormatter.ts similarity index 77% rename from source/npm/qsharp/ux/circuit-vis/formatters/gateFormatter.ts rename to source/npm/qsharp/ux/circuit-vis/renderer/formatters/gateFormatter.ts index 6c1c30a5ab6..5910a094efe 100644 --- a/source/npm/qsharp/ux/circuit-vis/formatters/gateFormatter.ts +++ b/source/npm/qsharp/ux/circuit-vis/renderer/formatters/gateFormatter.ts @@ -27,7 +27,7 @@ import { dashedBox, } from "./formatUtils.js"; -import { mathChars } from "../utils.js"; +import { mathChars } from "../../utils.js"; /** * Given an array of operations render data, return the SVG representation. @@ -64,18 +64,28 @@ const formatGate = (renderData: GateRenderData): SVGElement => { bodyWidth = width - controlCircleOffset; bodyX = x + controlCircleOffset / 2; } - return _createGate( - [ - _unitary( - label, - bodyX, - targetsY as number[][], - bodyWidth, - displayArgs, + const elems: SVGElement[] = [ + _unitary(label, bodyX, targetsY as number[][], bodyWidth, displayArgs), + ]; + // A classically-controlled Unitary can carry quantum controls mixed in alongside its + // classical refs. Render those as standard control dots+connectors on the column center, + // attached to the unitary body box's nearest edge — not as classical circles (that path is + // `_classicalControls`). + const quantumControlsY = _getQuantumControlYs(renderData); + if (quantumControlsY.length > 0) { + const flatTargets = (targetsY as number[][]).flat(); + const bodyTopY = Math.min(...flatTargets) - gateHeight / 2; + const bodyBottomY = Math.max(...flatTargets) + gateHeight / 2; + elems.push( + ..._renderQuantumGroupControls( + quantumControlsY, + x, + bodyTopY, + bodyBottomY, ), - ], - renderData, - ); + ); + } + return _createGate(elems, renderData); } case GateType.X: return _createGate([_x(renderData)], renderData); @@ -131,9 +141,8 @@ const _createGate = ( renderData.link.title, ); - // Add the gate elements as children of the link. - // If this operation is classically controlled, keep the control circles - // outside the link so they remain independently interactive. + // Add the gate elements as children of the link. If this operation is classically controlled, + // keep the control circles outside the link so they remain independently interactive. for (const e of svgElems) { linkElem.appendChild(e); } @@ -143,8 +152,8 @@ const _createGate = ( svgElems = classicalControlElems.concat(svgElems); } - // Zoom button comes last so it's on top of the element if both are present - // This allows clicking the zoom button without triggering the link + // Zoom button comes last so it's on top of the element if both are present This allows + // clicking the zoom button without triggering the link const zoomBtn: SVGElement | null = _zoomButton(renderData); if (zoomBtn != null) svgElems = svgElems.concat([zoomBtn]); @@ -172,10 +181,9 @@ const _zoomButton = (renderData: GateRenderData): SVGElement | null => { _gateBoundingBox(renderData); const expanded = renderData.isExpanded; - // If this operation has classical controls, the overall gate bounding box includes - // extra space on the left for the control circles. The expand/collapse button should - // align with the left edge of the gate *body* (dashed box / unitary box), not the - // outermost bounding box. + // If this operation has classical controls, the overall gate bounding box includes extra space on + // the left for the control circles. The expand/collapse button should align with the left edge of + // the gate *body* (dashed box / unitary box), not the outermost bounding box. const hasClassicalControls = renderData.classicalControlIds != null && renderData.controlsY.length > 0; @@ -204,8 +212,7 @@ const _zoomButton = (renderData: GateRenderData): SVGElement | null => { }; /** - * Calculate the bounding box for a given operation, which - * may itself be a group of operations. + * Calculate the bounding box for a given operation, which may itself be a group of operations. * * @param renderData Operation render data. * @@ -225,9 +232,9 @@ const _gateBoundingBox = ( const x = centerX - width / 2; - // If we are rendering a classically controlled group, we want the bounding box to go around - // the targets AND controls. This is because the classical control circle is rendered next - // to the dashed box, and needs to touch the box, like so: + // If we are rendering a classically controlled group, we want the bounding box to go around the + // targets AND controls. This is because the classical control circle is rendered next to the + // dashed box, and needs to touch the box, like so: // ┌╌╌╌╌╌┐ // ╎ ┌─┐ ╎ // ─────────┼─│X│─┼─ @@ -243,8 +250,8 @@ const _gateBoundingBox = ( const maxY = Math.max(...ys); const minY = Math.min(...ys); - // Here, we want to expand the bounding box to include the whole dashed - // box around the group, which will be sized according to the nested depth. + // Here, we want to expand the bounding box to include the whole dashed box around the group, + // which will be sized according to the nested depth. // // Example of a grouped operation: // @@ -325,9 +332,9 @@ function _style_gate_text(gate: SVGTextElement) { // In general, use the regular math font gate.classList.add("qs-maintext"); - // Wrap any latin or greek letters in tspan with KaTeX_Math font - // Style the entire Greek + Coptic block (https://unicodeplus.com/block/0370) - // Note this deliberately leaves ASCII digits [0-9] non-italic + // Wrap any latin or greek letters in tspan with KaTeX_Math font Style the entire Greek + Coptic + // block (https://unicodeplus.com/block/0370) Note this deliberately leaves ASCII digits [0-9] + // non-italic const italicChars = /[a-zA-Z\u{0370}-\u{03ff}]+/gu; label = label.replace(italicChars, `$&`); @@ -636,11 +643,17 @@ const _groupedOperations = (renderData: GateRenderData): SVGElement => { const { children, label, displayArgs, x, targetsY, width } = renderData; const expanded = renderData.isExpanded; - // Collapsed composite: render as a single summary gate (unitary-style), but keep - // the GateType as Group so the UI can still offer an expand button. + // Groups support classical controls only (rendered via `_classicalControls` in `_createGate`). + // Quantum controls on groups are rejected at the authoring layer (see `_isMultiTargetOrGroup` in + // `circuitActions.ts`); if such an op arrives from an external source we let it fall through + // unrendered rather than carry special-case logic for an unsupported scenario. + const hasClassicalControls = renderData.classicalControlIds != null; + + // Collapsed composite: render as a single summary gate (unitary-style), but keep the GateType as + // Group so the UI can still offer an expand button. if (!expanded) { - // `targetsY` for groups is typically a flat `number[]` (not split into groups). - // `_unitary` expects a `number[][]` where each entry is a contiguous set of wires. + // `targetsY` for groups is typically a flat `number[]` (not split into groups). `_unitary` + // expects a `number[][]` where each entry is a contiguous set of wires. const normalizedTargetsY: number[][] = Array.isArray(targetsY[0]) ? (targetsY as number[][]) : [targetsY as number[]]; @@ -652,17 +665,19 @@ const _groupedOperations = (renderData: GateRenderData): SVGElement => { bodyX = x + controlCircleOffset / 2; } - return _createGate( - [_unitary(label, bodyX, normalizedTargetsY, bodyWidth, displayArgs)], - renderData, + const summary = _unitary( + label, + bodyX, + normalizedTargetsY, + bodyWidth, + displayArgs, ); + + return _createGate([summary], renderData); } const boundingBox = _gateBoundingBox(renderData); - const hasClassicalControls = - renderData.classicalControlIds != null && renderData.controlsY.length > 0; - const elems: SVGElement[] = []; let boxX = boundingBox.x; @@ -673,12 +688,27 @@ const _groupedOperations = (renderData: GateRenderData): SVGElement => { boxWidth -= controlCircleOffset; } + // The dashed box surrounds the CHILDREN + any classical control wires (the classical refs need to + // be inside / touching the box so `_classicalControls` can attach its dashed connector). Both are + // already merged into `targetsY` by `_opToRenderData`'s classical-control patch, so deriving box + // geometry from `targetsY` alone gives the right span for pure-quantum and mixed-classical cases. + const flatTargets = (targetsY as (number | number[])[]).flat(); + const minTargetY = Math.min(...flatTargets); + const maxTargetY = Math.max(...flatTargets); + const boxY = minTargetY - gateHeight / 2 - renderData.topPadding; + const boxHeight = + maxTargetY - + minTargetY + + gateHeight + + renderData.bottomPadding + + renderData.topPadding; + // Draw dashed box around children gates const boxElem: SVGElement = dashedBox( boxX, - boundingBox.y, + boxY, boxWidth, - boundingBox.height, + boxHeight, hasClassicalControls ? "classical-container" : "gate-unitary", ); elems.push(boxElem); @@ -687,7 +717,7 @@ const _groupedOperations = (renderData: GateRenderData): SVGElement => { const labelText = _labelText( label, boxX + labelPaddingX, - boundingBox.y + groupTopPadding / 2 + groupLabelPaddingY, + boxY + groupTopPadding / 2 + groupLabelPaddingY, ); labelText.classList.add("qs-group-label"); @@ -704,6 +734,55 @@ const _groupedOperations = (renderData: GateRenderData): SVGElement => { return _createGate(elems, renderData); }; +/** + * Pick out the y-coords of QUANTUM controls from `controlsY`, filtering out classical-ref entries. + * Classical refs are marked by a non-undefined entry in the parallel `classicalControlIds` array + * (numeric id, or `null` when the id couldn't be resolved). When `classicalControlIds` is absent, + * the op has no classical refs and every control in `controlsY` is quantum. + */ +const _getQuantumControlYs = (renderData: GateRenderData): number[] => { + const { controlsY, classicalControlIds } = renderData; + if (classicalControlIds == null) return [...controlsY]; + return controlsY.filter((_, i) => classicalControlIds[i] === undefined); +}; + +/** + * Emit control dots and connector lines for each quantum control on a single-target unitary body + * that also carries classical refs (the mixed-control case in `formatGate`'s `GateType.Unitary` + * branch). The dot sits on the control wire at the body's center x; the connector runs from the dot + * to the nearest body edge (top edge if the control is above the body, bottom if below). A control + * wire inside the body's y range gets just the dot with no connector. + * + * Centerline x matches what `_controlledGate` uses for normal `ControlledUnitary` gates so dots + * line up consistently. + * + * Groups (ops with `children`) do NOT use this path — quantum controls on groups are rejected by + * the authoring layer (see `_isMultiTargetOrGroup` in `circuitActions.ts`). + */ +const _renderQuantumGroupControls = ( + controlsY: number[], + centerX: number, + boxYTop: number, + boxYBottom: number, +): SVGElement[] => { + const elems: SVGElement[] = []; + for (const y of controlsY) { + elems.push(controlDot(centerX, y, [y])); + if (y < boxYTop) { + const ln = line(centerX, y, centerX, boxYTop, "control-line"); + ln.style.pointerEvents = "none"; + elems.push(ln); + } else if (y > boxYBottom) { + const ln = line(centerX, boxYBottom, centerX, y, "control-line"); + ln.style.pointerEvents = "none"; + elems.push(ln); + } + // y inside [boxYTop, boxYBottom]: no extra connector — the wire already crosses the box, so the + // dot reads as attached. + } + return elems; +}; + const _classicalControls = ( leftX: number, renderData: GateRenderData, @@ -712,8 +791,14 @@ const _classicalControls = ( const elems: SVGElement[] = []; for (let i = 0; i < controlsY.length; i++) { + // `undefined` marks a QUANTUM control entry (a mix of quantum + classical refs on the same op). + // Those render via the standard control-dot path elsewhere — skip here so we don't draw a stray + // classical circle on a qubit wire. + const idEntry = classicalControlIds?.[i]; + if (idEntry === undefined) continue; + const controlY = controlsY[i]; - const label = classicalControlIds?.[i] ?? null; + const label: number | null = idEntry; // Draw control button and attached dashed line to the gate body. const controlCircleX: number = leftX + controlCircleRadius; @@ -814,4 +899,14 @@ function _labelText(label: string, x: number, y: number): SVGTextElement { return labelText; } -export { formatGates, formatGate }; +export { + formatGates, + formatGate, + // Internal helpers exposed for direct unit testing. The leading underscore signals "test-only + // export"; matches the `_isMultiTargetOrGroup` convention in `actions/circuitActions.ts`. + _createGate, + _gateBoundingBox, + _zoomButton, + _classicalControls, + _getQuantumControlYs, +}; diff --git a/source/npm/qsharp/ux/circuit-vis/formatters/inputFormatter.ts b/source/npm/qsharp/ux/circuit-vis/renderer/formatters/inputFormatter.ts similarity index 83% rename from source/npm/qsharp/ux/circuit-vis/formatters/inputFormatter.ts rename to source/npm/qsharp/ux/circuit-vis/renderer/formatters/inputFormatter.ts index 4d77c7244b4..f39f4868385 100644 --- a/source/npm/qsharp/ux/circuit-vis/formatters/inputFormatter.ts +++ b/source/npm/qsharp/ux/circuit-vis/renderer/formatters/inputFormatter.ts @@ -1,8 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { Qubit, SourceLocation } from "../circuit.js"; -import { RegisterType, RegisterMap, RegisterRenderData } from "../register.js"; +import { Qubit, SourceLocation } from "../../data/circuit.js"; +import { + RegisterType, + RegisterMap, + RegisterRenderData, +} from "../../data/register.js"; import { leftPadding, startY, @@ -13,16 +17,16 @@ import { gatePadding, } from "../constants.js"; import { createSvgElement, group, text } from "./formatUtils.js"; -import { mathChars } from "../utils.js"; +import { mathChars } from "../../utils.js"; /** - * `formatInputs` takes in an array of Qubits and outputs the SVG string of formatted - * qubit wires and a mapping from register IDs to register rendering data. + * `formatInputs` takes in an array of Qubits and outputs the SVG string of formatted qubit wires + * and a mapping from register IDs to register rendering data. * * @param qubits List of declared qubits. * - * @returns returns the SVG string of formatted qubit wires, a mapping from registers - * to y coord and total SVG height. + * @returns returns the SVG string of formatted qubit wires, a mapping from registers to y coord and + * total SVG height. */ const formatInputs = ( qubits: Qubit[], @@ -30,6 +34,8 @@ const formatInputs = ( [qubitIndex: number]: { heightAboveWire: number; heightBelowWire: number; + heightAboveFirstClassical: number; + bottomBordersAboveFirstClassical: number; }; }, renderLocations?: (s: SourceLocation[]) => { title: string; href: string }, @@ -52,9 +58,16 @@ const formatInputs = ( // └╌╌╌╌╌╌╌┘ qubits.forEach(({ id, numResults, declarations }, wireIndex) => { - const { heightAboveWire, heightBelowWire } = rowHeights[wireIndex] || { + const { + heightAboveWire, + heightBelowWire, + heightAboveFirstClassical, + bottomBordersAboveFirstClassical, + } = rowHeights[wireIndex] || { heightAboveWire: 0, heightBelowWire: 0, + heightAboveFirstClassical: 0, + bottomBordersAboveFirstClassical: 0, }; currY += heightAboveWire * groupTopPadding; @@ -112,7 +125,16 @@ const formatInputs = ( // ╎└╌╌╌╌╌┘╎ // └╌╌╌╌╌╌╌┘ - // Increment current height by classical register height for attached classical registers + // Reserve room above the first classical sub-wire for any classically-controlled group whose + // box top *or* box bottom sits in the gap between this qubit's wire and its first classical + // sub-wire. Two stacking rates apply: + // - Top borders carry the group label and stack at `groupTopPadding` per nested level. + // - Bottom borders have no label and stack at `groupBottomPadding` per nested level. They + // occur when a group's `maxQubit` is a pure qubit ref that has classical sub-wires; without + // the reservation the box bottom would cross through them. + currY += + heightAboveFirstClassical * groupTopPadding + + bottomBordersAboveFirstClassical * groupBottomPadding; // Add classical wires registers[id].children = Array.from(Array(numResults ?? 0), () => { diff --git a/source/npm/qsharp/ux/circuit-vis/formatters/registerFormatter.ts b/source/npm/qsharp/ux/circuit-vis/renderer/formatters/registerFormatter.ts similarity index 86% rename from source/npm/qsharp/ux/circuit-vis/formatters/registerFormatter.ts rename to source/npm/qsharp/ux/circuit-vis/renderer/formatters/registerFormatter.ts index e8348ace10d..e035f18176c 100644 --- a/source/npm/qsharp/ux/circuit-vis/formatters/registerFormatter.ts +++ b/source/npm/qsharp/ux/circuit-vis/renderer/formatters/registerFormatter.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { RegisterMap } from "../register.js"; +import { RegisterMap } from "../../data/register.js"; import { regLineStart } from "../constants.js"; import { GateRenderData, GateType } from "../gateRenderData.js"; import { group, line } from "./formatUtils.js"; @@ -12,8 +12,8 @@ import { group, line } from "./formatUtils.js"; * * @param registers Map from register IDs to register render data. * @param allGates All the gates in the circuit. - * @param endX End x-coordinate for the whole circuit. - * All wires will stretch to this x-coordinate. + * @param endX End x-coordinate for the whole circuit. All wires will stretch to this + * x-coordinate. * * @returns SVG representation of register wires. */ @@ -41,8 +41,8 @@ const formatRegisters = ( const ys: number[] = []; for (const gate of allGates.flat()) { if (gate.type === GateType.Group) { - // Don't render classical wires for a group that is expanded - the wires - // will be coming out of the measurement operations *inside* the group. + // Don't render classical wires for a group that is expanded - the wires will be coming + // out of the measurement operations *inside* the group. if (gate.isExpanded) continue; } @@ -51,11 +51,11 @@ const formatRegisters = ( continue; } ys.push(y); - // Found the gate that this classical wire originates from. Draw - // it starting at this gates x-coordinate. + // Found the gate that this classical wire originates from. Draw it starting at this gates + // x-coordinate. - // If this is a measurement gate, there is a vertical line - // going down from the gate to the wire + // If this is a measurement gate, there is a vertical line going down from the gate to the + // wire const verticalY = gate.type === GateType.Measure ? gate.controlsY[0] : undefined; @@ -74,8 +74,8 @@ const formatRegisters = ( * @param startX Start x coord. * @param endX End x coord. * @param wireY y coord of wire. - * @param gateY y coord of the measurement gate that this wire originates from. - * If undefined, no vertical line is drawn. + * @param gateY y coord of the measurement gate that this wire originates from. If undefined, no + * vertical line is drawn. * * @returns SVG representation of the given classical register. */ diff --git a/source/npm/qsharp/ux/circuit-vis/gateRenderData.ts b/source/npm/qsharp/ux/circuit-vis/renderer/gateRenderData.ts similarity index 52% rename from source/npm/qsharp/ux/circuit-vis/gateRenderData.ts rename to source/npm/qsharp/ux/circuit-vis/renderer/gateRenderData.ts index 53a2f1c1913..1bcf4610b14 100644 --- a/source/npm/qsharp/ux/circuit-vis/gateRenderData.ts +++ b/source/npm/qsharp/ux/circuit-vis/renderer/gateRenderData.ts @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { DataAttributes } from "./circuit.js"; -import { Register } from "./register.js"; +import { DataAttributes } from "../data/circuit.js"; +import { LayoutScope } from "./layoutMap.js"; +import { Register } from "../data/register.js"; /** * Enum for the various gate operations handled. @@ -29,8 +30,8 @@ export enum GateType { } /** - * Rendering data used to store information pertaining to a given - * operation for rendering its corresponding SVG. + * Rendering data used to store information pertaining to a given operation for rendering its + * corresponding SVG. */ export interface GateRenderData { /** Gate type. */ @@ -42,8 +43,8 @@ export interface GateRenderData { /** Array of y coords of control registers. */ controlsY: number[]; /** Array of y coords of target registers. - * For `GateType.Unitary` or `GateType.ControlledUnitary`, this is an array of groups of - * y coords, where each group represents a unitary box to be rendered separately. + * For `GateType.Unitary` or `GateType.ControlledUnitary`, this is an array of groups of y + * coords, where each group represents a unitary box to be rendered separately. */ targetsY: (number | number[])[]; /** Gate label. */ @@ -62,12 +63,31 @@ export interface GateRenderData { dataAttributes?: DataAttributes; /** Link href and title for clickable gate. */ link?: { href: string; title: string }; - /** Labels for the classical control registers (when present, this group is rendered with classical controls). */ - classicalControlIds?: (number | null)[]; /** - * Classical control registers used by this operation or any descendant. - * Used by processOperations to decide which classical wires may pass through - * this gate body without forcing a split. + * Labels for the classical control registers (when present, this op has at least one classical + * control). Aligned with `controlsY` by index: a numeric entry is a classical control with a + * known id, a `null` entry is a classical control whose id couldn't be resolved, and an + * `undefined` entry marks a quantum control that shares the op's `controls` array with classical + * refs. The formatter uses the `undefined` entries to route those controls through the standard + * control-dot render path instead of the classical-circle path. + */ + classicalControlIds?: (number | null | undefined)[]; + /** + * Classical control registers used by this operation or any descendant. Used by processOperations + * to decide which classical wires may pass through this gate body without forcing a split. */ classicalControlRegs?: Register[]; + /** + * @internal Used during layout to surface child-scope geometry from + * `_processChildren` up to the parent's `_fillRenderDataX`. Cleared (set to `undefined`) once + * consumed. Not used outside `process.ts`. + * + * Holds the recursive `processOperations` call's `localScope` (in the child's local + * startX-anchored coords) and any deeper scopes. The parent's `_fillRenderDataX` shifts these by + * the group's `offset` and merges them into its absolute scope accumulator. + */ + _childLayout?: { + localScope: LayoutScope; + childScopes: Map; + }; } diff --git a/source/npm/qsharp/ux/circuit-vis/renderer/gateWidth.ts b/source/npm/qsharp/ux/circuit-vis/renderer/gateWidth.ts new file mode 100644 index 00000000000..52c40e36d2c --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/renderer/gateWidth.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import { GateRenderData, GateType } from "./gateRenderData.js"; +import { + minGateWidth, + labelPaddingX, + labelFontSize, + argsFontSize, + controlCircleOffset, +} from "./constants.js"; + +/** + * Calculate the width of a gate, given its render data. + * + * @param renderData - The rendering data of the gate, including its type, label, display arguments. + * + * @returns Width of given gate (in pixels). + */ +const getMinGateWidth = ({ + type, + label, + displayArgs, + classicalControlIds, +}: GateRenderData): number => { + switch (type) { + case GateType.Measure: + case GateType.Cnot: + case GateType.Swap: + return minGateWidth; + default: { + // Classically controlled gates are wider because of the control button on the left + const controlButtonWidth = + classicalControlIds != null ? controlCircleOffset : 0; + const labelWidth = _getStringWidth(label); + const argsWidth = + displayArgs != null ? _getStringWidth(displayArgs, argsFontSize) : 0; + const textWidth = Math.max(labelWidth, argsWidth) + labelPaddingX * 2; + return Math.max(minGateWidth, textWidth) + controlButtonWidth; + } + } +}; + +/** + * Estimate string width in pixels based on character types and font size. This may not match the + * true rendered width, but should be close enough for calculating layout. + * + * @param text - The text string to measure. + * @param fontSize - The font size in pixels (default is labelFontSize). + * + * @returns Estimated width of the string in pixels. + */ +const _getStringWidth = ( + text: string, + fontSize: number = labelFontSize, +): number => { + let units = 0; + for (const ch of Array.from(text)) { + if (ch === " ") { + units += 0.33; + continue; + } + if ("il.:;,'`!|".includes(ch)) { + units += 0.28; + continue; + } + if ("mw".includes(ch)) { + units += 0.72; + continue; + } + if ("MW@#%&".includes(ch)) { + units += 0.78; + continue; + } + if (/[0-9]/.test(ch)) { + units += 0.55; + continue; + } + if (/[A-Z]/.test(ch)) { + units += 0.56; + continue; + } + if (/[a-z]/.test(ch)) { + units += 0.5; + continue; + } + if (/[θπ]/.test(ch)) { + units += 0.56; + continue; + } + if (/[ψ]/.test(ch)) { + units += 0.6; + continue; + } + if ("-+*/=^~_<>".includes(ch)) { + units += 0.5; + continue; + } + units += 0.56; + } + const kerningFudge = Math.max(0, text.length - 1) * 0.005; + // Round to a whole number to keep it easy to read + return Math.floor((units + kerningFudge) * fontSize); +}; + +export { getMinGateWidth }; diff --git a/source/npm/qsharp/ux/circuit-vis/renderer/layoutMap.ts b/source/npm/qsharp/ux/circuit-vis/renderer/layoutMap.ts new file mode 100644 index 00000000000..2d2ecb57775 --- /dev/null +++ b/source/npm/qsharp/ux/circuit-vis/renderer/layoutMap.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +/** + * LayoutMap — exported geometry from the circuit-rendering pass. + * + * [`processOperations`](process.ts) computes every coordinate while laying out the circuit. Rather + * than discard those numbers into SVG attributes and reverse-engineer them later, the renderer + * captures them in a `LayoutMap` and passes it to the editor directly — one source of truth, + * accurate for nested scopes. + * + * The map is owned by the View layer (regenerated on every render) and consumed by the editor + * controllers. Editor mutations go through the Action layer, which has no knowledge of `LayoutMap`. + */ + +/** + * Geometry for one *scope* — either the top-level component grid or the children grid of one + * expanded group. Each `processOperations` call (top-level or recursive via `_processChildren`) + * corresponds to exactly one scope. + */ +export type LayoutScope = { + /** + * Absolute x of the left edge of each column in this scope, indexed by column index within the + * scope. "Left edge" is where a gate centered in the column has its bounding box's left edge — + * i.e. `colStartX[i]` from `_fillRenderDataX`, which already accounts for `gatePadding` between + * columns. + */ + columnXOffsets: number[]; + + /** + * Width of each column. `columnXOffsets[i] + columnWidths[i]` gives the right edge of column + * `i`'s gates (before the inter-column `gatePadding * 2`). + */ + columnWidths: number[]; +}; + +/** + * Complete geometry for a rendered circuit. Built by [`processOperations`](process.ts) and threaded + * through [`Sqore.compose`](sqore.ts) to the editor. + */ +export type LayoutMap = { + /** + * Per-scope geometry, keyed by the *parent operation's* location string. The top-level scope (the + * circuit's root component grid) is keyed by `""`; the children of an expanded group at location + * `"0,0"` are keyed by `"0,0"`; grandchildren by `"0,0-1,2"`; etc. This is the addressing + * convention used by [`findParentArray`](utils.ts), so a `data-dropzone-location` of `"0,0-1,2"` + * points at scope `"0,0"`, column 1, opIndex 2. + */ + scopes: Map; + + /** + * Y coord of each *real* qubit wire, indexed by qubit id. Mirrors the values + * [`getWireData`](../editor/domUtils.ts) recovers from the DOM, but captured at compose time + * before any editor chrome (e.g. the ghost qubit wire) is added. + */ + wireYs: number[]; +}; + +/** Construct an empty `LayoutMap`. */ +export const emptyLayoutMap = (): LayoutMap => ({ + scopes: new Map(), + wireYs: [], +}); diff --git a/source/npm/qsharp/ux/circuit-vis/process.ts b/source/npm/qsharp/ux/circuit-vis/renderer/process.ts similarity index 61% rename from source/npm/qsharp/ux/circuit-vis/process.ts rename to source/npm/qsharp/ux/circuit-vis/renderer/process.ts index 0fb16035999..8f7a8f7d72e 100644 --- a/source/npm/qsharp/ux/circuit-vis/process.ts +++ b/source/npm/qsharp/ux/circuit-vis/renderer/process.ts @@ -10,14 +10,20 @@ import { groupTopPadding, groupBottomPadding, } from "./constants.js"; -import { ComponentGrid, Operation, SourceLocation } from "./circuit.js"; +import { ComponentGrid, Operation, SourceLocation } from "../data/circuit.js"; import { GateRenderData, GateType } from "./gateRenderData.js"; -import { Register, RegisterMap } from "./register.js"; -import { getMinGateWidth } from "./utils.js"; +import { LayoutScope } from "./layoutMap.js"; +import { Register, RegisterMap } from "../data/register.js"; +import { getMinGateWidth } from "./gateWidth.js"; /** - * Takes in a component grid and maps the operations to `GateRenderData` objects which - * contains information for formatting the corresponding SVG. + * Takes in a component grid and maps the operations to `GateRenderData` objects which contains + * information for formatting the corresponding SVG. + * + * Also returns layout info for this scope (column x-offsets and widths) and a map of all *child* + * scopes encountered while recursing into expanded groups, keyed by the parent op's location + * string. The caller decides whether `localScope` merges into a wider `LayoutMap` under `""` (top + * level) or under a parent op's location after shifting by that op's `offset` (nested). * * @param componentGrid Grid of circuit components. * @param registers Mapping from qubit IDs to register render data. @@ -25,8 +31,8 @@ import { getMinGateWidth } from "./utils.js"; * @param bottomY y-coordinate of the bottommost register involved in the operation. * @param renderLocations Optional function to map source locations to link hrefs and titles. * - * @returns An object containing `renderDataArray` (2D Array of GateRenderData objects) and - * `svgWidth` which is the width of the entire SVG. + * @returns An object containing `renderDataArray` (2D Array of GateRenderData objects), `svgWidth` + * (width of the entire SVG), and layout info (`localScope`, `childScopes`) for the LayoutMap. */ const processOperations = ( componentGrid: ComponentGrid, @@ -39,6 +45,18 @@ const processOperations = ( svgWidth: number; maxTopPadding: number; maxBottomPadding: number; + /** + * The local layout scope for this `processOperations` call. Coordinates are anchored at `startX` + * — absolute for the top-level call, and shifted by the caller's `offset` for nested calls (see + * `_fillRenderDataX`'s `GateType.Group` branch). + */ + localScope: LayoutScope; + /** + * Already-absolute scopes for any expanded groups encountered during this call (and recursively + * beneath them). Keyed by the parent op's `dataAttributes["location"]`. Does NOT include + * `localScope` itself. + */ + childScopes: Map; } => { if (componentGrid.length === 0) { return { @@ -46,6 +64,8 @@ const processOperations = ( svgWidth: startX + gatePadding * 2, maxTopPadding: 0, maxBottomPadding: 0, + localScope: { columnXOffsets: [], columnWidths: [] }, + childScopes: new Map(), }; } @@ -81,6 +101,20 @@ const processOperations = ( targets = op.targets; break; } + + // For ops with own classical controls, include those control wires in the body-geometry + // input. `_classicalControls` draws a short L-connector from each control circle to the + // body box; for that connector to land on the box (rather than in empty space below the + // body), the body must extend down to include the classical control wire's y. + if (op.kind === "unitary" && op.controls) { + const ownClassicalControls = op.controls.filter( + (r) => r.result != null, + ); + if (ownClassicalControls.length > 0) { + targets = [...targets, ...ownClassicalControls]; + } + } + const minTargetY = Math.min(...(renderData.targetsY as number[])); const maxTargetY = Math.max(...(renderData.targetsY as number[])); @@ -106,9 +140,9 @@ const processOperations = ( ].includes(renderData.type) || isCollapsedGroup) ) { - // Split multi-wire gate bodies into segments if there is a classical - // register wire between them. This prevents classical wires from - // visually intersecting/entering gate bodies. + // Split multi-wire gate bodies into segments if there is a classical register wire + // between them. This prevents classical wires from visually intersecting/entering gate + // bodies. // Get y coordinates of classical registers in the same column as this operation const classicalRegY: number[] = classicalRegs @@ -147,8 +181,8 @@ const processOperations = ( }), ); - // Filter out invalid gates and remove empty columns. - // Keep column widths in sync with the filtered columns. + // Filter out invalid gates and remove empty columns. Keep column widths in sync with the filtered + // columns. const filteredColumns = renderDataArray .map((col, colIndex) => ({ colIndex, @@ -162,14 +196,24 @@ const processOperations = ( ({ colIndex }) => columnsWidths[colIndex], ); - // Fill in x coord of each gate - const endX: number = _fillRenderDataX(filteredArray, filteredColumnWidths); + // Fill in x coord of each gate. `_fillRenderDataX` also returns the local column x-offsets it + // computed, plus the merged absolute child scopes harvested from any expanded groups in this grid + // (their `_childLayout` field, populated by `_processChildren`). + const { endX, colStartX, childScopes } = _fillRenderDataX( + filteredArray, + filteredColumnWidths, + ); return { renderDataArray: filteredArray, svgWidth: endX, maxTopPadding, maxBottomPadding, + localScope: { + columnXOffsets: colStartX, + columnWidths: filteredColumnWidths, + }, + childScopes, }; }; @@ -204,8 +248,8 @@ const _getClassicalRegStarts = ( }; /** - * Recursively collects classical control registers from source circuit children. - * Used inside _opToRenderData for collapsed groups whose children have not been rendered. + * Recursively collects classical control registers from source circuit children. Used inside + * _opToRenderData for collapsed groups whose children have not been rendered. */ function _collectClassicalControlsFromSourceChildren( children: ComponentGrid, @@ -227,8 +271,8 @@ function _collectClassicalControlsFromSourceChildren( } /** - * Maps operation to render data (e.g. gate type, position, dimensions, text) - * required to render the image. + * Maps operation to render data (e.g. gate type, position, dimensions, text) required to render the + * image. * * @param op Operation to be mapped into render data. * @param registers Array of registers. @@ -277,9 +321,8 @@ const _opToRenderData = ( const { gate, args, children, dataAttributes } = op; - // Classically-controlled operations are encoded as operations whose `controls` are - // classical registers (i.e. `Register.result` is set), with IDs provided via - // `metadata.controlResultIds`. + // Classically-controlled operations are encoded as operations whose `controls` are classical + // registers (i.e. `Register.result` is set), with IDs provided via `metadata.controlResultIds`. const hasClassicalControls = op.kind === "unitary" && ((controls?.some((reg) => reg.result != null) ?? false) || @@ -296,31 +339,55 @@ const _opToRenderData = ( renderData.controlsY = controls?.map((reg) => _getRegY(reg, registers)) || []; renderData.targetsY = targets.map((reg) => _getRegY(reg, registers)); + // For classically-controlled ops, include the classical-control sub-wires in `targetsY` so the + // wire span this op claims for layout matches the bounding-box span drawn by `_gateBoundingBox` + // (which merges `targetsY` with `controlsY` for its min/max). Without it, a parent group's + // `_processChildren` `topY === minTargetY` check fails for nested classically-controlled children + // and their `topPadding` doesn't propagate up, causing stacked nested conditionals to render box + // tops and labels at the same y. + if (op.kind === "unitary" && op.controls) { + const ownClassicalControlYs = op.controls + .filter((r) => r.result != null) + .map((reg) => _getRegY(reg, registers)); + if (ownClassicalControlYs.length > 0) { + renderData.targetsY = [ + ...(renderData.targetsY as number[]), + ...ownClassicalControlYs, + ]; + } + } + if (hasClassicalControls) { - // Classically-controlled operations. - // These are treated as composite/group operations when they have children. - // Expanded vs. collapsed rendering is controlled via the `expanded` state. + // Classically-controlled operations. These are treated as composite/group operations when they + // have children. Expanded vs. collapsed rendering is controlled via the `expanded` state. renderData.label = gate; - // Fill in the ID to be displayed in each control wire's circle. + // Fill in the ID to be displayed in each control wire's circle. Prefer the global id from + // `metadata.controlResultIds` (the trace-builder populates these so two M's on different qubits + // get distinct labels like `c_0` and `c_1`). When the metadata is missing (hand-authored `.qsc` + // files, programmatically built circuits), fall back to the control register's local `result` + // index. + // + // Quantum controls mixed in alongside classical refs get `undefined` so the formatter routes + // them through the standard control-dot path instead of drawing classical circles. `null` is + // reserved for classical refs whose id couldn't be resolved. renderData.classicalControlIds = - controls - ?.map( - (reg) => - op.metadata?.controlResultIds?.find( - (e) => e[0].qubit === reg.qubit && e[0].result === reg.result, - )?.[1], - ) - .map((id) => id ?? null) || []; + controls?.map((reg) => { + if (reg.result == null) return undefined; + const globalId = op.metadata?.controlResultIds?.find( + (e) => e[0].qubit === reg.qubit && e[0].result === reg.result, + )?.[1]; + return globalId ?? reg.result ?? null; + }) || []; if (hasChildren) { renderData.type = GateType.Group; if (isExpanded) { _processChildren(renderData, children!, registers, renderLocations); - // Add additional width for classical control circle. - // (The group width comes from children layout; it doesn't account for controls.) + // Add additional width for classical control circle. (The group width comes from children + // layout; it doesn't account for controls.) renderData.width += controlCircleOffset; } } else { @@ -328,9 +395,8 @@ const _opToRenderData = ( renderData.type = GateType.Unitary; } } else if (hasChildren) { - // Composite/grouped operations. - // Always represented as `GateType.Group` so the UI can determine expandability - // solely from gate type. + // Composite/grouped operations. Always represented as `GateType.Group` so the UI can determine + // expandability solely from gate type. renderData.type = GateType.Group; renderData.label = gate; @@ -357,9 +423,9 @@ const _opToRenderData = ( renderData.label = gate; } - // Collect classical control registers for this op and all descendants. - // For expanded groups, aggregate from already-rendered children to avoid re-walking source data. - // For collapsed groups with children, walk source data directly. + // Collect classical control registers for this op and all descendants. For expanded groups, + // aggregate from already-rendered children to avoid re-walking source data. For collapsed groups + // with children, walk source data directly. { const ownControls = op.kind === "unitary" @@ -386,13 +452,12 @@ const _opToRenderData = ( // If adjoint, add ' to the end of gate label if (isAdjoint && renderData.label.length > 0) renderData.label += "'"; - // If gate has extra arguments, display them - // For now, we only display the first argument + // If gate has extra arguments, display them For now, we only display the first argument if (args !== undefined && args.length > 0) renderData.displayArgs = args[0]; - // Minimum width is calculated based on the label and args. - // If this is a collapsed composite (GateType.Group with no children render data), - // its width should be based on the summary gate rather than the full expanded layout. + // Minimum width is calculated based on the label and args. If this is a collapsed composite + // (GateType.Group with no children render data), its width should be based on the summary gate + // rather than the full expanded layout. const minWidth = getMinGateWidth(renderData); const isCollapsedComposite = @@ -476,17 +541,31 @@ const _splitTargetsY = ( const qIdPosition: { [qId: number]: number } = {}; orderedQIds.forEach((qId, i) => (qIdPosition[qId] = i)); - // Sort targets and classicalRegY by ascending y value + // Sort targets by ascending y: qubit position, then qubit-only refs before their classical + // results, then by `result` index. targets = targets.slice(); targets.sort((a, b) => { const posDiff: number = qIdPosition[a.qubit] - qIdPosition[b.qubit]; - if (posDiff === 0 && a.result != null && b.result != null) - return a.result - b.result; - else return posDiff; + if (posDiff !== 0) return posDiff; + if (a.result == null && b.result == null) return 0; + if (a.result == null) return -1; + if (b.result == null) return 1; + return a.result - b.result; }); classicalRegY = classicalRegY.slice(); classicalRegY.sort((a, b) => a - b); + // Qubit positions whose QUANTUM wire is itself a target — i.e. a `{qubit: Q}` entry with `result + // == null`. The body legitimately covers these wires; any other qubit position whose wire falls + // strictly between two consecutive target Ys forces a split (rule 4 below). Built from the + // original targets list since membership is order-independent. + const quantumTargetPositions = new Set(); + for (const t of targets) { + if (t.result == null) { + quantumTargetPositions.add(qIdPosition[t.qubit]); + } + } + let prevPos = 0; let prevY = 0; @@ -494,14 +573,34 @@ const _splitTargetsY = ( const y = _getRegY(target, registers); const pos = qIdPosition[target.qubit]; + // A target on a classical sub-wire (`{qubit: Q, result: N}`) sits BELOW its parent qubit's + // quantum wire, so moving from a target at position P to a classical sub-wire at position P+1 + // visually crosses qubit P+1's quantum wire. If that wire isn't itself a quantum target, split + // so it passes through the gap. Generalized: split if any quantum-wire position strictly + // between `prevPos` and `pos` (inclusive of `pos` when `target.result != null`) isn't a quantum + // target. This subsumes rule 2, but rule 2's explicit check is kept above for clarity. + const upperPos = target.result == null ? pos - 1 : pos; + let crossesUntargetedQubit = false; + if (groups.length > 0) { + for (let p = prevPos + 1; p <= upperPos; p++) { + if (!quantumTargetPositions.has(p)) { + crossesUntargetedQubit = true; + break; + } + } + } + // Split into new group if one of the following holds: // 1. First target register // 2. Non-adjacent qubit registers // 3. There is a classical register between current and previous register + // 4. The body would visually cross an intermediate quantum wire that isn't itself a target + // (covers the classical-sub-wire-skips-a-quantum-row case) if ( groups.length === 0 || pos > prevPos + 1 || - (classicalRegY[0] > prevY && classicalRegY[0] < y) + (classicalRegY[0] > prevY && classicalRegY[0] < y) || + crossesUntargetedQubit ) groups.push([y]); else groups[groups.length - 1].push(y); @@ -518,17 +617,31 @@ const _splitTargetsY = ( }; /** - * Updates the x coord of each render data object in the given 2D array and returns rightmost x coord. + * Updates the x coord of each render data object in the given 2D array and returns layout info + * needed by `processOperations` to build the `LayoutMap`. + * + * In addition to setting each gate's center x, this function: + * - Returns `colStartX` (the per-column left-edge x in this scope's local coordinate system, + * anchored at `startX`). + * - For each expanded group encountered, shifts the group's `_childLayout` (set by + * `_processChildren`) from the child's local coords to absolute coords by applying the group's + * `offset`, then merges those absolute child scopes into a single `childScopes` map keyed by the + * child's parent op's location string. * * @param renderDataArray 2D array of render data. * @param columnWidths Array of column widths. * - * @returns Rightmost x coord. + * @returns `endX` (rightmost x coord), `colStartX` (per-column left-edge x), and `childScopes` + * (absolute scopes from expanded groups in this grid). */ const _fillRenderDataX = ( renderDataArray: GateRenderData[][], columnWidths: number[], -): number => { +): { + endX: number; + colStartX: number[]; + childScopes: Map; +} => { let endX: number = startX; const colStartX: number[] = columnWidths.map((width) => { @@ -537,6 +650,10 @@ const _fillRenderDataX = ( return x; }); + // Absolute scopes harvested from any expanded groups in this grid, including their own + // (already-shifted) descendant scopes. + const childScopes = new Map(); + renderDataArray.forEach((col, colIndex) => col.forEach((renderData) => { const x = colStartX[colIndex]; @@ -545,8 +662,8 @@ const _fillRenderDataX = ( switch (renderData.type) { case GateType.Group: { - // Center the group within the column, and offset nested child gates - // relative to the group's left edge (plus internal padding). + // Center the group within the column, and offset nested child gates relative to the + // group's left edge (plus internal padding). const groupLeftX = columnCenterX - renderData.width / 2; // Subtract startX offset from nested gates and add offset and padding @@ -558,6 +675,32 @@ const _fillRenderDataX = ( // Offset each x coord in children gates _offsetChildrenX(renderData.children, offset); + // LayoutMap accumulation: shift the child layout info (computed in the child's local + // coords) by `offset`, making it absolute. Key the child's `localScope` under *this* + // group's location — the scope key the editor looks up when emitting dropzones inside + // this group's body. Deeper scopes are in the same child-local system, so they get + // shifted by `offset` too. + const childLayout = renderData._childLayout; + if (childLayout != null) { + const myLocation = renderData.dataAttributes?.["location"]; + if (myLocation != null) { + childScopes.set(myLocation, { + columnXOffsets: childLayout.localScope.columnXOffsets.map( + (v) => v + offset, + ), + columnWidths: childLayout.localScope.columnWidths, + }); + for (const [key, scope] of childLayout.childScopes) { + childScopes.set(key, { + columnXOffsets: scope.columnXOffsets.map((v) => v + offset), + columnWidths: scope.columnWidths, + }); + } + } + // Clear the temp side-channel; nothing else reads it. + renderData._childLayout = undefined; + } + // Center gate in column renderData.x = columnCenterX; } @@ -571,7 +714,7 @@ const _fillRenderDataX = ( }), ); - return endX + gatePadding; + return { endX: endX + gatePadding, colStartX, childScopes }; }; /** @@ -624,6 +767,15 @@ function _processChildren( renderData.topPadding = childrenInstrs.maxTopPadding + groupTopPadding; renderData.bottomPadding = childrenInstrs.maxBottomPadding + groupBottomPadding; + + // Stash the child layout info on the parent's render data so that when the parent's + // `_fillRenderDataX` runs later, it can shift the child's local coords to absolute (using the + // group's `offset`) and merge them into the parent call's `childScopes` accumulator. See + // `_fillRenderDataX` for where this is consumed. + renderData._childLayout = { + localScope: childrenInstrs.localScope, + childScopes: childrenInstrs.childScopes, + }; } export { processOperations }; diff --git a/source/npm/qsharp/ux/circuit-vis/sqore.ts b/source/npm/qsharp/ux/circuit-vis/sqore.ts index a8af3b777b7..cabdde2db25 100644 --- a/source/npm/qsharp/ux/circuit-vis/sqore.ts +++ b/source/npm/qsharp/ux/circuit-vis/sqore.ts @@ -1,10 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { formatInputs } from "./formatters/inputFormatter.js"; -import { formatGates } from "./formatters/gateFormatter.js"; -import { formatRegisters } from "./formatters/registerFormatter.js"; -import { processOperations } from "./process.js"; +import { formatInputs } from "./renderer/formatters/inputFormatter.js"; +import { formatGates } from "./renderer/formatters/gateFormatter.js"; +import { formatRegisters } from "./renderer/formatters/registerFormatter.js"; +import { processOperations } from "./renderer/process.js"; import { Circuit, CircuitGroup, @@ -12,18 +12,19 @@ import { Operation, SourceLocation, Qubit, -} from "./circuit.js"; -import { GateRenderData } from "./gateRenderData.js"; +} from "./data/circuit.js"; +import { GateRenderData } from "./renderer/gateRenderData.js"; +import { LayoutMap, emptyLayoutMap } from "./renderer/layoutMap.js"; +import { Location } from "./data/location.js"; +import { ViewState } from "./data/viewState.js"; import { gateHeight, minGateWidth, minToolboxHeight, svgNS, -} from "./constants.js"; -import { createDropzones } from "./draggable.js"; -import { enableEvents } from "./events.js"; -import { createPanel, enableRunButton } from "./panel.js"; -import { getMinMaxRegIdx } from "./utils.js"; +} from "./renderer/constants.js"; +import { installEditor } from "./editor/installEditor.js"; +import { getOperationRegisters } from "./utils.js"; import type { StateColumn } from "./state-viz/stateViz.js"; import type { PrepareStateVizOptions } from "./state-viz/worker/stateVizPrep.js"; @@ -37,11 +38,16 @@ interface ComposedSqore { height: number; /** SVG elements the make up the visualization. */ elements: SVGElement[]; + /** + * Geometry from the layout pass. Captured here so the editor can position dropzones from the same + * numbers `processOperations` already computed, instead of reverse-engineering them from rendered + * SVG attributes. See [`layoutMap.ts`](renderer/layoutMap.ts). + */ + layoutMap: LayoutMap; } /** - * Defines the mapping of unique location to each operation. Used for enabling - * interactivity. + * Defines the mapping of unique location to each operation. Used for enabling interactivity. */ type GateRegistry = { [location: string]: Operation; @@ -51,9 +57,9 @@ export type EditorHandlers = { editCallback: (circuitGroup: CircuitGroup) => void; // When provided, enables the Run button in the toolbox. runCallback?: () => void; - // Optional callback to offload state visualization computation. - // When provided (e.g., by the VS Code webview), the state visualizer can - // compute state in a Web Worker without relying on globals. + // Optional callback to offload state visualization computation. When provided (e.g., by the VS + // Code webview), the state visualizer can compute state in a Web Worker without relying on + // globals. computeStateVizColumnsForCircuitModel?: ( model: Circuit, opts?: PrepareStateVizOptions, @@ -64,8 +70,8 @@ export type DrawOptions = { renderDepth?: number; renderLocations?: (l: SourceLocation[]) => { title: string; href: string }; /** - * When provided, enables editing behaviors (dropzones, run button, etc.) and - * requires the callbacks necessary to support those behaviors. + * When provided, enables editing behaviors (dropzones, run button, etc.) and requires the + * callbacks necessary to support those behaviors. */ editor?: EditorHandlers; /** @@ -84,6 +90,21 @@ export class Sqore { container: HTMLElement | null = null; zoomOnResize: boolean = true; zoomLevel: number = 100; + /** + * Per-session view preferences (e.g. user-toggled expand/collapse state). Survives every + * `renderCircuit` call but is intentionally NOT serialized into the saved circuit. See + * [`viewState.ts`](data/viewState.ts). + */ + readonly viewState: ViewState = new ViewState(); + /** + * Snapshot of `op object → location string` captured at the end of the most recent render, used + * to migrate `viewState` keys forward when ops shift position. See `rebaseViewState`. + * + * `null` means "no prior render yet" (first draw) or "the prior snapshot is no longer valid" + * (after `updateCircuit` replaces the underlying tree). In both cases the next render skips the + * rebase and just refreshes the snapshot. + */ + private lastLocationMap: Map | null = null; /** * Initializes Sqore object. * @@ -128,8 +149,41 @@ export class Sqore { } /** - * Window resize handler to recalculate and set the zoom level - * based on the new window width. + * Replace the underlying circuit and re-render in place, preserving everything that lives on + * `this` (most importantly `viewState`, but also the cached container, zoom level, and the + * editor's event registrations). + * + * Intended for hosts that receive **external** circuit updates — e.g. the VS Code editor parsing + * an `onDidChangeTextDocument` into a fresh `CircuitGroup`. Using this instead of a new `Sqore` + * preserves `viewState` (so user-expanded groups stay expanded) and avoids a re-render flicker. + * + * Hosts that want a fully clean instance (e.g. opening a different circuit in the same panel) + * should keep using `qviz.draw(...)`. + * + * @param circuitGroup The new circuit group to render. + */ + updateCircuit(circuitGroup: CircuitGroup): void { + if ( + circuitGroup == null || + circuitGroup.circuits == null || + circuitGroup.circuits.length === 0 + ) { + throw new Error(`No circuit found. Please provide a valid circuit.`); + } + this.circuitGroup = circuitGroup; + // We only render the first circuit in the group today; matches the constructor's behavior. + this.circuit = circuitGroup.circuits[0]; + // External replacement: the new circuit's op object identities have no relation to the prior + // tree. Drop the rebase snapshot so the next render doesn't try to migrate viewState against + // stale identities (which would silently drop every entry). + this.lastLocationMap = null; + if (this.container != null) { + this.renderCircuit(this.container); + } + } + + /** + * Window resize handler to recalculate and set the zoom level based on the new window width. */ private onResize() { if (!this.zoomOnResize) { @@ -167,13 +221,12 @@ export class Sqore { * Update the width of the SVG element based on the zoom level. */ updateSvgWidth(svg: SVGElement, zoomLevel: number) { - // The width attribute contains the true width. - // We'll leave this attribute untouched, so we can use it again if the - // zoom level is ever updated. + // The width attribute contains the true width. We'll leave this attribute untouched, so we can + // use it again if the zoom level is ever updated. const width = svg.getAttribute("width")!; - // We'll set the width in the style attribute to (true width * zoom level). - // This value takes precedence over the true width in the width attribute. + // We'll set the width in the style attribute to (true width * zoom level). This value takes + // precedence over the true width in the width attribute. svg.setAttribute( "style", `max-width: ${width}; width: ${(parseInt(width) * (zoomLevel || 100)) / 100}; height: auto`, @@ -198,27 +251,37 @@ export class Sqore { /** * Render circuit into `container`. * + * Always deep-copies `this.circuit` so the rendered grid can be mutated freely (location stamps, + * default-expand flags, ViewState overrides) without touching the saved circuit. + * * @param container HTML element for rendering visualization into. - * @param circuit Circuit object to be rendered. */ - private renderCircuit(container: HTMLElement, circuit?: Circuit): void { + private renderCircuit(container: HTMLElement): void { + // Migrate viewState keys to track ops whose locations shifted due to mutations between renders + // (drag-and-drop, gate insert, qubit-line edits, etc.). MUST run BEFORE the deep copy below, + // because the rebase compares op object identities against the live + // `this.circuit.componentGrid` — the JSON copy would break that identity link. + this.rebaseViewState(); + // Create copy of circuit to prevent mutation - const _circuit: Circuit = - circuit ?? JSON.parse(JSON.stringify(this.circuit)); + const _circuit: Circuit = JSON.parse(JSON.stringify(this.circuit)); // Assign unique locations to each operation _circuit.componentGrid.forEach((col, colIndex) => col.components.forEach((op, i) => - this.fillGateRegistry(op, `${colIndex},${i}`), + this.fillGateRegistry(op, Location.root().child(colIndex, i)), ), ); - // Expand operations to the specified render depth + // Apply default-expansion passes first — these match the original behavior for any op without + // an explicit user choice. this.expandOperationsToDepth(_circuit.componentGrid, this.renderDepth); - - // Auto-expand any groups with single children this.expandIfSingleOperation(_circuit.componentGrid); + // Apply user view-state overrides on top. Anything the user has explicitly expanded or + // collapsed wins over the defaults. + this.viewState.applyTo(_circuit.componentGrid); + // Create visualization components const composedSqore: ComposedSqore = this.compose(_circuit); const svg: SVGElement = this.generateSvg(composedSqore); @@ -237,19 +300,86 @@ export class Sqore { container.replaceChild(svg, previousSvg); } } - this.addGateClickHandlers(container, _circuit); + this.addGateClickHandlers(container); const editor = this.options.editor; const isEditable = editor != null; if (isEditable) { - createDropzones(container, this); - createPanel(container, editor.computeStateVizColumnsForCircuitModel); - if (editor.runCallback) { - enableRunButton(container, editor.runCallback); + installEditor(container, this, composedSqore.layoutMap, editor, () => + this.renderCircuit(container), + ); + } + + // Snapshot the live op → location map for the next render's rebase. Built from `this.circuit` + // (the live model), NOT the deep copy, so the op object identities here match the ones the + // editor's mutations will operate on between now and the next render. + this.lastLocationMap = this.buildLiveLocationMap( + this.circuit.componentGrid, + ); + } + + /** + * Walk `grid` in render order (the same `Location.root().child(...)` scheme `fillGateRegistry` + * uses) and build a map from each op object reference to its current location string. + * + * Walks the live model — callers must NOT pass a deep copy, since identity-based lookups are the + * point. + */ + private buildLiveLocationMap(grid: ComponentGrid): Map { + const map = new Map(); + const walk = (g: ComponentGrid, parent: Location): void => { + g.forEach((col, colIndex) => + col.components.forEach((op, opIndex) => { + const loc = parent.child(colIndex, opIndex); + map.set(op, loc.toString()); + if (op.children != null) { + walk(op.children, loc); + } + }), + ); + }; + walk(grid, Location.root()); + return map; + } + + /** + * Migrate `viewState` keys forward across mutations that may have shifted ops to new locations. + * + * Uses object identity against `this.lastLocationMap` (captured at the end of the previous + * render) so user expand/collapse choices follow their op when its string location changes — e.g. + * dragging a gate into column 0 shifts every other op's column index by 1. + * + * No-op on the first render (no prior snapshot) and after `updateCircuit` invalidates the + * snapshot. The rebase logic itself lives in [`ViewState.rebase`](data/viewState.ts). + */ + private rebaseViewState(): void { + const prev = this.lastLocationMap; + if (prev == null) return; + const next = this.buildLiveLocationMap(this.circuit.componentGrid); + + // Build a (prev-location → new-location) fallback map from any ops that carry a + // `sqore-prev-location` stamp. The stamp is set by [`moveOperation`](actions/circuitActions.ts) + // when it deep-clones the source op — the clone has a new object identity so the identity + // lookup against `next` would miss and drop the ViewState entry; the stamp lets us recover the + // choice by matching on the pre-move location. Consumed (deleted) here so it never leaks into + // the rendered SVG. + const prevLocationFallback = new Map(); + for (const [op, newLoc] of next) { + const stamp = op.dataAttributes?.["sqore-prev-location"]; + if (typeof stamp === "string") { + prevLocationFallback.set(stamp, newLoc); + delete op.dataAttributes!["sqore-prev-location"]; } - enableEvents(container, this, () => this.renderCircuit(container)); - editor.editCallback(this.minimizeCircuits(this.circuitGroup)); } + + // For every op we tracked at the last render, compute its old and new location. Build the + // (oldLoc → newLoc | null) remap that `ViewState.rebase` consumes. + const remap = new Map(); + for (const [op, oldLoc] of prev) { + const newLoc = next.get(op) ?? prevLocationFallback.get(oldLoc); + remap.set(oldLoc, newLoc ?? null); + } + this.viewState.rebase(remap); } private expandOperationsToDepth( @@ -281,10 +411,12 @@ export class Sqore { onlyComponent.dataAttributes, "location", ) && - onlyComponent.dataAttributes["expanded"] !== "false" + onlyComponent.dataAttributes["expanded"] !== "false" && + onlyComponent.children != null ) { - const location: string = onlyComponent.dataAttributes["location"]; - this.expandOperation(grid, location); + // We already have the only-component in hand, so set the attr directly rather than walking + // the grid for it. + onlyComponent.dataAttributes["expanded"] = "true"; } } // Recursively expand if the only child is also a single operation @@ -335,14 +467,14 @@ export class Sqore { const { qubits, componentGrid } = circuit; - // Calculate the row heights, which may vary depending on how many - // expanded group borders need to fit between qubit wires. + // Calculate the row heights, which may vary depending on how many expanded group borders need + // to fit between qubit wires. const rowHeights = getRowHeights(qubits, componentGrid); const isEditable = this.options.editor != null; - // Draw the qubit labels. - // Also calculate other register render data to be used later in the rendering. + // Draw the qubit labels. Also calculate other register render data to be used later in the + // rendering. const { qubitLabels, registers, svgHeight } = formatInputs( qubits, rowHeights, @@ -354,13 +486,28 @@ export class Sqore { const bottomY = qubits[qubits.length - 1] ? registers[qubits[qubits.length - 1].id].y : -1; - const { renderDataArray, svgWidth } = processOperations( - componentGrid, - topY, - bottomY, - registers, - isEditable ? undefined : this.options.renderLocations, - ); + const { renderDataArray, svgWidth, localScope, childScopes } = + processOperations( + componentGrid, + topY, + bottomY, + registers, + isEditable ? undefined : this.options.renderLocations, + ); + + // Assemble the LayoutMap from the layout pass. + // + // - The top-level scope is keyed by `""` (matches the existing `LayoutMap` convention; see + // [`layoutMap.ts`](renderer/layoutMap.ts)). + // - `childScopes` is already keyed by each parent op's location string, with absolute coords. + // - `wireYs` mirrors the y-coords of the real qubit wires before any editor chrome (e.g. the + // ghost qubit wire) is added. + const layoutMap: LayoutMap = emptyLayoutMap(); + layoutMap.scopes.set("", localScope); + for (const [key, scope] of childScopes) { + layoutMap.scopes.set(key, scope); + } + layoutMap.wireYs = qubits.map((q) => registers[q.id].y); // Draw the operations. const formattedGates: SVGElement = formatGates(renderDataArray); @@ -376,6 +523,7 @@ export class Sqore { width: svgWidth, height: svgHeight, elements: [qubitLabels, formattedRegs, formattedGates], + layoutMap, }; return composedSqore; } @@ -416,29 +564,33 @@ export class Sqore { } /** - * Depth-first traversal to assign unique location string to `operation`. - * The operation is assigned the location `location` and its `i`th child - * in its `colIndex` column is recursively given the location - * `${location}-${colIndex},${i}`. + * Depth-first traversal to assign a unique location string to `operation`. The operation is + * assigned `location.toString()` and its `i`th child in its `colIndex` column is recursively + * given `location.child(colIndex, i)`. * - * @param operation Operation to be assigned. - * @param location: Location to assign to `operation`. + * Takes a [`Location`](data/location.ts) value rather than a raw string, so the addressing format + * is owned by exactly one module. The string form is still what gets stored in + * `dataAttributes["location"]` / used as `gateRegistry` keys, since the rest of the codebase + * reads those as strings. * + * @param operation Operation to be assigned. + * @param location Hierarchical location to assign to `operation`. */ - private fillGateRegistry(operation: Operation, location: string): void { + private fillGateRegistry(operation: Operation, location: Location): void { if (operation.dataAttributes == null) operation.dataAttributes = {}; - operation.dataAttributes["location"] = location; + const locationStr = location.toString(); + operation.dataAttributes["location"] = locationStr; - // Note: `dataAttributes["expanded"]` is intentionally not defaulted here. - // Expansion is controlled by: + // Note: `dataAttributes["expanded"]` is intentionally not defaulted here. Expansion is + // controlled by: // - `renderDepth` (see `expandOperationsToDepth`), // - user interaction (expand/collapse), and - // - `expandIfSingleOperation`, which auto-expands a single top-level op - // unless it has been explicitly collapsed. - this.gateRegistry[location] = operation; + // - `expandIfSingleOperation`, which auto-expands a single top-level op unless it has been + // explicitly collapsed. + this.gateRegistry[locationStr] = operation; operation.children?.forEach((col, colIndex) => col.components.forEach((childOp, i) => { - this.fillGateRegistry(childOp, `${location}-${colIndex},${i}`); + this.fillGateRegistry(childOp, location.child(colIndex, i)); }), ); } @@ -447,20 +599,22 @@ export class Sqore { * Add interactive click handlers to circuit HTML elements. * * @param container HTML element containing visualized circuit. - * @param circuit Circuit to be visualized. * */ - private addGateClickHandlers(container: HTMLElement, circuit: Circuit): void { - this.addZoomHandlers(container, circuit); + private addGateClickHandlers(container: HTMLElement): void { + this.addZoomHandlers(container); } /** * Add interactive click handlers for expand/collapse functionality. * + * Each chevron click writes the user's choice into `this.viewState` and then re-renders. + * ViewState survives the deep-copy that happens inside `renderCircuit`, so the choice persists + * across editor mutations rather than being lost on the next refresh. + * * @param container HTML element containing visualized circuit. - * @param circuit Circuit to be visualized. */ - private addZoomHandlers(container: HTMLElement, circuit: Circuit): void { + private addZoomHandlers(container: HTMLElement): void { container.querySelectorAll(".gate .gate-control").forEach((ctrl) => { // Zoom in on clicked gate ctrl.addEventListener("click", (ev: Event) => { @@ -468,12 +622,12 @@ export class Sqore { ctrl.parentElement?.getAttribute("data-location"); if (typeof gateId == "string") { if (ctrl.classList.contains("gate-collapse")) { - this.collapseOperation(circuit.componentGrid, gateId); + this.viewState.setExpanded(gateId, false); } else if (ctrl.classList.contains("gate-expand")) { - this.expandOperation(circuit.componentGrid, gateId); + this.viewState.setExpanded(gateId, true); } this.zoomOnResize = false; - this.renderCircuit(container, circuit); + this.renderCircuit(container); ev.stopPropagation(); } @@ -481,57 +635,6 @@ export class Sqore { }); } - /** - * Expand selected composite operation. - * - * @param componentGrid Grid of circuit components. - * @param location Location of operation to expand. - */ - private expandOperation( - componentGrid: ComponentGrid, - location: string, - ): void { - componentGrid.forEach((col) => - col.components.forEach((op) => { - if (op.children != null) this.expandOperation(op.children, location); - if (op.dataAttributes == null) return op; - const opId: string = op.dataAttributes["location"]; - if (opId === location && op.children != null) { - op.dataAttributes["expanded"] = "true"; - } - }), - ); - } - - /** - * Collapse selected composite operation. - * - * @param componentGrid Grid of circuit components. - * @param parentLoc Location of operation to collapse. - */ - private collapseOperation( - componentGrid: ComponentGrid, - parentLoc: string, - ): void { - componentGrid.forEach((col) => - col.components.forEach((op) => { - if (op.children != null) this.collapseOperation(op.children, parentLoc); - if (op.dataAttributes == null) return op; - const opId: string = op.dataAttributes["location"]; - if (opId === parentLoc) { - // Explicitly collapse the targeted parent operation. - op.dataAttributes["expanded"] = "false"; - } else if (opId.startsWith(parentLoc)) { - // For descendants, remove the explicit expanded attribute rather than - // forcing it to "false". This allows default expansion rules (e.g. - // classically controlled groups default to expanded) to re-apply - // correctly when the parent is later re-expanded. - delete op.dataAttributes["expanded"]; - } - }), - ); - } - // Minimize the circuits in a circuit group to remove dataAttributes minimizeCircuits(circuitGroup: CircuitGroup): CircuitGroup { // Create a deep copy of the circuit group @@ -560,8 +663,12 @@ export class Sqore { /** * Recursively computes vertical space required to render group borders. * - * The resulting `heightAboveWire` and `heightBelowWire` values per qubit are - * later used by `formatInputs` to leave sufficient space between qubit wires. + * The resulting `heightAboveWire` and `heightBelowWire` values per qubit are later used by + * `formatInputs` to leave sufficient space between qubit wires. `heightAboveFirstClassical` is + * similar but applies to the gap between a qubit's wire and its first classical sub-wire — used to + * reserve room for the label of any classically-controlled group whose box top sits in that gap + * (the producing measurement's classical sub-wire is the group's `controlY`, and the label lives + * just above it). * * @param qubits Array of qubits in the circuit. * @param componentGrid Grid of circuit components to traverse. @@ -575,28 +682,40 @@ function getRowHeights( [qubitIndex: number]: { heightAboveWire: number; heightBelowWire: number; + heightAboveFirstClassical: number; + bottomBordersAboveFirstClassical: number; }; } { const rowHeights: { [qubitIndex: number]: { currentGroupBordersAboveWire: number; currentGroupBordersBelowWire: number; + currentClassicalGroupsAboveFirstClassical: number; + currentBottomBordersAboveFirstClassical: number; heightAboveWire: number; heightBelowWire: number; + heightAboveFirstClassical: number; + bottomBordersAboveFirstClassical: number; }; } = {}; + const numResultsByQubit: { [qubitIndex: number]: number } = {}; for (const q of qubits) { const { id } = q; rowHeights[id] = { currentGroupBordersBelowWire: 0, currentGroupBordersAboveWire: 0, + currentClassicalGroupsAboveFirstClassical: 0, + currentBottomBordersAboveFirstClassical: 0, heightBelowWire: 0, heightAboveWire: 0, + heightAboveFirstClassical: 0, + bottomBordersAboveFirstClassical: 0, }; + numResultsByQubit[id] = q.numResults ?? 0; } - updateRowHeights(componentGrid, rowHeights, qubits.length); + updateRowHeights(componentGrid, rowHeights, numResultsByQubit); return rowHeights; } @@ -606,51 +725,130 @@ function updateRowHeights( [qubitIndex: number]: { currentGroupBordersAboveWire: number; currentGroupBordersBelowWire: number; + currentClassicalGroupsAboveFirstClassical: number; + currentBottomBordersAboveFirstClassical: number; heightAboveWire: number; heightBelowWire: number; + heightAboveFirstClassical: number; + bottomBordersAboveFirstClassical: number; }; }, - numQubits: number, + numResultsByQubit: { [qubitIndex: number]: number }, ) { for (const col of componentGrid) { for (const component of col.components) { if (isExpandedGroup(component)) { - // We're in an expanded group. There is a dashed border above - // the top qubit, and below the bottom qubit. - const [topQubit, bottomQubit] = getMinMaxRegIdx(component); - - // Increment the current count of dashed group borders for - // the top and bottom rows for this operation. - // If the max height for this row has been exceeded above or below the wire, - // update it. - rowHeights[topQubit].currentGroupBordersAboveWire++; - rowHeights[topQubit].heightAboveWire = Math.max( - rowHeights[topQubit].heightAboveWire, - rowHeights[topQubit].currentGroupBordersAboveWire, + // The group's dashed box top is anchored at the topmost reg's y and the bottom at the + // bottommost reg's y. Each border bumps a row-height counter chosen by which layout row its + // y lands in: + // + // - Pure qubit ref `{q}`, q has no classical sub-wires → gap above/below q's wire + // (`heightAboveWire` / `heightBelowWire`). + // - Pure qubit ref `{q}`, q has classical sub-wires → top goes to `heightAboveWire`; + // bottom lands in the gap before q's first classical sub-wire + // (`bottomBordersAboveFirstClassical`). + // - Classical sub-wire ref `{q, r}` → top lands in that gap + // (`heightAboveFirstClassical`); bottom goes to `heightBelowWire`. + // + // The two "above first classical" counters differ because top borders carry labels (stack + // at `groupTopPadding`) while bottom borders don't (stack at `groupBottomPadding`). + const regs = getOperationRegisters(component); + if (regs.length === 0) continue; + + const qubits = regs.map((r) => r.qubit); + const minQubit = Math.min(...qubits); + const maxQubit = Math.max(...qubits); + + // For minQubit: the *topmost* anchor ref is a pure qubit ref if one exists on minQubit + // (pure refs sit above any classical sub-wires); otherwise it's a classical ref. + const minQubitHasPureRef = regs.some( + (r) => r.qubit === minQubit && r.result == null, ); - rowHeights[bottomQubit].currentGroupBordersBelowWire++; - rowHeights[bottomQubit].heightBelowWire = Math.max( - rowHeights[bottomQubit].heightBelowWire, - rowHeights[bottomQubit].currentGroupBordersBelowWire, + // For maxQubit: the *bottommost* anchor ref is a classical ref if any exist on maxQubit + // (classical sub-wires sit below the qubit wire); otherwise it's the pure qubit ref. + const maxQubitHasClassicalRef = regs.some( + (r) => r.qubit === maxQubit && r.result != null, ); + // Track which counters we bumped so we can decrement after recursion. + let bumpedAboveWireQ: number | null = null; + let bumpedTopFirstClassicalQ: number | null = null; + let bumpedBottomFirstClassicalQ: number | null = null; + let bumpedBelowWireQ: number | null = null; + + // Top border placement + if (minQubitHasPureRef) { + rowHeights[minQubit].currentGroupBordersAboveWire++; + rowHeights[minQubit].heightAboveWire = Math.max( + rowHeights[minQubit].heightAboveWire, + rowHeights[minQubit].currentGroupBordersAboveWire, + ); + bumpedAboveWireQ = minQubit; + } else { + rowHeights[minQubit].currentClassicalGroupsAboveFirstClassical++; + rowHeights[minQubit].heightAboveFirstClassical = Math.max( + rowHeights[minQubit].heightAboveFirstClassical, + rowHeights[minQubit].currentClassicalGroupsAboveFirstClassical, + ); + bumpedTopFirstClassicalQ = minQubit; + } + + // Bottom border placement + if ( + !maxQubitHasClassicalRef && + (numResultsByQubit[maxQubit] ?? 0) > 0 + ) { + // Bottom anchor is a pure qubit ref on a qubit that has classical sub-wires below it. The + // border y sits in the gap between maxQubit's wire and its first classical sub-wire, but + // unlike top borders has no label, so it uses the smaller bottom-border counter. + rowHeights[maxQubit].currentBottomBordersAboveFirstClassical++; + rowHeights[maxQubit].bottomBordersAboveFirstClassical = Math.max( + rowHeights[maxQubit].bottomBordersAboveFirstClassical, + rowHeights[maxQubit].currentBottomBordersAboveFirstClassical, + ); + bumpedBottomFirstClassicalQ = maxQubit; + } else { + rowHeights[maxQubit].currentGroupBordersBelowWire++; + rowHeights[maxQubit].heightBelowWire = Math.max( + rowHeights[maxQubit].heightBelowWire, + rowHeights[maxQubit].currentGroupBordersBelowWire, + ); + bumpedBelowWireQ = maxQubit; + } + // recurse - updateRowHeights(component.children || [], rowHeights, numQubits); + updateRowHeights( + component.children || [], + rowHeights, + numResultsByQubit, + ); - // decrement - rowHeights[topQubit].currentGroupBordersAboveWire--; - rowHeights[bottomQubit].currentGroupBordersBelowWire--; + // decrement (mirror the bumps above) + if (bumpedAboveWireQ != null) { + rowHeights[bumpedAboveWireQ].currentGroupBordersAboveWire--; + } + if (bumpedTopFirstClassicalQ != null) { + rowHeights[bumpedTopFirstClassicalQ] + .currentClassicalGroupsAboveFirstClassical--; + } + if (bumpedBottomFirstClassicalQ != null) { + rowHeights[bumpedBottomFirstClassicalQ] + .currentBottomBordersAboveFirstClassical--; + } + if (bumpedBelowWireQ != null) { + rowHeights[bumpedBelowWireQ].currentGroupBordersBelowWire--; + } } } } } /** - * An "expanded group" here is any operation that is to be rendered showing - * its children, with a dashed box around the children. + * An "expanded group" here is any operation that is to be rendered showing its children, with a + * dashed box around the children. */ -function isExpandedGroup(component: Operation) { +export function isExpandedGroup(component: Operation) { const expandedAttr = component.dataAttributes?.["expanded"]; if (expandedAttr != null) { return expandedAttr === "true"; diff --git a/source/npm/qsharp/ux/circuit-vis/state-viz/stateViz.ts b/source/npm/qsharp/ux/circuit-vis/state-viz/stateViz.ts index 15b3a5984cc..928aef93bda 100644 --- a/source/npm/qsharp/ux/circuit-vis/state-viz/stateViz.ts +++ b/source/npm/qsharp/ux/circuit-vis/state-viz/stateViz.ts @@ -1,16 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -// State visualization renderer. -// Defines the renderable column/types and updates the `.state-panel` DOM -// (SVG/HTML) to display probabilities/phases, given either an amp map or -// pre-prepared columns. +// State visualization renderer. Defines the renderable column/types and updates the `.state-panel` +// DOM (SVG/HTML) to display probabilities/phases, given either an amp map or pre-prepared columns. export type RenderOptions = { maxColumns?: number; phaseColorMap?: (phaseRad: number) => string; - // Fill color for the aggregated "Others" column. - // Default comes from VIZ.defaultOthersColor. + // Fill color for the aggregated "Others" column. Default comes from VIZ.defaultOthersColor. othersColor?: string; minColumnWidth?: number; // minimum width per column to avoid label collisions minPanelWidthPx?: number; // prescribed minimum panel width in pixels @@ -67,8 +64,8 @@ const VIZ = { export const createStatePanel = (): HTMLElement => { try { - // Allows host environments (e.g., VS Code webview) to react to panel creation - // without needing a direct import hook. + // Allows host environments (e.g., VS Code webview) to react to panel creation without needing a + // direct import hook. (globalThis as any).dispatchEvent?.( new CustomEvent("qsharp:stateviz:create"), ); @@ -178,8 +175,8 @@ export const renderMessageStatePanel = ( panel.style.flexBasis = `${VIZ.emptyStateFlexBasisPx}px`; }; -// Put the panel into a blank (non-message) state. -// This is used when the circuit is non-empty but state data isn't available yet. +// Put the panel into a blank (non-message) state. This is used when the circuit is non-empty but +// state data isn't available yet. export const renderBlankStatePanel = (panel: HTMLElement): void => { const svg = panel.querySelector("svg.state-svg") as SVGSVGElement | null; if (svg) { @@ -188,8 +185,8 @@ export const renderBlankStatePanel = (panel: HTMLElement): void => { panel.classList.remove("message"); const msg = panel.querySelector(".state-panel-message"); if (msg) msg.remove(); - // If we were previously in message state, restore sizing control back to CSS - // (or subsequent renders) instead of keeping the message-state flex-basis. + // If we were previously in message state, restore sizing control back to CSS (or subsequent + // renders) instead of keeping the message-state flex-basis. panel.style.flexBasis = ""; }; @@ -199,8 +196,8 @@ export const updateStatePanelFromColumns = ( columns: StateColumn[], opts: RenderOptions = {}, ): void => { - // Content visibility restored via CSS when not in empty mode - // Remove empty mode to reveal content via CSS + // Content visibility restored via CSS when not in empty mode Remove empty mode to reveal content + // via CSS panel.classList.remove("message"); const msg = panel.querySelector(".state-panel-message"); if (msg) msg.remove(); @@ -248,10 +245,10 @@ type LayoutMetrics = { phaseColor: (phi: number) => string; }; -// The animation speed for the state viz panel can be set via the passed in options -// argument, or via CSS custom property `--stateAnimMs` on the panel element. -// This function computes the effective animation duration in milliseconds from -// these sources, with the CSS value taking precedence over the options argument. +// The animation speed for the state viz panel can be set via the passed in options argument, or via +// CSS custom property `--stateAnimMs` on the panel element. This function computes the effective +// animation duration in milliseconds from these sources, with the CSS value taking precedence over +// the options argument. const getAnimationMs = (panel: HTMLElement, opts: RenderOptions): number => { let animationMs = VIZ.defaultAnimationMs; if ( diff --git a/source/npm/qsharp/ux/circuit-vis/state-viz/stateVizController.ts b/source/npm/qsharp/ux/circuit-vis/state-viz/stateVizController.ts index e30fb814f6f..3750f7c0ff2 100644 --- a/source/npm/qsharp/ux/circuit-vis/state-viz/stateVizController.ts +++ b/source/npm/qsharp/ux/circuit-vis/state-viz/stateVizController.ts @@ -1,13 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -// State visualization controller for the circuit side panel. -// Responsible for: ensuring the state panel exists, coordinating async state -// computation + rendering, suppressing stale renders, and managing the loading -// spinner/dev toolbar wiring. +// State visualization controller for the circuit side panel. Responsible for: ensuring the state +// panel exists, coordinating async state computation + rendering, suppressing stale renders, and +// managing the loading spinner/dev toolbar wiring. -// Here is a general overview of the flow for the state visualization: -// panel.ts +// Here is a general overview of the flow for the state visualization: panel.ts // └─ ensureStateVisualization(...) // └─ stateVizController.ts (loading spinner, request-id cancellation, retries) // ├─ computeStateVizColumnsFromCurrentModelAsync(...) @@ -32,8 +30,8 @@ import { setStatePanelLoading, } from "./stateViz.js"; -import { getCurrentCircuitModel } from "../events.js"; -import type { Circuit } from "../circuit.js"; +import { getCurrentCircuitModel } from "../editor/events.js"; +import type { Circuit } from "../data/circuit.js"; import { computeAmpMapForCircuit, UnsupportedStateComputeError, @@ -78,9 +76,9 @@ export function ensureStateVisualization( | undefined; if (existingController) { - // Sqore calls createPanel() on every edit (it re-renders the SVG). Keep a - // single state-viz controller per panel element so in-flight renders from - // previous calls can't toggle loading off underneath new renders. + // Sqore calls createPanel() on every edit (it re-renders the SVG). Keep a single state-viz + // controller per panel element so in-flight renders from previous calls can't toggle loading + // off underneath new renders. existingController.setHostContainer(container); existingController.setComputeCallback( computeStateVizColumnsForCircuitModel, @@ -89,8 +87,7 @@ export function ensureStateVisualization( return; } - // Captured, mutable inputs that can be updated by future calls to - // ensureStateVisualization(...). + // Captured, mutable inputs that can be updated by future calls to ensureStateVisualization(...). let hostContainer: HTMLElement = container; let computeCallback: ComputeStateVizColumnsForCircuitModel | undefined = computeStateVizColumnsForCircuitModel; @@ -142,8 +139,8 @@ export function ensureStateVisualization( return; } - // Avoid flicker: once visible, keep loading on briefly, and debounce the - // hide so rapid successive edits don't flash the spinner. + // Avoid flicker: once visible, keep loading on briefly, and debounce the hide so rapid + // successive edits don't flash the spinner. const minVisibleMs = 250; const hideDebounceMs = 150; const elapsed = performance.now() - (loadingShownAtMs || 0); @@ -163,9 +160,8 @@ export function ensureStateVisualization( try { beginLoadingForRequest(requestId); - // If we were previously showing a message (e.g., unsupported/too many - // qubits), clear it immediately so the loading overlay can be shown while - // the new request is computing. + // If we were previously showing a message (e.g., unsupported/too many qubits), clear it + // immediately so the loading overlay can be shown while the new request is computing. if (panel.classList.contains("message")) { renderBlankStatePanel(panel); } @@ -187,10 +183,9 @@ export function ensureStateVisualization( if (requestId !== renderRequestId) return; if (columns == null) { - // Model isn't ready for this render yet (events not enabled), or the - // model corresponds to a different SVG (during a re-render). Keep the - // panel blank for non-empty circuits so the loading overlay can show; - // show a message state only when the circuit is truly empty. + // Model isn't ready for this render yet (events not enabled), or the model corresponds to a + // different SVG (during a re-render). Keep the panel blank for non-empty circuits so the + // loading overlay can show; show a message state only when the circuit is truly empty. const wiresGroup = circuitSvg?.querySelector(".wires"); const wireCount = wiresGroup ? wiresGroup.children.length : 0; if (wireCount <= 0) { @@ -245,9 +240,9 @@ export function ensureStateVisualization( }, } satisfies StateVizController; - // Re-render when the circuit model becomes available. The circuit SVG is - // replaced before `enableEvents(...)` runs, so computing state immediately in - // `createPanel(...)` would otherwise risk using a stale model. + // Re-render when the circuit model becomes available. The circuit SVG is replaced before + // `enableEvents(...)` runs, so computing state immediately in `createPanel(...)` would otherwise + // risk using a stale model. try { container.addEventListener("qsharp:circuit:modelReady", () => { requestRenderState(); diff --git a/source/npm/qsharp/ux/circuit-vis/state-viz/worker/index.ts b/source/npm/qsharp/ux/circuit-vis/state-viz/worker/index.ts index 5ed18bc4599..52d6ec70fa5 100644 --- a/source/npm/qsharp/ux/circuit-vis/state-viz/worker/index.ts +++ b/source/npm/qsharp/ux/circuit-vis/state-viz/worker/index.ts @@ -1,8 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// Worker-safe exports for state visualization. -// Intentionally avoids importing UI modules (DOM/CSS). +// Worker-safe exports for state visualization. Intentionally avoids importing UI modules (DOM/CSS). export { computeAmpMapForCircuit, @@ -15,4 +14,4 @@ export { type PrepareStateVizOptions, } from "./stateVizPrep.js"; -export type { Circuit as CircuitModel } from "../../circuit.js"; +export type { Circuit as CircuitModel } from "../../data/circuit.js"; diff --git a/source/npm/qsharp/ux/circuit-vis/state-viz/worker/stateCompute.ts b/source/npm/qsharp/ux/circuit-vis/state-viz/worker/stateCompute.ts index f148b578b7a..778dbf4d7fe 100644 --- a/source/npm/qsharp/ux/circuit-vis/state-viz/worker/stateCompute.ts +++ b/source/npm/qsharp/ux/circuit-vis/state-viz/worker/stateCompute.ts @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -// Core state computation for circuit-vis. -// Implements a small statevector simulator that evaluates the circuit model and -// produces an amplitude map. Intentionally avoids DOM/visualization concerns so -// it can run on the main thread or in a Web Worker. +// Core state computation for circuit-vis. Implements a small statevector simulator that evaluates +// the circuit model and produces an amplitude map. Intentionally avoids DOM/visualization concerns +// so it can run on the main thread or in a Web Worker. -import type { ComponentGrid, Operation, Qubit } from "../../circuit.js"; +import type { ComponentGrid, Operation, Qubit } from "../../data/circuit.js"; import { evaluateAngleExpression } from "../../angleExpression.js"; // This holds the complex amplitudes of the different basis states. diff --git a/source/npm/qsharp/ux/circuit-vis/utils.ts b/source/npm/qsharp/ux/circuit-vis/utils.ts index e475f34dd96..d34d8d119ac 100644 --- a/source/npm/qsharp/ux/circuit-vis/utils.ts +++ b/source/npm/qsharp/ux/circuit-vis/utils.ts @@ -1,16 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { GateRenderData, GateType } from "./gateRenderData.js"; -import { - minGateWidth, - labelPaddingX, - labelFontSize, - argsFontSize, - controlCircleOffset, -} from "./constants.js"; -import { ComponentGrid, Operation } from "./circuit.js"; -import { Register } from "./register.js"; +import { ComponentGrid, Operation } from "./data/circuit.js"; +import { Location } from "./data/location.js"; +import { Register } from "./data/register.js"; /** * Performs a deep equality check between two objects or arrays. @@ -51,113 +44,23 @@ const deepEqual = (obj1: unknown, obj2: unknown): boolean => { }; /** - * Calculate the width of a gate, given its render data. + * Find targets of an operation's children by recursively walking through all of its children's + * controls, targets, and (for measurements) qubits + results. Note that this intentionally ignores + * the direct targets of `operation` itself; it's the union of the *descendants'* register sets. * - * @param renderData - The rendering data of the gate, including its type, label, display arguments. + * Used by the action layer to refresh a group's cached `.targets`/`.results` after its children + * have been mutated (see `moveOperation` and `_pruneEmptyAncestors` in + * [`actions/circuitActions.ts`](actions/circuitActions.ts)). * - * @returns Width of given gate (in pixels). - */ -const getMinGateWidth = ({ - type, - label, - displayArgs, - classicalControlIds, -}: GateRenderData): number => { - switch (type) { - case GateType.Measure: - case GateType.Cnot: - case GateType.Swap: - return minGateWidth; - default: { - // Classically controlled gates are wider because of the control button on the left - const controlButtonWidth = - classicalControlIds != null ? controlCircleOffset : 0; - const labelWidth = _getStringWidth(label); - const argsWidth = - displayArgs != null ? _getStringWidth(displayArgs, argsFontSize) : 0; - const textWidth = Math.max(labelWidth, argsWidth) + labelPaddingX * 2; - return Math.max(minGateWidth, textWidth) + controlButtonWidth; - } - } -}; - -/** - * Estimate string width in pixels based on character types and font size. - * This may not match the true rendered width, but should be close enough for - * calculating layout. - * - * @param text - The text string to measure. - * @param fontSize - The font size in pixels (default is labelFontSize). - * - * @returns Estimated width of the string in pixels. - */ -const _getStringWidth = ( - text: string, - fontSize: number = labelFontSize, -): number => { - let units = 0; - for (const ch of Array.from(text)) { - if (ch === " ") { - units += 0.33; - continue; - } - if ("il.:;,'`!|".includes(ch)) { - units += 0.28; - continue; - } - if ("mw".includes(ch)) { - units += 0.72; - continue; - } - if ("MW@#%&".includes(ch)) { - units += 0.78; - continue; - } - if (/[0-9]/.test(ch)) { - units += 0.55; - continue; - } - if (/[A-Z]/.test(ch)) { - units += 0.56; - continue; - } - if (/[a-z]/.test(ch)) { - units += 0.5; - continue; - } - if (/[θπ]/.test(ch)) { - units += 0.56; - continue; - } - if (/[ψ]/.test(ch)) { - units += 0.6; - continue; - } - if ("-+*/=^~_<>".includes(ch)) { - units += 0.5; - continue; - } - units += 0.56; - } - const kerningFudge = Math.max(0, text.length - 1) * 0.005; - // Round to a whole number to keep it easy to read - return Math.floor((units + kerningFudge) * fontSize); -}; - -/** - * Find targets of an operation's children by recursively walking - * through all of its children's controls and targets. - * Note that this intentionally ignores the direct targets of the - * operation itself. - * - * Example: - * Gate Foo contains gate H and gate RX. - * qIds of Gate H is 1 - * qIds of Gate RX are 1, 2 - * This should return [{qId: 1}, {qId: 2}] + * Output registers are deduplicated by **full register identity** — the `(qubit, result)` tuple — + * not by `qubit` alone. A bare-qubit ref `{qubit: 0}` and a classical-register ref `{qubit: 0, + * result: 0}` are distinct and both survive if both appear among the descendants. Preserving + * `result` keeps the classical-control visual-extent line that classically-conditional unitaries + * record in `targets` from being lost on refresh. * * @param operation The operation to find targets for. - * @returns An array of registers with unique qIds. + * @returns An array of registers with unique `(qubit, result)` identities; `result` is preserved + * when present. */ const getChildTargets = (operation: Operation): Register[] | [] => { const _recurse = (operation: Operation) => { @@ -197,33 +100,28 @@ const getChildTargets = (operation: Operation): Register[] | [] => { }), ); - // Extract qIds from array of object - // i.e. [{qId: 0}, {qId: 1}, {qId: 1}] -> [0, 1, 1] - const qIds = registers.map((register) => register.qubit); - const uniqueQIds = Array.from(new Set(qIds)); - - // Transform array of numbers into array of qId object - // i.e. [0, 1] -> [{qId: 0}, {qId: 1}] - return uniqueQIds.map((qId) => ({ qubit: qId })); -}; - -/** - * Split a location string into an array of index tuples. - * - * Example: - * "0,1-0,2-2,3" -> [[0,1], [0,2], [2,3]] - * - * @param location The location string to split. - * @returns An array of indexes. - */ -const locationStringToIndexes = (location: string): [number, number][] => { - return location !== "" - ? location.split("-").map((segment) => { - const coords = segment.split(","); - if (coords.length !== 2) throw new Error("Invalid location"); - return [parseInt(coords[0]), parseInt(coords[1])]; - }) - : []; + // Dedup by full register identity (qubit + result). `undefined` result and an explicit result are + // distinct register kinds (see dedup contract in the doc comment); we use a unique sentinel in + // the key to avoid collisions like `qubit=0, result=undefined` vs `qubit=0:undefined-as-string`. + const seen = new Set(); + const out: Register[] = []; + for (const reg of registers) { + const key = + reg.result === undefined + ? `${reg.qubit}:q` + : `${reg.qubit}:c${reg.result}`; + if (seen.has(key)) continue; + seen.add(key); + // Rebuild fresh objects rather than aliasing the descendants' own register references — callers + // assign the returned array straight into `parent.targets`/`.results`, and we don't want a + // later mutation on a child's register to mutate the parent's cached extent. + out.push( + reg.result === undefined + ? { qubit: reg.qubit } + : { qubit: reg.qubit, result: reg.result }, + ); + } + return out; }; /** @@ -238,13 +136,65 @@ const getGateLocationString = (operation: Operation): string | null => { }; /** - * Get the minimum and maximum register indices for a given operation. + * Get the minimum and maximum drawn-row indices for a given operation. Bare qubit row `q` is index + * `q`; classical-result rows (registers with `.result !== undefined`) sit BELOW their owning qubit + * row and are encoded as `q + 0.5` so the inclusive range correctly distinguishes them from a + * quantum entry at `q + 1` and from a quantum entry at `q` itself. + * + * Used exclusively for sibling-overlap checks (`_doesOverlap`) in the action layer. Callers that + * need integer wire indices should use `getQuantumWireRange` instead. * - * @param operation The operation for which to get the register indices. - * @param numQubits The number of qubits in the circuit. - * @returns A tuple containing the minimum and maximum register indices. + * @param operation The operation for which to get the row indices. + * @returns A tuple containing the minimum and maximum row indices. */ function getMinMaxRegIdx(operation: Operation): [number, number] { + // Classical-register rows sit immediately below their owning qubit row (between qubit `q` and + // qubit `q+1`). Encode them as `q + 0.5` so the inclusive-range overlap check correctly + // distinguishes a quantum row at `q` from a classical row of `q`. + const rows = getOperationRegisters(operation).map((r) => + r.result !== undefined ? r.qubit + 0.5 : r.qubit, + ); + const minRegIdx: number = Math.min(...rows); + const maxRegIdx: number = Math.max(...rows); + + return [minRegIdx, maxRegIdx]; +} + +/** + * Like `getMinMaxRegIdx`, but excludes classical-control registers (those whose `.result` is set). + * The qubit field of a classical control points at the producing measurement's qubit wire, which + * isn't really "part of" the consumer op's body — it's just a back-reference used to draw the + * connector down to the classical wire row. + * + * Use this for any decision about which wires belong to an op's editable scope: child-drop scope of + * an expanded group, shift-extend reach of a parent group, multi-leg drop targets for a selected + * op. Using `getMinMaxRegIdx` for those would wrongly sweep in the producing measurement's qubit + * wire. + * + * Returns `[-1, -1]` if the op has no quantum-only registers (shouldn't happen for any valid op, + * but defensive). + */ +const getQuantumWireRange = (operation: Operation): [number, number] => { + const qRegs = getOperationRegisters(operation).filter( + ({ result }) => result === undefined, + ); + if (qRegs.length === 0) return [-1, -1]; + const qRegIdxList = qRegs.map(({ qubit }) => qubit); + return [Math.min(...qRegIdxList), Math.max(...qRegIdxList)]; +}; + +/** + * Get every `Register` referenced by an operation, including both its controls and its + * targets/qubits/results. Returned references are the live objects on the operation, so callers may + * mutate `reg.qubit` / `reg.result` in place to renumber wires. + * + * Mirrors the union that `getMinMaxRegIdx` walks; centralized here so the action layer and the data + * layer don't each re-spell the per-`kind` switch. + * + * @param operation The operation to enumerate registers for. + * @returns All registers (controls + targets/qubits/results) of `operation`. + */ +const getOperationRegisters = (operation: Operation): Register[] => { let targets: Register[]; let controls: Register[]; switch (operation.kind) { @@ -261,45 +211,101 @@ function getMinMaxRegIdx(operation: Operation): [number, number] { controls = []; break; } - - const qRegs = [...controls, ...targets].map(({ qubit }) => qubit); - const minRegIdx: number = Math.min(...qRegs); - const maxRegIdx: number = Math.max(...qRegs); - - return [minRegIdx, maxRegIdx]; -} - -/********************** - * Finder Functions * - **********************/ + return [...controls, ...targets]; +}; /** - * Find the surrounding gate element of a host element. + * Vertical extent of an op on the wire grid, expressed as a pair of `Register` endpoints. Either + * endpoint may land on a qubit row (no `.result`) or on a classical-result row (`.result` set). + * + * Geometry. Classical-result rows sit immediately BELOW their owning qubit row — the rendered stack + * on a qubit `q_c` with results `r0..rN` reads, top to bottom: + * + * q_c, q_c.r0, q_c.r1, ..., q_c.rN, q_(c+1), ... + * + * Endpoints are compared by `.qubit` first, then by `.result` (with `undefined` sorting above any + * concrete result index, i.e. the bare qubit row sits above its own classical-result rows). + * + * The returned endpoints are FRESH register objects (not aliases of the op's own registers) so + * callers can keep them around without risking mutation of the source op. + * + * Returns `null` if the op carries no registers at all (defensive — no valid op should hit this). * - * @param hostElem The SVG element representing the host element. - * @returns The surrounding gate element or null if not found. + * @param operation The operation to measure. + * @returns `[min, max]` register endpoints, or `null` if empty. */ -const findGateElem = (hostElem: SVGElement): SVGElement | null => { - return hostElem.closest("[data-location]"); +const getWireRange = (operation: Operation): [Register, Register] | null => { + const regs = getOperationRegisters(operation); + if (regs.length === 0) return null; + // `result === undefined` sorts ABOVE any concrete result index on the same qubit (bare qubit row + // sits above its r0 row). Encode `undefined` as -1 for the comparison key. + const key = (r: Register) => r.result ?? -1; + let minReg = regs[0]; + let maxReg = regs[0]; + for (let i = 1; i < regs.length; i++) { + const r = regs[i]; + if ( + r.qubit < minReg.qubit || + (r.qubit === minReg.qubit && key(r) < key(minReg)) + ) { + minReg = r; + } + if ( + r.qubit > maxReg.qubit || + (r.qubit === maxReg.qubit && key(r) > key(maxReg)) + ) { + maxReg = r; + } + } + // Copy so callers can't mutate the op's own registers. + const copy = (r: Register): Register => + r.result === undefined + ? { qubit: r.qubit } + : { qubit: r.qubit, result: r.result }; + return [copy(minReg), copy(maxReg)]; }; +/********************** + * Finder Functions * + * *********************/ + /** - * Find the location of the gate surrounding a host element. + * Walk a path of `[colIdx, opIdx]` segments from a root grid down through nested operation + * children, returning the grid reached at the end. * - * @param hostElem The SVG element representing the host element. - * @returns The location string of the surrounding gate or null if not found. + * Returns `null` if any segment is out of bounds — for example because the model has changed since + * the location was captured (a stale `data-location` attribute on a DOM node, or a hand-constructed + * location that addresses an op that no longer exists). + * + * Note: matches the long-standing semantic that an interior op missing a `children` array does + * *not* fail the walk; the walk stays on the same grid for that step. Out-of-bounds is the only + * thing that produces `null`. */ -const findLocation = (hostElem: SVGElement) => { - const gateElem = findGateElem(hostElem); - return gateElem != null ? gateElem.getAttribute("data-location") : null; +const _walkToGrid = ( + componentGrid: ComponentGrid, + segments: ReadonlyArray, +): ComponentGrid | null => { + let grid = componentGrid; + for (const [colIdx, opIdx] of segments) { + const col = grid[colIdx]; + if (col == null) return null; + const op = col.components[opIdx]; + if (op == null) return null; + grid = op.children ?? grid; + } + return grid; }; /** * Find the parent operation of the operation specified by location. * + * Navigates via [`Location`](data/location.ts) so the addressing format is owned by exactly one + * module. + * * @param componentGrid The grid of components to search through. * @param location The location string of the operation. - * @returns The parent operation or null if not found. + * @returns The parent operation, or `null` if the location is empty, shallower than two segments, + * or addresses an op that does not exist. */ const findParentOperation = ( componentGrid: ComponentGrid, @@ -307,51 +313,52 @@ const findParentOperation = ( ): Operation | null => { if (!location) return null; - const indexes = locationStringToIndexes(location); - indexes.pop(); - const lastIndex = indexes.pop(); + const parsed = Location.parse(location); + // Need at least two segments: one for the op itself, one for its parent. + if (parsed.depth < 2) return null; - if (lastIndex == null) return null; + const parentOpLocation = parsed.parent(); + const parentOpSegment = parentOpLocation.last(); + if (parentOpSegment == null) return null; - let parentOperation = componentGrid; - for (const index of indexes) { - parentOperation = - parentOperation[index[0]].components[index[1]].children || - parentOperation; - } - return parentOperation[lastIndex[0]].components[lastIndex[1]]; + const grid = _walkToGrid(componentGrid, parentOpLocation.parent().segments); + if (grid == null) return null; + + const [parentCol, parentOp] = parentOpSegment; + return grid[parentCol]?.components[parentOp] ?? null; }; /** * Find the parent component grid of an operation based on its location. * + * Navigates via [`Location`](data/location.ts) so the addressing format is owned by exactly one + * module. + * * @param componentGrid The grid of components to search through. * @param location The location string of the operation. - * @returns The parent grid of components or null if not found. + * @returns The parent grid of components, or `null` if the location is empty or addresses an op + * nested below a missing ancestor. */ const findParentArray = ( componentGrid: ComponentGrid, location: string | null, ): ComponentGrid | null => { if (!location) return null; - - const indexes = locationStringToIndexes(location); - indexes.pop(); // The last index refers to the operation itself, remove it so that the last index instead refers to the parent operation - - let parentArray = componentGrid; - for (const index of indexes) { - parentArray = - parentArray[index[0]].components[index[1]].children || parentArray; - } - return parentArray; + // Drop the last segment — it addresses the op itself; we want the grid that contains it, which is + // keyed by its parent's segments. + return _walkToGrid(componentGrid, Location.parse(location).parent().segments); }; /** * Find an operation based on its location. * + * Navigates via [`Location`](data/location.ts) so the addressing format is owned by exactly one + * module. + * * @param componentGrid The grid of components to search through. * @param location The location string of the operation. - * @returns The operation or null if not found. + * @returns The operation at the given location, or `null` if the location is empty or addresses an + * op that does not exist. */ const findOperation = ( componentGrid: ComponentGrid, @@ -359,91 +366,113 @@ const findOperation = ( ): Operation | null => { if (!location) return null; - const index = locationStringToIndexes(location).pop(); - const operationParent = findParentArray(componentGrid, location); + const last = Location.parse(location).last(); + if (last == null) return null; - if (operationParent == null || index == null) return null; + const operationParent = findParentArray(componentGrid, location); + if (operationParent == null) return null; - return operationParent[index[0]].components[index[1]]; + const [col, op] = last; + return operationParent[col]?.components[op] ?? null; }; -/********************** - * Getter Functions * - **********************/ - /** - * Get list of y values based on circuit wires. + * Set of qubit wires covered by EXTERNAL SIBLINGS of the op at `location` in that op's outer + * column. "External sibling" = another op sharing the same column in the op's containing array + * (i.e., NOT a descendant inside the op's own children grid). * - * @param container The HTML container element containing the circuit visualization. - * @returns An array of y values corresponding to the circuit wires. - */ -const getWireData = (container: HTMLElement): number[] => { - const wireElems = container.querySelectorAll(".qubit-wire"); - const wireData = Array.from(wireElems).map((wireElem) => { - return Number(wireElem.getAttribute("y1")); - }); - return wireData; -}; - -/** - * Get list of toolbox items. + * "Covered" follows the visual extent reported by `getWireRange`: the inclusive range of qubit rows + * the sibling's body and any classical-control connector paint into. Classical-result endpoints + * contribute the qubit row BELOW which they sit (e.g. a low endpoint at `q_c.r0` extends coverage + * starting at `q_(c+1)`; a high endpoint at `q_c.r0` extends coverage through `q_c`). * - * @param container The HTML container element containing the toolbox items. - * @returns An array of SVG graphics elements representing the toolbox items. - */ -const getToolboxElems = (container: HTMLElement): SVGGraphicsElement[] => { - return Array.from( - container.querySelectorAll("[toolbox-item]"), - ); -}; - -/** - * Get list of host elements that dropzones can be attached to. + * Use this to answer "what wires would directly collide if I widened this op's wire span?" — the + * building block for the shift-extend dropzone filter, which composes this with + * `getAncestorColumnSiblingWires` to walk the ancestor chain and accumulate every collision the + * cascade would need to resolve. * - * @param container The HTML container element containing the circuit visualization. - * @returns An array of SVG graphics elements representing the host elements. - */ -const getHostElems = (container: HTMLElement): SVGGraphicsElement[] => { - const circuitSvg = container.querySelector("svg.qviz"); - return circuitSvg != null - ? Array.from( - circuitSvg.querySelectorAll( - '[class^="gate-"]:not(.gate-control, .gate-swap), .control-dot, .oplus, .cross', - ), - ) - : []; -}; - -/** - * Get list of gate elements from the circuit, but not the toolbox. + * Returns an empty set when `location` is null, doesn't resolve, or addresses a top-level op with + * no siblings (no overlap possible). * - * @param container The HTML container element containing the circuit visualization. - * @returns An array of SVG graphics elements representing the gate elements. + * @param componentGrid The grid the op lives in. + * @param location The op's location string. + * @returns The qubit wires covered by every external sibling in the op's outer column. */ -const getGateElems = (container: HTMLElement): SVGGraphicsElement[] => { - const circuitSvg = container.querySelector("svg.qviz"); - return circuitSvg != null - ? Array.from(circuitSvg.querySelectorAll(".gate")) - : []; +const getOuterColumnSiblingWires = ( + componentGrid: ComponentGrid, + location: string | null, +): Set => { + const blocked = new Set(); + if (!location) return blocked; + const last = Location.parse(location).last(); + if (last == null) return blocked; + const [outerCol] = last; + const parentArray = findParentArray(componentGrid, location); + if (parentArray == null) return blocked; + const column = parentArray[outerCol]; + if (column == null) return blocked; + const selfOp = findOperation(componentGrid, location); + for (const sibling of column.components) { + if (sibling === selfOp) continue; + const range = getWireRange(sibling); + // Defensive: a register-less op (shouldn't exist) can't cover any wire. + if (range == null) continue; + const [minReg, maxReg] = range; + // Classical-result row sits between q_owner and q_(owner+1). A LOW endpoint on a classical row + // enters coverage at q_(owner+1); a HIGH endpoint on a classical row extends coverage through + // q_owner. A quantum endpoint covers its own row at either end. + const minQubit = + minReg.result === undefined ? minReg.qubit : minReg.qubit + 1; + const maxQubit = maxReg.qubit; + for (let w = minQubit; w <= maxQubit; w++) { + blocked.add(w); + } + } + return blocked; }; /** - * Get list of qubit label elements for drag-and-drop. + * Union of `getOuterColumnSiblingWires` across the op at `location` AND every ancestor of it, up to + * (but not including) the root grid. * - * @param container The HTML container element containing the circuit visualization. - * @returns An array of SVGTextElement representing the qubit labels. + * Why walk the chain. The shift-extend gesture extends an immediately-enclosing group, but the + * action layer's cascade (see [`refreshAncestorTargets`](actions/circuitActions.ts) + + * [`_resolveOverlapAfterExtend`](actions/circuitActions.ts)) widens every ancestor whose span + * doesn't already enclose the drop wire — and each widening may collide with siblings IN THAT + * ANCESTOR'S column, not just the immediate parent's. Filtering only the immediate parent's + * siblings under-blocks: a deeply nested source could be shift-dropped onto a wire owned by a + * top-level sibling of an ancestor several levels up, leaving the cascade to silently cope (or, in + * pathological cases, leave a visible overlap). + * + * Walks via location-string `parent()` rather than object identity so it can be called against the + * live model without needing the ancestor chain pre-collected. + * + * Returns an empty set when `location` is null or doesn't resolve. + * + * @param componentGrid The grid the op lives in. + * @param location The op's location string. + * @returns The union of every ancestor's outer-column sibling wires, including the op's own outer + * column. */ -const getQubitLabelElems = (container: HTMLElement): SVGTextElement[] => { - const circuitSvg = container.querySelector("svg.qviz"); - if (!circuitSvg) return []; - const labelGroup = circuitSvg.querySelector("g.qubit-input-states"); - if (!labelGroup) return []; - return Array.from(labelGroup.querySelectorAll("text")); +const getAncestorColumnSiblingWires = ( + componentGrid: ComponentGrid, + location: string | null, +): Set => { + const blocked = new Set(); + if (!location) return blocked; + let loc = Location.parse(location); + while (!loc.isRoot && loc.last() != null) { + const locStr = loc.toString(); + for (const wire of getOuterColumnSiblingWires(componentGrid, locStr)) { + blocked.add(wire); + } + loc = loc.parent(); + } + return blocked; }; -// Non-ASCII chars are fraught with danger. Copy/paste these when possible. -// Use the following regex in VS Code to find invalid unicode chars -// [^\x20-\x7e\u{03b8}-\u{03c8}\u{2020}\u{27e8}\u{27e9}] +// Non-ASCII chars are fraught with danger. Copy/paste these when possible. Use the following regex +// in VS Code to find invalid unicode chars [^\x20-\x7e\u{03b8}-\u{03c8}\u{2020}\u{27e8}\u{27e9}] const mathChars = { theta: "θ", // \u{03b8} @@ -454,22 +483,64 @@ const mathChars = { rangle: "⟩", // \u{27e9} }; +/** + * Given a click's SVG-space Y coordinate, the list of wire-Ys the clicked host element spans, and + * the circuit's full wire-Y array, return the index (into `wireData`) of the wire whose Y is + * **closest to the click**. + * + * Used by the selection controller to pick a per-click handle for multi-wire host elements (the + * body of a group, SWAP, multi-qubit measurement, etc.), so the grabbed wire becomes the drag + * handle rather than always pinning the gate's topmost wire to the drop wire. + * + * Behavior: + * + * - `wireYs` is empty → return `-1` (no candidate). Caller should fall back to the static + * `data-wire` attribute. + * - `wireYs` has a single Y → return its `wireData` index directly. The click-Y is irrelevant for + * single-wire host elements (control dots, target circles, measurement crosses, ket boxes), and + * skipping the search avoids a pointless `getScreenCTM` call by the caller. + * - Multi-wire span → tie-break by smallest `|wireY - clickY|`, then by smaller `wireY` + * (deterministic on a tie). Returns `-1` if the winning Y isn't in `wireData` at all, which + * would indicate a renderer / editor wire-table mismatch. + * + * Clicks above the topmost wire or below the bottommost clamp to that endpoint, which is the + * natural "closest" behavior — no special-case code needed. + */ +const pickClosestWireIndex = ( + clickSvgY: number, + wireYs: ReadonlyArray, + wireData: ReadonlyArray, +): number => { + if (wireYs.length === 0) return -1; + if (wireYs.length === 1) { + return wireData.indexOf(wireYs[0]); + } + let bestY = wireYs[0]; + let bestDist = Math.abs(bestY - clickSvgY); + for (let i = 1; i < wireYs.length; i++) { + const y = wireYs[i]; + const dist = Math.abs(y - clickSvgY); + if (dist < bestDist || (dist === bestDist && y < bestY)) { + bestDist = dist; + bestY = y; + } + } + return wireData.indexOf(bestY); +}; + export { deepEqual, - getMinGateWidth, getChildTargets, - locationStringToIndexes, getGateLocationString, getMinMaxRegIdx, - findGateElem, - findLocation, + getOperationRegisters, + getQuantumWireRange, + getWireRange, findParentOperation, findParentArray, findOperation, - getWireData, - getToolboxElems, - getHostElems, - getGateElems, - getQubitLabelElems, + getOuterColumnSiblingWires, + getAncestorColumnSiblingWires, mathChars, + pickClosestWireIndex, }; diff --git a/source/npm/qsharp/ux/circuit.tsx b/source/npm/qsharp/ux/circuit.tsx index 1ed57b219f8..28cef9207af 100644 --- a/source/npm/qsharp/ux/circuit.tsx +++ b/source/npm/qsharp/ux/circuit.tsx @@ -5,7 +5,7 @@ import * as qviz from "./circuit-vis/index.js"; import { useEffect, useRef, useState } from "preact/hooks"; import { CircuitProps } from "./data.js"; import { Spinner } from "./spinner.js"; -import { SourceLocation, toCircuitGroup } from "./circuit-vis/circuit.js"; +import { SourceLocation, toCircuitGroup } from "./circuit-vis/data/circuit.js"; // For perf reasons we set a limit on how many gates/qubits // we attempt to render. This is still a lot higher than a human would @@ -75,7 +75,16 @@ function ZoomableCircuit(props: { const isEditable = props.editor != null; useEffect(() => { - // Enable "rendering" text while the circuit is being drawn + if (isEditable && qvizObj.current != null) { + // Editable editor is bound to one circuit for its lifetime, so reuse + // the Sqore to preserve view state (expand/collapse) and avoid flicker. + qvizObj.current.updateCircuit(props.circuitGroup); + return; + } + // First mount, or a non-editable host that may push an unrelated circuit. + // Reusing the Sqore would leak view state across circuits with matching + // locations, so start from a clean draw with a fresh Sqore. + qvizObj.current = null; setRendering(true); const container = circuitDiv.current!; container.innerHTML = ""; diff --git a/source/npm/qsharp/ux/data.ts b/source/npm/qsharp/ux/data.ts index 659dfba0f26..3598e76e470 100644 --- a/source/npm/qsharp/ux/data.ts +++ b/source/npm/qsharp/ux/data.ts @@ -79,5 +79,5 @@ export type CircuitProps = { }; export type EditorHandlers = import("./circuit-vis/index.js").EditorHandlers; -export type CircuitGroup = import("./circuit-vis/circuit.js").CircuitGroup; -export type CircuitModel = import("./circuit-vis/circuit.js").Circuit; +export type CircuitGroup = import("./circuit-vis/data/circuit.js").CircuitGroup; +export type CircuitModel = import("./circuit-vis/data/circuit.js").Circuit; diff --git a/source/npm/qsharp/ux/qsharp-circuit.css b/source/npm/qsharp/ux/qsharp-circuit.css index 8553b931802..1299846bf8d 100644 --- a/source/npm/qsharp/ux/qsharp-circuit.css +++ b/source/npm/qsharp/ux/qsharp-circuit.css @@ -254,6 +254,23 @@ fill: var(--vscode-editor-selectionBackground, #ec7063); fill-opacity: 50%; } + + /* + * D4 Stage B ghost-border overlay. Painted by `DragController` + * while shift is held mid-drag and the cursor hovers a shift- + * extend dropzone, to preview the destination group's new wire + * span if the user released here. Single translucent rect; no + * hover state because it's a passive visualization. + */ + .shift-extend-ghost { + fill: var(--vscode-editor-selectionBackground, #ec7063); + fill-opacity: 15%; + stroke: var(--vscode-editor-selectionBackground, #ec7063); + stroke-opacity: 60%; + stroke-width: 2; + stroke-dasharray: 6 4; + pointer-events: none; + } .grab { cursor: grab; }