feat(web-test): мультивыбор в selectValue(field, [...]) — 5 поверхностей

selectValue с массивом значений → выбор нескольких значений в полях типа
«список значений». Всегда replace. selectValuesMulti диспетчеризует 4 жеста
(F4-first детект): checkbox-form (все кандидаты, diff-тоггл), pool-podbor
(промежуточный пул + Подбор, очистка Ctrl+A+Delete), cloud-dropdown (инлайн
.cloudDD, коорд-клик + клик-вне), catalog-multirow (Ctrl-мультивыбор + Выбрать).

Композиция существующего API (clickElement/fillTableRow/closeForm/filterList/
readTable/getFormState); единственный новый низкоуровневый код — readCloudDDScript
(dom/submenu.mjs). Возврат selected:{field, values, notSelected?:[{value,reason}]}
(англ. коды, без артефактов реализации). Имена кнопок — MULTI_BTN (единый
источник детект+клик). Одиночный selectValue не тронут.

Регресс 25/25. Новый tests/web-test/22-multiselect-listfield.test.mjs.
Live: стенд МножественныйВыбор (5 полей: A/B/C/D/E) + Титан (Наименование
компании=A, Статьи=B), значения восстановлены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-06-28 20:17:05 +03:00
parent 7c75424153
commit bde3a5f89f
5 changed files with 388 additions and 4 deletions
+16
View File
@@ -340,6 +340,22 @@ await selectValue('Документ', '0000-000601', { type: 'Реализаци
Also supports DCS labels — auto-enables the paired checkbox.
**Multi-select (value-list fields)** — pass an **array** to select several values at once. Works
across all "список значений" surfaces (checkbox form, intermediate pool + Подбор, inline cloud
dropdown, catalog multi-row select) — the right gesture is auto-detected. Always **replace**
semantics (the field ends up with exactly the given set):
```js
await selectValue('Наименование компании', ['Альфа ООО', 'Бета АО']);
// result.selected = { field: 'Наименование компании', values: ['Альфа ООО', 'Бета АО'] }
// Values not offered by the field land in notSelected (the call does not throw):
const r = await selectValue('Организации', ['Альфа ООО', 'Несуществующая']);
// r.selected = { field: 'Организации', values: ['Альфа ООО'],
// notSelected: [{ value: 'Несуществующая', reason: 'not_in_list' }] }
```
Array elements are strings (the displayed value); objects `{ col: value }` are passed through as
the catalog search (for surfaces that open a selection catalog).
#### `fillTableRow(fields, opts)` → form state with `filled` (+ optional `notFilled`)
Fill table row cells via Tab navigation. Value is a plain string, `{ value, type }` for composite-type cells, or `''`/`null` to clear (Shift+F4).
+1
View File
@@ -89,6 +89,7 @@ export {
export {
readSubmenuScript,
clickPopupItemScript,
readCloudDDScript,
} from './dom/submenu.mjs';
export { checkErrorsScript } from './dom/errors.mjs';
@@ -1,4 +1,4 @@
// web-test dom/submenu v1.0 — popup/submenu reading and clicking
// web-test dom/submenu v1.1 — popup/submenu reading and clicking
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
/**
@@ -147,3 +147,32 @@ export function clickPopupItemScript(text) {
return null;
})()`;
}
/**
* Read a platform "cloud dropdown" checkbox list (`.cloudDD`) — the inline
* quick-choice multi-select dropdown (e.g. a catalog with QuickChoice opened via F4).
* NOT handled by readSubmenuScript (which reads `.eddText`/`.cloud`/popup `a.press`).
*
* Returns `[{ text, checked, x, y }]` — `checked` from `.checkbox.select`, `x/y` =
* the checkbox center (click there to toggle). Toggle via page.mouse.click (a
* `.surface` backdrop swallows selector clicks); confirm by clicking OUTSIDE the panel.
* Returns `{ error: 'no_clouddd' }` when no visible `.cloudDD` panel.
*/
export function readCloudDDScript() {
return `(() => {
const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/\\s+/g, ' ').replace(/ё/gi, 'е');
const panel = [...document.querySelectorAll('.cloudDD')].find(p => p.offsetWidth > 0 && p.offsetHeight > 0);
if (!panel) return { error: 'no_clouddd' };
const items = [];
panel.querySelectorAll('.checkbox').forEach(chk => {
if (chk.offsetWidth === 0) return;
// The text label sits beside the checkbox — climb until a container has text.
let row = chk;
for (let k = 0; k < 4 && row.parentElement; k++) { row = row.parentElement; if ((row.innerText || '').trim()) break; }
const r = chk.getBoundingClientRect();
items.push({ text: norm(row.innerText || ''), checked: chk.classList.contains('select'),
x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) });
});
return items;
})()`;
}
@@ -1,4 +1,4 @@
// web-test forms/select-value v1.25 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers.
// web-test forms/select-value v1.26 — Reference & composite-type value selection: selectValue (+ array multi-select), fillReferenceField, selection/type-dialog pickers.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
@@ -6,7 +6,7 @@ import {
} from '../core/state.mjs';
import {
detectFormScript, findFieldButtonScript, resolveFieldsScript,
readSubmenuScript, checkErrorsScript,
readSubmenuScript, checkErrorsScript, readCloudDDScript,
findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript,
findPatternInputIdScript, isTypeDialogScript, isNotInListCloudVisibleScript,
findChildFormByButtonScript, readTypeDialogVisibleRowsScript,
@@ -22,7 +22,11 @@ import {
} from '../core/helpers.mjs';
import { pasteText } from '../core/clipboard.mjs';
import { getFormState } from './state.mjs';
import { filterList } from '../table/filter.mjs';
import { filterList, unfilterList } from '../table/filter.mjs';
import { clickElement } from '../core/click.mjs';
import { fillTableRow } from '../table/row-fill.mjs';
import { closeForm } from './close.mjs';
import { readTable } from '../table/grid.mjs';
/**
* Scan a selection-form grid for the row matching `search` (string, or a
@@ -620,7 +624,255 @@ export async function fillReferenceField(selector, fieldName, value, formNum) {
* B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form
* C) DLB opens a separate selection form directly — search + dblclick in grid
*/
// Button labels for the value-list multi-select surfaces. Single source for BOTH
// surface detection and clicking (clickElement matches a button by its technical
// name OR tooltip), so detect and click never drift apart.
const MULTI_BTN = {
uncheckAll: 'СписокСнятьФлажки', // tooltip "Снять пометки со всех строк"
confirm: 'ОК',
podbor: 'Подбор',
choose: 'Выбрать',
yes: 'Да',
};
// Recognized selection surfaces a value-list field can open.
const MULTI_SURFACE = {
checkboxForm: 'checkbox-form', // modal form: all candidates as checkboxes (+ ОК)
poolPodbor: 'pool-podbor', // intermediate "pool" form + Подбор
cloudDropdown: 'cloud-dropdown', // inline .cloudDD quick-choice checkbox dropdown
catalogMultiRow: 'catalog-multirow', // catalog selection form, Ctrl multi-row + Выбрать
};
/**
* Multi-select for "value-list" fields: `selectValue(field, [v1, v2, ...])`.
* Always REPLACE semantics (select exactly the given set). Dispatches across the
* 4 selection surfaces (see upload/webtest-multiselect-design.md §7a.1) — a checkbox
* form, an intermediate pool + Подбор catalog, an inline cloud dropdown, and a
* Ctrl-multi-row catalog form. Composes existing API
* (clickElement/fillTableRow/closeForm/filterList/readTable/getFormState).
* Returns flat form state + `selected: { field, values:[…selected…], notSelected?:[{value,reason}] }`.
* Surface name / step detail go to console (exec output) only, NOT the structured result.
*/
async function selectValuesMulti(fieldName, values, { type } = {}) {
ensureConnected();
await dismissPendingErrors();
const baseForm = await page.evaluate(detectFormScript());
if (baseForm === null) throw new Error('selectValue(multi): no form found');
const valStr = (el) => typeof el === 'string' ? el : String(Object.values(el)[0]);
const targets = values.map(v => ({ raw: v, str: valStr(v) }));
const selected = [], notSelected = [];
const eqYo = (a, b) => normYo(String(a).toLowerCase()) === normYo(String(b).toLowerCase());
const incYo = (hay, needle) => normYo(String(hay).toLowerCase()).includes(normYo(String(needle).toLowerCase()));
// Resolve field button (DLB→CB) + DCS-checkbox auto-enable (mirror selectValue).
let btn = await page.evaluate(findFieldButtonScript(baseForm, fieldName, 'DLB'));
if (btn?.error === 'button_not_found') btn = await page.evaluate(findFieldButtonScript(baseForm, fieldName, 'CB'));
if (btn?.error) {
return returnFormState({ selected: { field: fieldName, values: [], error: 'field_not_found',
notSelected: targets.map(t => ({ value: t.str, reason: 'field_not_found' })) } });
}
if (btn.dcsCheckbox) {
const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`;
const isChecked = await page.$eval(cbSel, el =>
el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select')).catch(() => false);
if (!isChecked) { await page.click(cbSel).catch(() => {}); await waitForStable(); }
}
const cloudDDVisible = () => page.evaluate(`!![...document.querySelectorAll('.cloudDD')].find(p => p.offsetWidth > 0 && p.offsetHeight > 0)`);
// ── Open + detect (F4-first) ──
await clickElement(fieldName).catch(() => {});
await waitForStable(baseForm);
let formNum = await helperDetectNewForm(baseForm);
if (formNum === null) {
const inputId = await findFieldInputId(baseForm, btn.fieldName);
if (inputId) { await page.click(`[id="${inputId}"]`).catch(() => {}); await page.waitForTimeout(200); }
await page.keyboard.press('F4');
await page.waitForTimeout(ACTION_WAIT);
await waitForStable(baseForm);
formNum = await helperDetectNewForm(baseForm);
}
let surface = null;
if (formNum !== null) {
const fs = await getFormState();
const buttons = fs.buttons || [];
const hasButton = (label) => buttons.some(b => (b.name || '').includes(label) || (b.tooltip || '').includes(label));
if (hasButton(MULTI_BTN.podbor)) surface = MULTI_SURFACE.poolPodbor;
else if (hasButton(MULTI_BTN.choose) && !hasButton(MULTI_BTN.confirm)) surface = MULTI_SURFACE.catalogMultiRow;
else if (hasButton(MULTI_BTN.uncheckAll) || hasButton(MULTI_BTN.confirm)) surface = MULTI_SURFACE.checkboxForm;
} else if (await cloudDDVisible()) {
surface = MULTI_SURFACE.cloudDropdown;
}
console.log(`[selectValuesMulti] field="${fieldName}" surface=${surface} form=${formNum} values=${JSON.stringify(targets.map(t => t.str))}`);
if (!surface) {
return returnFormState({ selected: { field: fieldName, values: [], error: 'surface_unrecognized',
notSelected: targets.map(t => ({ value: t.str, reason: 'open_failed' })) } });
}
// ── Per-surface handlers ──
const ok = (v) => selected.push(v);
const fail = (v, reason) => notSelected.push({ value: v, reason });
// Toggle a candidate's (checkbox) in a headerless checkbox grid by its Колонка1 text.
async function toggleByText(text, want) {
let done = false;
try {
const r = await fillTableRow({ '(checkbox)': want ? 'true' : 'false' }, { row: { 'Колонка1': text } });
done = !!r.filled?.[0]?.ok;
} catch { done = false; }
if (!done) {
// off-window — narrow via search then retry
try { await filterList(text); } catch {}
try {
const r = await fillTableRow({ '(checkbox)': want ? 'true' : 'false' }, { row: { 'Колонка1': text } });
done = !!r.filled?.[0]?.ok;
} catch { done = false; }
try { await unfilterList(); } catch {}
}
return done;
}
// Surface: modal checkbox form listing ALL candidates. Replace = diff (touch only
// what changes) when the whole list is visible; bulk-uncheck + check for long lists.
async function selectInCheckboxForm() {
const t = await readTable({ maxRows: 200 });
const listFullyVisible = !(t.hasMore && t.hasMore.below);
if (listFullyVisible) {
const checkedNow = new Set((t.rows || []).filter(r => r['(checkbox)'] === 'true').map(r => r['Колонка1']));
const targetSet = new Set(targets.map(x => x.str));
for (const v of checkedNow) if (!targetSet.has(v)) await toggleByText(v, false);
for (const tg of targets) {
if (checkedNow.has(tg.str)) { ok(tg.str); continue; }
(await toggleByText(tg.str, true)) ? ok(tg.str) : fail(tg.str, 'not_in_list');
}
} else {
// Virtualized list — can't read full current state to diff; bulk-uncheck then check targets.
await clickElement(MULTI_BTN.uncheckAll).catch(() => {});
for (const tg of targets) (await toggleByText(tg.str, true)) ? ok(tg.str) : fail(tg.str, 'not_in_list');
}
await clickElement(MULTI_BTN.confirm);
}
// Surface: intermediate "pool" form (already-picked rows) + Подбор to add from a catalog.
// Pool rows persist, so replace = delete all rows (not uncheck). Pool may carry
// checkboxes (custom) or be plain rows (platform — membership itself = selected).
async function selectViaPool() {
await clearPool();
const poolHasCheckbox = (t) => (t.columns || []).includes('(checkbox)');
const isSelectedInPool = (t, v) => {
const row = (t.rows || []).find(r => eqYo(r['Колонка1'], v));
return !!row && (!poolHasCheckbox(t) || row['(checkbox)'] !== 'false');
};
let pool = await readTable({ maxRows: 200 });
const needPodbor = [];
for (const tg of targets) {
const existing = (pool.rows || []).find(r => eqYo(r['Колонка1'], tg.str));
if (existing) {
if (poolHasCheckbox(pool) && existing['(checkbox)'] !== 'true') {
(await toggleByText(tg.str, true)) ? ok(tg.str) : fail(tg.str, 'pool_mark_failed');
} else ok(tg.str); // platform pool: row present = selected; or already checked
} else needPodbor.push(tg);
}
// Add the rest via a SINGLE Подбор session (catalog stays open in Подбор mode).
if (needPodbor.length) {
await clickElement(MULTI_BTN.podbor);
await waitForStable(formNum);
if (await helperDetectNewForm(formNum) !== null) {
for (const tg of needPodbor) {
try { await filterList(tg.str); } catch {}
try { await clickElement(tg.str, { dblclick: true }); } catch {}
}
await closeForm({ save: false });
pool = await readTable({ maxRows: 200 });
for (const tg of needPodbor) isSelectedInPool(pool, tg.str) ? ok(tg.str) : fail(tg.str, 'not_found_in_catalog');
} else {
needPodbor.forEach(tg => fail(tg.str, 'podbor_not_opened'));
}
}
await clickElement(MULTI_BTN.confirm);
}
// Empty the pool by selecting every row (Ctrl+A covers off-screen rows) and deleting.
async function clearPool() {
const t0 = await readTable({ maxRows: 200 });
if (!(t0.rows || []).length) return;
try { await clickElement({ row: 0, column: 'Колонка1' }); } catch {} // focus grid (make list active)
await page.keyboard.press('Control+a');
await page.waitForTimeout(200);
await page.keyboard.press('Delete');
await waitForStable(formNum);
const t1 = await readTable({ maxRows: 50 });
if ((t1.rows || []).length) await clickElement(MULTI_BTN.uncheckAll).catch(() => {}); // fallback
}
// Surface: inline .cloudDD quick-choice dropdown. Coordinate clicks (a .surface
// backdrop swallows selector clicks); confirm by clicking OUTSIDE (never Escape/Enter/ОК).
async function selectInCloudDropdown() {
const readItems = () => page.evaluate(readCloudDDScript());
let items = await readItems();
if (!Array.isArray(items)) { targets.forEach(t => fail(t.str, 'dropdown_unreadable')); return; }
for (const it of items.filter(i => i.checked)) { await page.mouse.click(it.x, it.y); await page.waitForTimeout(150); }
for (const tg of targets) {
items = await readItems();
const it = items.find(i => eqYo(i.text, tg.str)) || items.find(i => incYo(i.text, tg.str));
if (!it) { fail(tg.str, 'not_in_list'); continue; }
if (!it.checked) { await page.mouse.click(it.x, it.y); await page.waitForTimeout(200); }
const after = (await readItems()).find(i => eqYo(i.text, tg.str));
after?.checked ? ok(tg.str) : fail(tg.str, 'not_toggled');
}
await clickOutsideCloudDropdown();
}
async function clickOutsideCloudDropdown() {
const rect = await page.evaluate(`(() => { const p = [...document.querySelectorAll('.cloudDD')].find(e => e.offsetWidth > 0); if (!p) return null; const r = p.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height), vw: window.innerWidth }; })()`);
if (!rect) return;
const outX = (rect.x + rect.w + 80 <= rect.vw) ? rect.x + rect.w + 80 : Math.max(10, rect.x - 40);
await page.mouse.click(outX, rect.y + Math.round(rect.h / 2));
await page.waitForTimeout(300);
}
// Surface: catalog selection form with multi-row selection (Ctrl-click rows, then Выбрать).
async function selectInCatalogMultiRow() {
for (let i = 0; i < targets.length; i++) {
const tg = targets[i];
try {
await clickElement(tg.str, i === 0 ? {} : { modifier: 'ctrl' });
ok(tg.str);
} catch { fail(tg.str, 'row_not_found'); }
}
await clickElement(MULTI_BTN.choose);
await waitForStable(baseForm);
const fs = await getFormState();
if (fs.confirmation) await clickElement(MULTI_BTN.yes).catch(() => {}); // deletion-marked element confirm
}
try {
if (surface === MULTI_SURFACE.checkboxForm) await selectInCheckboxForm();
else if (surface === MULTI_SURFACE.poolPodbor) await selectViaPool();
else if (surface === MULTI_SURFACE.cloudDropdown) await selectInCloudDropdown();
else if (surface === MULTI_SURFACE.catalogMultiRow) await selectInCatalogMultiRow();
} catch (e) {
// Hard failure mid-flow — surface what we have; real 1C errors propagate via wrapper.
const st = await getFormState();
st.selected = { field: fieldName, values: selected, error: e.message,
notSelected: [...notSelected, ...targets.filter(t => !selected.includes(t.str) && !notSelected.some(n => n.value === t.str)).map(t => ({ value: t.str, reason: 'aborted: ' + e.message }))] };
const err = await checkForErrors();
if (err) st.errors = err;
return st;
}
await waitForStable(baseForm);
const selres = { field: fieldName, values: selected };
if (notSelected.length) selres.notSelected = notSelected;
return returnFormState({ selected: selres });
}
export async function selectValue(fieldName, searchText, { type } = {}) {
// Multi-select: an array of values → dispatch to the value-list multi handler
// (5 surfaces). Single-value path below is unchanged.
if (Array.isArray(searchText)) return selectValuesMulti(fieldName, searchText, arguments[2] || {});
ensureConnected();
await dismissPendingErrors();
const formNum = await page.evaluate(detectFormScript());
@@ -0,0 +1,86 @@
export const name = 'multi-select: selectValue(field, [v1,v2]) across 5 value-list surfaces';
export const tags = ['multiselect', 'selectvalue', 'smoke'];
export const timeout = 420000;
// selectValue с массивом значений → мультивыбор в полях типа «список значений».
// Стенд: обработка МножественныйВыбор, 5 поверхностей:
// ЧерезФлажки → checkbox-form, ЧерезПодбор → pool+Подбор, ОрганизацииСписок → cloudDD,
// КонтрагентыСписок → catalog multi-row (Ctrl), СписокПлатформенный → platform pool+Подбор.
const sortq = a => [...a].sort();
export default async function({ navigateLink, selectValue, getFormState, wait, assert, step, log }) {
const fieldValue = async (name) => {
const fs = await getFormState();
return (fs.fields || []).find(f => f.name === name)?.value || '';
};
await step('setup: открыть обработку МножественныйВыбор', async () => {
await navigateLink('Обработка.МножественныйВыбор');
await wait(1);
});
// ── A: custom checkbox form (all candidates) ────────────────────────────────
await step('A (checkbox-form): выбрать [Альфа, Бета]', async () => {
const r = await selectValue('Через флажки', ['Альфа', 'Бета']);
log(`A selected: ${JSON.stringify(r.selected)}`);
assert.deepEqual(sortq(r.selected.values), sortq(['Альфа', 'Бета']), 'оба значения выбраны');
assert.ok(!r.selected.notSelected, 'notSelected отсутствует');
assert.equal(await fieldValue('ЧерезФлажки'), 'Альфа, Бета', 'значение поля = набор');
});
await step('A: replace на подмножество + несуществующее [Бета, Гамма]', async () => {
const r = await selectValue('Через флажки', ['Бета', 'Гамма']);
log(`A2 selected: ${JSON.stringify(r.selected)}`);
assert.deepEqual(r.selected.values, ['Бета'], 'осталась только Бета (Альфа снята)');
assert.ok(r.selected.notSelected?.some(n => n.value === 'Гамма'), 'Гамма → notSelected');
assert.equal(await fieldValue('ЧерезФлажки'), 'Бета', 'replace: поле = Бета');
});
// ── B: custom pool + Подбор (catalog Номенклатура) ──────────────────────────
await step('B (pool+Подбор): выбрать [Товар 01, Товар 02]', async () => {
const r = await selectValue('Через подбор', ['Товар 01', 'Товар 02']);
log(`B selected: ${JSON.stringify(r.selected)}`);
assert.deepEqual(sortq(r.selected.values), sortq(['Товар 01', 'Товар 02']), 'оба товара подобраны');
assert.ok(!r.selected.notSelected, 'notSelected отсутствует');
});
await step('B: replace на [Товар 02] (очистка пула Ctrl+A+Delete)', async () => {
const r = await selectValue('Через подбор', ['Товар 02']);
log(`B2 selected: ${JSON.stringify(r.selected)}`);
assert.deepEqual(r.selected.values, ['Товар 02'], 'остался только Товар 02');
assert.equal(await fieldValue('ЧерезПодбор'), 'Товар 02', 'replace: поле = Товар 02');
});
// ── C: platform cloudDD checkbox dropdown ───────────────────────────────────
await step('C (cloudDD): выбрать [Альфа, Бета]', async () => {
const r = await selectValue('Организации (список)', ['Альфа', 'Бета']);
log(`C selected: ${JSON.stringify(r.selected)}`);
assert.deepEqual(sortq(r.selected.values), sortq(['Альфа', 'Бета']), 'оба выбраны в дропдауне');
assert.equal(await fieldValue('ОрганизацииСписок'), 'Альфа, Бета', 'значение поля = набор');
});
await step('C: replace на [Бета] (снятие + клик-вне)', async () => {
const r = await selectValue('Организации (список)', ['Бета']);
log(`C2 selected: ${JSON.stringify(r.selected)}`);
assert.deepEqual(r.selected.values, ['Бета'], 'осталась только Бета');
assert.equal(await fieldValue('ОрганизацииСписок'), 'Бета', 'replace в cloudDD');
});
// ── D: platform catalog multi-row select (Ctrl) ─────────────────────────────
await step('D (catalog multi-row): выбрать [АО Запад, ООО Восток]', async () => {
const r = await selectValue('Контрагенты (список)', ['АО Запад', 'ООО Восток']);
log(`D selected: ${JSON.stringify(r.selected)}`);
assert.deepEqual(sortq(r.selected.values), sortq(['АО Запад', 'ООО Восток']), 'обе строки выбраны Ctrl-кликом');
assert.equal(await fieldValue('КонтрагентыСписок'), 'АО Запад, ООО Восток', 'значение поля = набор');
});
// ── E: platform pool + Подбор (catalog Контрагенты) ─────────────────────────
await step('E (platform pool+Подбор): выбрать [ООО Север, ООО Юг]', async () => {
const r = await selectValue('Без расширенного', ['ООО Север', 'ООО Юг']);
log(`E selected: ${JSON.stringify(r.selected)}`);
assert.deepEqual(sortq(r.selected.values), sortq(['ООО Север', 'ООО Юг']), 'оба контрагента подобраны');
assert.ok(!r.selected.notSelected, 'notSelected отсутствует');
});
}