feat(web-test): fillTableRow заполняет ячейку-выбор-из-списка через форму выбора

Поле с кнопкой выбора и обработчиком НачалоВыбора (значение выбирается из программного
списка — например колонка Тип в типовой Консоли запросов) раньше заполнялось plain-paste,
который молча откатывался → ok:true/method:direct (ложный успех). Теперь движок детектит
такую ячейку и выбирает значение из формы выбора.

- dom/grid-edit.mjs: readActiveGridCellScript отдаёт buttonKind активной ячейки
  (ref/calc/date/choice по кнопке _DLB/_CB и её классу).
- engine/table/row-fill.mjs v1.20: для kind=choice — F4 → pickFromTypeDialog
  (скан/Ctrl+F/OK) → method:choice; если после выбора открылась форма значения,
  это составная ячейка (нужен {value,type}). Ветка добавлена в Tab-цикл и directEditPick.
- engine/forms/select-value.mjs v1.21: умный dismiss диалога типов на путях
  not_found/multiple — Escape только пока диалог открыт, больше не закрывает
  исходную форму слепым Escape×3.
- Стенд: строковая колонка-выбор ТипЗначения (НачалоВыбора → ПоказатьВыборЭлемента)
  в ДеревоНоменклатуры; тест 16 покрывает method:choice и негатив not_found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-05-31 17:26:37 +03:00
