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..a48403a3ce7 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/_helpers.mjs @@ -0,0 +1,541 @@ +// 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..c9f35b6ef9a --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/angleExpression.test.mjs @@ -0,0 +1,218 @@ +// 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..70293d6dc50 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/addRemove.test.mjs @@ -0,0 +1,511 @@ +// 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 appends to the target column and bumps qubitUseCounts", () => { + 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 op is the inserted reference (deep-copied from template). + assert.equal(added, at(model, "0,0")); + assert.deepEqual(model.qubitUseCounts, [1, 0]); +}); + +test("addOperation on an existing wire bumps qubitUseCounts without growing qubits", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + assert.equal(model.qubits.length, 2); + assert.deepEqual(model.qubitUseCounts, [1, 0]); + + // Second op on the SAME wire (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]); +}); + +test("addOperation on a wire several IDs beyond the end bulk-grows qubits", () => { + const model = new CircuitModel(emptyCircuit(1)); + assert.equal(model.qubits.length, 1); + + // Drop on wire 5 — ensureQubitCount(5) adds wires 1..5 in one shot. + addOperation(model, unitary("H"), "0,0", 5); + + assert.equal(model.qubits.length, 6); + assert.deepEqual(model.qubitUseCounts, [0, 0, 0, 0, 0, 1]); + for (let i = 0; i < model.qubits.length; i++) { + assert.equal(model.qubits[i].id, i); + } +}); + +test("addOperation with insertNewColumn=true creates a fresh column", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + + // insertNewColumn pushes X into a fresh column 0, shifting H right. + addOperation(model, gate("X", 1), "0,0", 1, /* insertNewColumn */ true); + + expectGrid(model, [["X"], ["H"]]); + assert.deepEqual(model.qubitUseCounts, [1, 1]); +}); + +test("addOperation with insertNewColumn=true moves other operations to the right", () => { + const model = new CircuitModel(emptyCircuit(2)); + addOperation(model, unitary("H"), "0,0", 0); + addOperation(model, unitary("S"), "1,0", 0); + + addOperation(model, gate("X", 1), "1,0", 1, /* insertNewColumn */ true); + + expectGrid(model, [["H"], ["X"], ["S"]]); + assert.deepEqual(model.qubitUseCounts, [2, 1]); +}); + +test("addOperation deep-copies its 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 add must not affect the model. + template.gate = "MUTATED"; + assert.equal(/** @type {any} */ (added).gate, "H"); + expectOp(at(model, "0,0"), "H"); +}); + +test("addOperation with a missing target 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, []); +}); + +// --------------------------------------------------------------------------- +// 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]); +}); + +// --------------------------------------------------------------------------- +// 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..b0990a8fbe9 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupAddRemove.test.mjs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// Remove-mutator tests on grouped shapes, driven through the public +// `removeOperation` action. Counterpart to `addRemove.test.mjs` +// (which covers the flat, non-grouped case). Focuses on stripping a +// leaf inside a group and the ancestor-`.targets` narrowing that +// follows the removal. + +// @ts-check + +import { test } from "node:test"; +import { removeOperation } from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { at, build, circuit, expectOp, gate, group } from "../_helpers.mjs"; + +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] } }); +}); 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..e22d06e0d2e --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupAncestorRefresh.test.mjs @@ -0,0 +1,343 @@ +// 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,2", 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,2", 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/groupClone.test.mjs b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupClone.test.mjs new file mode 100644 index 00000000000..46ed52df9d0 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupClone.test.mjs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +// addOperation: clone-copy of a group preserves shape. +// +// Ctrl-drag (clone) of a multi-wire op routes through the same +// rigid-shift path as `moveOperation`'s `_moveAsUnit`: every +// register in the cloned subtree shifts by the same +// `targetWire - sourceWire` delta, keeping `.targets` and every +// nested child wire aligned. + +// @ts-check + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { addOperation } from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + build, + circuit, + expectOp, + gate, + group, + meas, + qubits, +} from "../_helpers.mjs"; + +test("addOperation: clone-copy of a group with delta>0 shifts every nested register", () => { + const model = build( + circuit(4, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + const sourceFoo = at(model, "0,0"); + + // clone Foo, grab on q0, drop on q2 (delta = +2) + const cloned = addOperation( + model, + sourceFoo, + "1,0", + /* targetWire */ 2, + /* insertNewColumn */ false, + /* sourceWire */ 0, + ); + + assert.ok(cloned, "clone returned an op"); + expectOp(cloned, { + Foo: { targets: [2, 3], children: [[{ H: 2 }, { X: 3 }]] }, + }); + // Original Foo is untouched (clone, not move). + expectOp(at(model, "0,0"), { + Foo: { targets: [0, 1], children: [[{ H: 0 }, { X: 1 }]] }, + }); +}); + +test("addOperation: clone-copy of a group with delta=0 preserves all children on their original wires", () => { + const model = build( + circuit(2, [[group("Foo", [[gate("H", 0), gate("X", 1)]])]]), + ); + const sourceFoo = at(model, "0,0"); + + // clone Foo, grab on q0, drop on q0 (delta = 0, different column) + const cloned = addOperation( + model, + sourceFoo, + "1,0", + /* targetWire */ 0, + /* insertNewColumn */ false, + /* sourceWire */ 0, + ); + + assert.ok(cloned, "clone returned an op"); + expectOp(cloned, { + Foo: { targets: [0, 1], children: [[{ H: 0 }, { X: 1 }]] }, + }); +}); + +test("addOperation: clone-copy of a multi-target gate preserves every leg", () => { + // The clone path must rigid-shift every leg by the same delta — + // collapsing `targets` to a single-wire stub would destroy a leg. + const model = build(circuit(4, [[gate("SWAP", [0, 1])]])); + const sourceSwap = at(model, "0,0"); + + // clone SWAP, grab on q0, drop on q2 (delta = +2) + const cloned = addOperation( + model, + sourceSwap, + "1,0", + /* targetWire */ 2, + /* insertNewColumn */ false, + /* sourceWire */ 0, + ); + + assert.ok(cloned, "clone returned an op"); + expectOp(cloned, { SWAP: [2, 3] }); +}); + +test("addOperation: clone-copy of a group containing an internal classical control shifts the classical ref in lockstep", () => { + // The cloned conditional H must read the CLONED measurement's + // classical register (c_2.0), not the original's (c_0.0). + const model = build( + circuit(4, [ + [ + group("Foo", [ + [meas(0)], + [gate("H", 1, { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ], + ]), + ); + const sourceFoo = at(model, "0,0"); + + // clone Foo, grab on q0, drop on q2 (delta = +2) + const cloned = addOperation( + model, + sourceFoo, + "1,0", + /* targetWire */ 2, + /* insertNewColumn */ false, + /* sourceWire */ 0, + ); + + assert.ok(cloned, "clone returned an op"); + expectOp(cloned, { + Foo: { + children: [ + [{ M: { qubits: [2], results: [{ q: 2, r: 0 }] } }], + [{ H: { targets: [3], ctrls: [{ q: 2, r: 0 }], conditional: true } }], + ], + }, + }); +}); + +test("addOperation: clone-copy of a classically-controlled op anchors its classical ref when the producer M is not cloned", () => { + // Mirror of the lockstep test, but the producing M lives OUTSIDE + // the cloned op. Foo reads c_0.0 produced by an external M. Cloning + // Foo with a wire delta must shift its quantum legs but ANCHOR the + // classical ref at q0.r0 — the original producer still owns it. + const model = build( + circuit(qubits(5, { 0: 1 }), [ + [meas(0)], + [gate("Foo", [1, 2], { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ); + const sourceFoo = at(model, "1,0"); + + // clone Foo, grab on q1, drop on q3 (delta = +2) + const cloned = addOperation( + model, + sourceFoo, + "2,0", + /* targetWire */ 3, + /* insertNewColumn */ false, + /* sourceWire */ 1, + ); + + assert.ok(cloned, "clone returned an op"); + // Quantum legs shifted q1→q3, q2→q4; classical ctrl anchored at q0.r0. + expectOp(cloned, { + Foo: { targets: [3, 4], ctrls: [{ q: 0, r: 0 }], conditional: true }, + }); + // Original Foo and the external M are untouched. + expectOp(at(model, "1,0"), { + Foo: { targets: [1, 2], ctrls: [{ q: 0, r: 0 }], conditional: true }, + }); + expectOp(at(model, "0,0"), { M: { qubits: [0], results: [{ q: 0, r: 0 }] } }); +}); + +test("addOperation: clone-copy of a group anchors an internal child's classical ref when the producer M is outside the group", () => { + // The classically-dependent op is INSIDE the cloned group, but the + // producing M is OUTSIDE it (not cloned). Cloning the group with a + // wire delta shifts the child's quantum target but anchors its + // classical ref at q0.r0 — the external producer still owns it. + const model = build( + circuit(qubits(5, { 0: 1 }), [ + [meas(0)], + [ + group("Foo", [ + [gate("X", 1, { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ], + ]), + ); + const sourceFoo = at(model, "1,0"); + + // clone Foo, grab on q1, drop on q3 (delta = +2) + const cloned = addOperation( + model, + sourceFoo, + "2,0", + /* targetWire */ 3, + /* insertNewColumn */ false, + /* sourceWire */ 1, + ); + + assert.ok(cloned, "clone returned an op"); + // Child X's target shifted q1→q3; classical ctrl anchored at q0.r0. + expectOp(cloned, { + Foo: { + children: [ + [{ X: { targets: [3], ctrls: [{ q: 0, r: 0 }], conditional: true } }], + ], + }, + }); + // Original Foo and the external M are untouched. + expectOp(at(model, "1,0"), { + Foo: { + children: [ + [{ X: { targets: [1], ctrls: [{ q: 0, r: 0 }], conditional: true } }], + ], + }, + }); + expectOp(at(model, "0,0"), { M: { qubits: [0], results: [{ q: 0, r: 0 }] } }); +}); + +test("addOperation: clone-copy that would push a wire below 0 returns null", () => { + // Grabbing Foo (wires 1-2) on q1 and dropping at q-1 computes + // delta = -2, underflowing wire 1 → -1. Returns null (no-op). + const model = build( + circuit(3, [[group("Foo", [[gate("H", 1), gate("X", 2)]])]]), + ); + const sourceFoo = at(model, "0,0"); + const before = JSON.stringify(model.componentGrid); + + // clone Foo, grab on q1, drop on q-1 (delta = -2, underflows) + const result = addOperation( + model, + sourceFoo, + "1,0", + /* targetWire */ -1, + /* insertNewColumn */ false, + /* sourceWire */ 1, + ); + + assert.equal(result, null, "expected null when shift would underflow"); + assert.equal(JSON.stringify(model.componentGrid), before); +}); 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..76879433559 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupCollisionSplit.test.mjs @@ -0,0 +1,383 @@ +// 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..10e989e759b --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/groupMove.test.mjs @@ -0,0 +1,412 @@ +// 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 { + addOperation, + moveOperation, +} from "../../../dist/ux/circuit-vis/actions/circuitActions.js"; +import { + at, + build, + circuit, + expectGrid, + expectOp, + gate, + group, + meas, + qubits, +} 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] } }); +}); + +// --------------------------------------------------------------------------- +// Dragging a group as a rigid unit. +// +// Moving a group shifts the group's own `.targets` AND recursively +// every register reference in its children grid by the same delta, +// so the box and its contents stay aligned. +// --------------------------------------------------------------------------- + +test("moveOperation: dragging a group shifts the box AND all child register refs", () => { + // Group with children H@0, CNOT(target=1, ctrl=0). Drag wire 0 + // → wire 2 (delta = +2). Box and children all shift by +2. + const model = build( + circuit(4, [ + [group("Group", [[gate("H", 0), gate("CNOT", 1, { ctrls: [0] })]])], + ]), + ); + + const moved = moveOperation(model, "0,0", "0,0", 0, 2, false, false); + assert.ok(moved); + + expectOp(at(model, "0,0"), { + Group: { + targets: [2, 3], + children: [[{ H: 2 }, { CNOT: { targets: [3], ctrls: [2] } }]], + }, + }); +}); + +// --------------------------------------------------------------------------- +// Classical-control anchoring on a moved group's children. +// --------------------------------------------------------------------------- + +test("moveOperation: moving a group with a classically-controlled child anchors the classical control", () => { + // External M produces the classical reg; the producer stays put, so + // X's target shifts but its classical control must anchor on q0. + const model = build( + circuit(qubits(4, { 0: 1 }), [ + [meas(0)], + [group("Group", [[gate("X", 1, { ctrls: [{ q: 0, r: 0 }] })]])], + ]), + ); + + // drag the group q1 → q2 (delta = +1) + moveOperation(model, "1,0", "1,0", 1, 2, false, false); + + expectOp(at(model, "1,0"), { + Group: { + children: [[{ X: { targets: [2], ctrls: [{ q: 0, r: 0 }] } }]], + }, + }); +}); + +test("moveOperation: moving a group whose internal measurement produces the classical reg shifts the consumer", () => { + // The producing M is INSIDE the moved subtree, so the classical reg + // moves too; the consumer's classical control shifts in lockstep. + const model = build( + circuit(qubits(4, { 1: 1 }), [ + [ + group("Group", [ + [meas(1)], + [gate("X", 1, { ctrls: [{ q: 1, r: 0 }] })], + ]), + ], + ]), + ); + + // drag the group q1 → q2 (delta = +1) + moveOperation(model, "0,0", "0,0", 1, 2, false, false); + + expectOp(at(model, "0,0"), { + Group: { + children: [ + [{ M: { qubits: [2], results: [{ q: 2, r: 0 }] } }], + [{ X: { targets: [2], ctrls: [{ q: 2, r: 0 }] } }], + ], + }, + }); + + // numResults bookkeeping must follow the measurement. + assert.equal( + model.qubits[1].numResults, + undefined, + "wire 1 must no longer claim a classical register", + ); + assert.equal( + model.qubits[2].numResults, + 1, + "wire 2 must now claim the classical register", + ); +}); + +test("moveOperation: unit-moving a multi-target gate with an external classical control anchors that control", () => { + // Multi-target gates take the same rigid unit-shift path as groups. + // External M produces the classical reg, so the quantum targets + // shift but the classical control must anchor on q0. + const model = build( + circuit(qubits(5, { 0: 1 }), [ + [meas(0)], + [gate("Foo", [1, 2], { ctrls: [{ q: 0, r: 0 }], conditional: true })], + ]), + ); + + // drag the gate q1 → q3 (delta = +2) + moveOperation(model, "1,0", "1,0", 1, 3, false, false); + + // targets shift q1→q3, q2→q4; classical control anchored at q0.r0. + expectOp(at(model, "1,0"), { + Foo: { targets: [3, 4], ctrls: [{ q: 0, r: 0 }], conditional: true }, + }); +}); + +// --------------------------------------------------------------------------- +// Bounds-checking for unit-shift moves on groups. +// --------------------------------------------------------------------------- + +test("moveOperation: refuses a unit-shift that would push wires below 0", () => { + // Group spans wires 1-2. Grabbing on q2 and dropping on q0 is a + // delta = -2 shift, which would push the group's low wire (1) to -1. + const circuitLiteral = circuit(4, [ + [group("Group", [[gate("X", 1), gate("Y", 2)]])], + ]); + const before = JSON.stringify(circuitLiteral); + const model = build(circuitLiteral); + + // grab q2, drop on q0 → delta = -2, low wire 1 would underflow to -1 + const result = moveOperation(model, "0,0", "0,0", 2, 0, false, false); + + assert.equal(result, null, "move must be refused"); + assert.equal( + JSON.stringify({ + qubits: model.qubits, + componentGrid: model.componentGrid, + }), + before, + "refusal must not mutate the model", + ); +}); + +test("moveOperation: a unit-shift whose lowest wire lands exactly on 0 is allowed", () => { + // Boundary: span [1, 2] shifted by -1 lands on [0, 1] — exactly on 0 + // is still in-range, so the move succeeds. + const model = build( + circuit(4, [[group("Group", [[gate("X", 1), gate("Y", 2)]])]]), + ); + + // grab q1, drop on q0 (delta = -1) + const result = moveOperation(model, "0,0", "0,0", 1, 0, false, false); + assert.ok(result, "move must succeed when min post-shift wire is exactly 0"); + + expectOp(at(model, "0,0"), { Group: { targets: [0, 1] } }); +}); + +test("moveOperation: a unit-shift on a single-child group is bounded by the derived min wire", () => { + // The bounds check uses the derived min wire (here [1], from the lone + // X@1), not any pre-declared span: shift -1 → [0] is in-range. + const model = build(circuit(4, [[group("Group", [[gate("X", 1)]])]])); + + // grab q1, drop on q0 (delta = -1) + const result = moveOperation(model, "0,0", "0,0", 1, 0, false, false); + assert.ok(result, "move must succeed when derived min post-shift wire is 0"); + + expectOp(at(model, "0,0"), { + Group: { targets: [0], children: [[{ X: 0 }]] }, + }); +}); + +// --------------------------------------------------------------------------- +// 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("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("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", + ); +}); + +// --------------------------------------------------------------- +// 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..a4d09b30e05 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/measurementCascade.test.mjs @@ -0,0 +1,437 @@ +// 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..ec9bb23b906 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/moveStamp.test.mjs @@ -0,0 +1,67 @@ +// 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..77d4dd8869f --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/producerOrdering.test.mjs @@ -0,0 +1,206 @@ +// 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..6bfb06721b3 --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuit-actions/qubitOps.test.mjs @@ -0,0 +1,360 @@ +// 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("moveQubit with sourceWire === targetWire is a no-op", () => { + const model = build(circuit(2, [[gate("X", 0), gate("H", 1)]])); + const before = JSON.stringify(model.componentGrid); + + moveQubit(model, 1, 1, false); + + assert.equal(JSON.stringify(model.componentGrid), before); +}); + +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 }]]); +}); + +// --------------------------------------------------------------------------- +// 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..8b5dd76607b --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuitModel.test.mjs @@ -0,0 +1,377 @@ +// 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 by reference", () => { + const circuit = emptyCircuit(2); + const model = new CircuitModel(circuit); + + // Mutate via the model. + model.componentGrid.push({ components: [unitary("H", 0)] }); + + // Underlying circuit sees the same change. + assert.equal(circuit.componentGrid.length, 1); + assert.equal(circuit.componentGrid, model.componentGrid); +}); + +test("constructor borrows qubits by reference", () => { + const circuit = emptyCircuit(2); + const model = new CircuitModel(circuit); + + assert.equal(circuit.qubits, model.qubits); +}); + +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); +}); + +// --------------------------------------------------------------------------- +// snapshot +// --------------------------------------------------------------------------- + +test("snapshot returns a Circuit aliasing the model's arrays", () => { + const model = new CircuitModel(emptyCircuit(2)); + + const snap = model.snapshot(); + + assert.equal(snap.qubits, model.qubits); + assert.equal(snap.componentGrid, model.componentGrid); +}); + +// --------------------------------------------------------------------------- +// 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 is a no-op when all wires are used", () => { + /** @type {any} */ + const circuit = { + qubits: [{ id: 0 }, { id: 1 }], + componentGrid: [ + { components: [unitary("X", 0)] }, + { components: [unitary("H", 1)] }, + ], + }; + const model = new CircuitModel(circuit); + + model.removeTrailingUnusedQubits(); + + assert.equal(model.qubits.length, 2); + assert.deepEqual(model.qubitUseCounts, [1, 1]); +}); + +test("removeTrailingUnusedQubits empties the model when no wires are used", () => { + const model = new CircuitModel(emptyCircuit(3)); + + model.removeTrailingUnusedQubits(); + + assert.equal(model.qubits.length, 0); + assert.deepEqual(model.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("incrementQubitUseCountForOp ignores out-of-range registers", () => { + const model = new CircuitModel(emptyCircuit(2)); + + // Wire 5 doesn't exist — silently skipped. + model.incrementQubitUseCountForOp(/** @type {any} */ (unitary("X", 5))); + + assert.deepEqual(model.qubitUseCounts, [0, 0]); +}); + +test("incrementQubitUseCountForOp counts every qubit register", () => { + const model = new CircuitModel(emptyCircuit(3)); + + model.incrementQubitUseCountForOp( + /** @type {any} */ (unitary("X", 0, [1, 2])), + ); + + assert.deepEqual(model.qubitUseCounts, [1, 1, 1]); +}); + +test("decrementQubitUseCountForOp mirrors increment", () => { + /** @type {any} */ + const circuit = { + qubits: [{ id: 0 }, { id: 1 }, { id: 2 }], + componentGrid: [{ components: [unitary("X", 0, [1, 2])] }], + }; + const model = new CircuitModel(circuit); + assert.deepEqual(model.qubitUseCounts, [1, 1, 1]); + + model.decrementQubitUseCountForOp( + /** @type {any} */ (model.componentGrid[0].components[0]), + ); + + assert.deepEqual(model.qubitUseCounts, [0, 0, 0]); +}); diff --git a/source/npm/qsharp/test/circuit-editor/circuitTargets.bench.md b/source/npm/qsharp/test/circuit-editor/circuitTargets.bench.md new file mode 100644 index 00000000000..9144214666d --- /dev/null +++ b/source/npm/qsharp/test/circuit-editor/circuitTargets.bench.md @@ -0,0 +1,154 @@ +# Circuit `targets` benchmark results + +Harness: [circuitTargets.bench.mjs](./circuitTargets.bench.mjs). Captured +with `node test/circuit-editor/circuitTargets.bench.mjs