parent 52478a6c39
commit 7c9769c644
5 changed files with 178 additions and 12 deletions
@@ -255,10 +255,22 @@ export function readActiveGridCellScript() {
}
}
}
// Classify the cell's choice button (if any): ref (_DLB), calc/date (_CB iCalcB/iCalendB),
// or bare 'choice' (_CB iCB — value picked from a programmatic list, e.g. НачалоВыбора).
let buttonKind = null;
const base = f.id.replace(/_i\\d+$/, '');
const dlb = document.getElementById(base + '_DLB');
const cb = document.getElementById(base + '_CB');
if (dlb && dlb.offsetWidth > 0) buttonKind = 'ref';
else if (cb && cb.offsetWidth > 0) {
if (cb.classList.contains('iCalcB')) buttonKind = 'calc';
else if (cb.classList.contains('iCalendB')) buttonKind = 'date';
else buttonKind = 'choice';
}
return {
tag: 'INPUT', id: f.id,
fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''),
headerText
headerText, buttonKind
};
}
}
@@ -1,4 +1,4 @@
// web-test forms/select-value v1.20 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers.
// web-test forms/select-value v1.21 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
@@ -265,6 +265,20 @@ export async function pickFromTypeDialog(formNum, typeName) {
return page.evaluate(readTypeDialogVisibleRowsScript(formNum, typeNorm));
}
// Helper: dismiss the type-selection dialog (and any child "Найти") on error.
// Escape closes the dialog chain, but a blind Escape×3 cascades into the underlying
// form. So press Escape only while THIS type dialog is still present, then stop —
// leaving the source form (and cell edit mode) for the caller to handle.
async function dismissTypeDialog() {
for (let i = 0; i < 4; i++) {
const stillOpen = await page.evaluate(
`!!document.getElementById('form${formNum}_OK') || !!document.getElementById('form${formNum}_ValueList')`);
if (!stillOpen) break;
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
}
}
// Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists)
const scan = await readVisibleRows();
@@ -278,7 +292,7 @@ export async function pickFromTypeDialog(formNum, typeName) {
}
if (scan.matches.length > 1) {
for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }
await dismissTypeDialog();
await waitForStable();
throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
}
@@ -307,7 +321,7 @@ export async function pickFromTypeDialog(formNum, typeName) {
const findFormNum = await page.evaluate(findChildFormByButtonScript(formNum, 'Find'));
if (findFormNum === null) {
await page.keyboard.press('Escape');
await dismissTypeDialog();
await waitForStable();
throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection');
}
@@ -320,14 +334,14 @@ export async function pickFromTypeDialog(formNum, typeName) {
const afterSearch = await readVisibleRows();
if (afterSearch.matches.length === 0) {
for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }
await dismissTypeDialog();
await waitForStable();
throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` +
`. Visible: ${(scan.visible || []).join(', ')}`);
}
if (afterSearch.matches.length > 1) {
for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); }
await dismissTypeDialog();
await waitForStable();
throw new Error(`selectValue: multiple types match "${typeName}": ${afterSearch.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`);
}
@@ -1,4 +1,4 @@
// web-test table/row-fill v1.19 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
// web-test table/row-fill v1.20 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import {
@@ -245,10 +245,22 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
return { field: key, ok: false, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` };
}
} else {
// No type specified — close type dialog and report error
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
return { field: key, ok: false, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` };
// No type given — treat as a choice cell: the value IS the list item
// ("Выбрать тип"). Pick it; if a value form follows, it was genuinely a
// composite-value cell that needs {value, type}.
try {
await pickFromTypeDialog(selForm, info.value);
} catch (e) {
return { field: key, ok: false, error: 'not_found', message: e.message };
}
await waitForStable(formNum);
const after = await helperDetectNewForm(formNum);
if (after !== null) {
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
return { field: key, ok: false, error: 'type_required', message: `Cell "${key}" expects { value, type }` };
}
return { field: key, ok: true, method: 'choice' };
}
}
const pr = await pickFromSelectionForm(selForm, key, info.value, formNum);
@@ -294,6 +306,24 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
// Also check if a selection form already appeared
let selForm = await helperDetectNewForm(formNum);
if (selForm === null && inInputAfterDblclick) {
// Choice cell (bare _CB list-pick) — paste would revert silently; open via F4.
const activeCell = await page.evaluate(readActiveGridCellScript());
if (activeCell.buttonKind === 'choice') {
await page.keyboard.press('F4');
let cForm = null;
for (let cw = 0; cw < 8; cw++) {
await page.waitForTimeout(200);
cForm = await helperDetectNewForm(formNum);
if (cForm !== null) break;
}
if (cForm !== null) {
const pr = await directEditPick(cForm, key, info);
info.filled = true;
results.push(pr);
continue;
}
// F4 opened nothing — fall through to paste (best effort)
}
// Plain text/numeric field — fill via clipboard paste
await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] });
await page.waitForTimeout(400);
@@ -529,6 +559,62 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) {
continue;
}
// Choice cell: value is picked from a programmatic list (field with НачалоВыбора →
// ПоказатьВыборЭлемента, e.g. a "Выбрать тип" list). Plain paste reverts silently,
// so open the choice form via F4 and pick from it.
if (cell.buttonKind === 'choice') {
await page.keyboard.press('F4');
let choiceForm = null;
for (let cw = 0; cw < 8; cw++) {
await page.waitForTimeout(200);
choiceForm = await helperDetectNewForm(formNum);
if (choiceForm !== null) break;
}
if (choiceForm === null) {
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'no_selection_form', message: `Cell "${matchedKey}": F4 did not open a choice form` });
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
continue;
}
if (await isTypeDialog(choiceForm)) {
try {
await pickFromTypeDialog(choiceForm, text);
} catch (e) {
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'not_found', message: e.message });
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
continue;
}
await waitForStable(formNum);
// If a value form opened after the pick, this was a composite-value cell → needs {value, type}
const valForm = await helperDetectNewForm(formNum);
if (valForm !== null) {
await page.keyboard.press('Escape'); await page.waitForTimeout(300);
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: false,
error: 'type_required', message: `Cell "${matchedKey}" expects { value, type }` });
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
continue;
}
info.filled = true;
results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'choice', value: text });
if ([...pending.values()].every(p => p.filled)) break;
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
continue;
}
// F4 opened a regular selection form (reference via CB) — pick from it
const pr = await pickFromSelectionForm(choiceForm, matchedKey, text, formNum);
info.filled = true;
results.push(pr.ok
? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }
: { field: matchedKey, cell: cell.fullName, ok: false, error: pr.error, message: pr.message });
if ([...pending.values()].every(p => p.filled)) break;
await page.keyboard.press('Tab'); await page.waitForTimeout(500);
continue;
}
// === Fill this cell: clipboard paste (trusted event) ===
await page.keyboard.press('Control+A');
await pasteText(text);
@@ -812,7 +812,12 @@ export const steps = [
{ name: 'Номенклатура', type: 'CatalogRef.Номенклатура', title: 'Номенклатура' },
{ name: 'Цена', type: 'Number(15,2)', title: 'Цена' },
{ name: 'Картинка', type: 'Boolean', title: 'Картинка' },
// Строковая колонка-выбор-из-списка: значение выбирается обработчиком НачалоВыбора
// через СписокТипов.ПоказатьВыборЭлемента (как колонка Тип в типовой Консоли запросов).
{ name: 'ТипЗначения', type: 'String', title: 'Тип значения' },
]},
// Список значений для программного выбора (ПоказатьВыборЭлемента).
{ name: 'СписокТипов', type: 'ValueList' },
],
elements: [
{ table: 'Дерево', path: 'Дерево', initialTreeView: 'ExpandTopLevel', changeRowSet: true,
@@ -824,6 +829,9 @@ export const steps = [
{ picField: 'ДеревоКартинка', path: 'Дерево.Картинка', title: 'Картинка', valuesPicture: 'StdPicture.Favorites', loadTransparent: true },
// CheckBoxField на тот же булев — для кросс-проверки состояния картинки.
{ check: 'ДеревоКартинкаФлаг', path: 'Дерево.Картинка', title: 'Флаг' },
// Поле-выбор-из-списка с кнопкой выбора и обработчиком НачалоВыбора.
{ input: 'ДеревоТипЗначения', path: 'Дерево.ТипЗначения', title: 'Тип значения',
choiceButton: true, on: ['StartChoice'], handlers: { StartChoice: 'ДеревоТипЗначенияНачалоВыбора' } },
]},
],
},
@@ -836,6 +844,10 @@ export const steps = [
content: `&НаСервере
Процедура ПриСозданииНаСервере(Отказ, СтандартнаяОбработка)
\tЗаполнитьУровень(Дерево.ПолучитьЭлементы(), Справочники.Номенклатура.ПустаяСсылка());
\tСписокТипов.Добавить("Строка");
\tСписокТипов.Добавить("Число");
\tСписокТипов.Добавить("Дата");
\tСписокТипов.Добавить("Булево");
КонецПроцедуры
&НаСервере
@@ -877,6 +889,24 @@ export const steps = [
\t\tТекущиеДанные.Картинка = НЕ ТекущиеДанные.Картинка;
\tКонецЕсли;
КонецПроцедуры
&НаКлиенте
Процедура ДеревоТипЗначенияНачалоВыбора(Элемент, ДанныеВыбора, СтандартнаяОбработка)
\tСтандартнаяОбработка = Ложь;
\tОписаниеОповещения = Новый ОписаниеОповещения("ТипЗначенияЗавершениеВыбора", ЭтотОбъект);
\tСписокТипов.ПоказатьВыборЭлемента(ОписаниеОповещения, НСтр("ru = 'Выбрать тип'"));
КонецПроцедуры
&НаКлиенте
Процедура ТипЗначенияЗавершениеВыбора(ВыбранныйЭлемент, ДополнительныеПараметры) Экспорт
\tЕсли ВыбранныйЭлемент = Неопределено Тогда
\t\tВозврат;
\tКонецЕсли;
\tТекущиеДанные = Элементы.Дерево.ТекущиеДанные;
\tЕсли ТекущиеДанные <> Неопределено Тогда
\t\tТекущиеДанные.ТипЗначения = ВыбранныйЭлемент.Значение;
\tКонецЕсли;
КонецПроцедуры
`,
},
+25 -1
View File
@@ -24,7 +24,7 @@ export default async function({ navigateLink, clickElement, closeForm, readTable
await step('read-roots: на верхнем уровне видны группы (Товары, Услуги, БольшойСписок)', async () => {
const t = await readTable('Дерево');
log(`columns=${t.columns?.join(',')} rows=${t.rows?.length}`);
assert.deepEqual(t.columns, ['Номенклатура', 'Цена', 'Картинка', 'Флаг'], 'колонки: Номенклатура + Цена + Картинка + Флаг');
assert.deepEqual(t.columns, ['Номенклатура', 'Цена', 'Картинка', 'Флаг', 'Тип значения'], 'колонки: Номенклатура + Цена + Картинка + Флаг + Тип значения');
assert.equal(t.rows.length, 3, '3 корневые строки');
const names = t.rows.map(r => r['Номенклатура']);
assert.includes(names, 'Товары', 'есть Товары');
@@ -61,6 +61,30 @@ export default async function({ navigateLink, clickElement, closeForm, readTable
assert.equal(tovar01['Цена'], '1 500,00', 'Цена обновилась до 1 500,00');
});
await step('choice-cell: fillTableRow задаёт ТипЗначения через форму выбора (НачалоВыбора)', async () => {
// Колонка-строка с кнопкой выбора + обработчиком НачалоВыбора → СписокТипов.ПоказатьВыборЭлемента
// («Выбрать тип»). Plain-paste тут не годится — движок открывает форму выбора и выбирает из списка.
const r = await fillTableRow({ ТипЗначения: 'Число' }, { row: 1 });
log(`filled: ${JSON.stringify(r.filled)}`);
const cell = r.filled?.find(f => f.field === 'ТипЗначения');
assert.ok(cell, 'поле ТипЗначения в результате');
assert.equal(cell.ok, true, 'ok=true');
assert.equal(cell.method, 'choice', 'method=choice (выбор из списка, не direct)');
const t = await readTable('Дерево');
const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01');
assert.equal(tovar01['Тип значения'], 'Число', 'ТипЗначения = Число');
});
await step('choice-cell-negative: несуществующий тип → ok:false/not_found (форма не закрывается)', async () => {
// not_found гасит только диалог выбора типа (умный dismiss), исходная форма остаётся —
// следующие шаги (picture) это подтверждают.
const r = await fillTableRow({ ТипЗначения: 'Нетакоготипа' }, { row: 1 });
const cell = r.filled?.find(f => f.field === 'ТипЗначения');
assert.ok(cell, 'поле ТипЗначения в результате');
assert.equal(cell.ok, false, 'ok=false для несуществующего типа');
assert.equal(cell.error, 'not_found', 'error=not_found');
});
await step('picture: колонка-картинка (pic:0/\'\') + кросс-проверка чекбоксом', async () => {
const t = await readTable('Дерево');
const t15 = t.rows.find(r => r['Номенклатура'] === 'Товар 15'); // Цена 1500 > 1000 → иконка