mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-24 05:31:02 +03:00
feat(web-test): фокус на поле ввода через clickElement (fallback)
clickElement как последний fallback (без table) фокусирует одноимённое
поле ввода, не меняя значение — возвращает focused:{field,id,ok}.
Закрывает пробел: клавиши F4/Shift+F4 требовали сфокусированного поля,
но штатного примитива фокуса не было.
- dom/forms.mjs: резолв input.editInput/textarea по имени/заголовку
последним шагом findClickTargetScript; имена полей в available
- click-form.mjs: focusFormField (клик по инпуту + isInputFocused → ok)
- click.mjs: ветка диспетчера kind === field
- SKILL.md + docs/web-test-guide.md: focused в extras, пример focus→F4
- tests: 19-focus-field.test.mjs (focus/F4/регресс/негатив)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3a89aa21e6
commit
f424d2ac70
@@ -235,7 +235,7 @@ Sections + all open tabs.
|
|||||||
|
|
||||||
### Actions
|
### Actions
|
||||||
|
|
||||||
**Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`.
|
**Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `focused`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`.
|
||||||
|
|
||||||
#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state
|
#### `clickElement(text, { dblclick?, table?, expand?, modifier?, scroll? })` → form state
|
||||||
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
||||||
@@ -259,6 +259,11 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match).
|
|||||||
await clickElement('ИСУ ФХД'); // select row
|
await clickElement('ИСУ ФХД'); // select row
|
||||||
await clickElement('ИСУ ФХД', { expand: true }); // expand/collapse
|
await clickElement('ИСУ ФХД', { expand: true }); // expand/collapse
|
||||||
```
|
```
|
||||||
|
- **Focus a field** (last resort, when no `table` given): if `text` matches no clickable control but matches a form field's name/label, clicks the input to focus it **without changing its value**. Returns `focused: { field, id, ok }` (`ok: false` if the field couldn't take focus). Use it to drive focus-dependent keys:
|
||||||
|
```js
|
||||||
|
await clickElement('Контрагент'); // focus the reference field
|
||||||
|
await getPage().keyboard.press('F4'); // open its selection form
|
||||||
|
```
|
||||||
- **Multi-select rows** with `modifier: 'ctrl'` (add to selection) or `modifier: 'shift'` (select range):
|
- **Multi-select rows** with `modifier: 'ctrl'` (add to selection) or `modifier: 'shift'` (select range):
|
||||||
```js
|
```js
|
||||||
await clickElement('Номенклатура 1'); // select first row
|
await clickElement('Номенклатура 1'); // select first row
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test dom/forms v1.4 — form detection, content read, click-target/field-button resolution
|
// web-test dom/forms v1.5 — form detection, content read, click-target/field-button resolution
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
import { DETECT_FORM_FN, READ_FORM_FN } from './_shared.mjs';
|
import { DETECT_FORM_FN, READ_FORM_FN } from './_shared.mjs';
|
||||||
|
|
||||||
@@ -203,7 +203,35 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { error: 'not_found', available: items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean) };
|
// Form input fields — LAST resort: focus a field by name/label without changing its value.
|
||||||
|
// Only when no table scope is given ("если нет уточнения таблицы"): grid cells are handled elsewhere.
|
||||||
|
// Reached only after every clickable target (button/link/tab/nav/grid row) failed to match,
|
||||||
|
// so collisions between a field name and a real control are unlikely.
|
||||||
|
const fields = [];
|
||||||
|
if (!tableName) {
|
||||||
|
[...document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]')].forEach(el => {
|
||||||
|
if (el.offsetWidth === 0) return;
|
||||||
|
// Skip inputs inside a grid — those are table cells, not form fields.
|
||||||
|
let n = el.parentElement; let inGrid = false;
|
||||||
|
while (n) { if (n.classList && n.classList.contains('grid')) { inGrid = true; break; } n = n.parentElement; }
|
||||||
|
if (inGrid) return;
|
||||||
|
const idName = el.id.replace(p, '').replace(/_i\\d+$/, '');
|
||||||
|
const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '#title_div');
|
||||||
|
const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim();
|
||||||
|
fields.push({ id: el.id, name: idName, label });
|
||||||
|
});
|
||||||
|
let ff = fields.find(f => f.label && f.label.toLowerCase() === target);
|
||||||
|
if (!ff) ff = fields.find(f => f.name.toLowerCase() === target);
|
||||||
|
if (!ff) ff = fields.find(f => f.label && f.label.toLowerCase().startsWith(target));
|
||||||
|
if (!ff) ff = fields.find(f => f.name.toLowerCase().startsWith(target));
|
||||||
|
if (!ff && target.length >= 4) ff = fields.find(f => f.label && f.label.toLowerCase().includes(target));
|
||||||
|
if (!ff && target.length >= 4) ff = fields.find(f => f.name.toLowerCase().includes(target));
|
||||||
|
if (ff) return { id: ff.id, kind: 'field', name: ff.label || ff.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean);
|
||||||
|
for (const f of fields) { const nm = f.label || f.name; if (nm && !available.includes(nm)) available.push(nm); }
|
||||||
|
return { error: 'not_found', available };
|
||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test core/click v1.21 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element handlers by target kind.
|
// web-test core/click v1.22 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element / field-focus handlers by target kind.
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
|
|
||||||
import { page, ensureConnected, highlightMode } from './state.mjs';
|
import { page, ensureConnected, highlightMode } from './state.mjs';
|
||||||
@@ -17,7 +17,7 @@ import { clickGridCell } from '../table/click-cell.mjs';
|
|||||||
import {
|
import {
|
||||||
clickConfirmationButton, tryClickPopupItem,
|
clickConfirmationButton, tryClickPopupItem,
|
||||||
} from '../forms/click-popup.mjs';
|
} from '../forms/click-popup.mjs';
|
||||||
import { clickFormTarget } from '../forms/click-form.mjs';
|
import { clickFormTarget, focusFormField } from '../forms/click-form.mjs';
|
||||||
import {
|
import {
|
||||||
clickSpreadsheetCell, findSpreadsheetCellByText,
|
clickSpreadsheetCell, findSpreadsheetCellByText,
|
||||||
} from '../spreadsheet/spreadsheet.mjs';
|
} from '../spreadsheet/spreadsheet.mjs';
|
||||||
@@ -121,6 +121,7 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi
|
|||||||
if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx);
|
if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx);
|
||||||
if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx);
|
if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx);
|
||||||
if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx);
|
if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx);
|
||||||
|
if (target.kind === 'field') return await focusFormField(target, ctx);
|
||||||
return await clickFormTarget(target, ctx);
|
return await clickFormTarget(target, ctx);
|
||||||
} finally {
|
} finally {
|
||||||
if (highlightMode) try { await unhighlight(); } catch {}
|
if (highlightMode) try { await unhighlight(); } catch {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// web-test forms/click-form v1.0 — click handler for form-element targets: button, tab, submenu, link.
|
// web-test forms/click-form v1.1 — click handler for form-element targets: button, tab, submenu, link, field-focus.
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
//
|
//
|
||||||
// Called by core/click.mjs dispatcher after target is found.
|
// Called by core/click.mjs dispatcher after target is found.
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '../../dom.mjs';
|
} from '../../dom.mjs';
|
||||||
import { checkForErrors } from '../core/errors.mjs';
|
import { checkForErrors } from '../core/errors.mjs';
|
||||||
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
|
import { waitForStable, startNetworkMonitor } from '../core/wait.mjs';
|
||||||
import { safeClick, returnFormState } from '../core/helpers.mjs';
|
import { safeClick, returnFormState, isInputFocused } from '../core/helpers.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Click a form target (button, tab, submenu, link) using its resolved {kind, id, x, y, name}.
|
* Click a form target (button, tab, submenu, link) using its resolved {kind, id, x, y, name}.
|
||||||
@@ -105,3 +105,18 @@ export async function clickFormTarget(target, ctx) {
|
|||||||
if (netMonitor) try { await netMonitor.cleanup(); } catch {}
|
if (netMonitor) try { await netMonitor.cleanup(); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus a form input field (last-resort target kind: 'field') by clicking the input itself —
|
||||||
|
* does NOT change its value. Lets the caller then drive focus-dependent shortcuts
|
||||||
|
* (F4 selection form, Shift+F4 clear, etc.) via getPage().keyboard.
|
||||||
|
* Returns flat form state with `focused: { field, id, ok }`; `ok` reflects whether the
|
||||||
|
* input actually received focus (false for disabled/readonly fields). Never throws on ok=false.
|
||||||
|
*/
|
||||||
|
export async function focusFormField(target, ctx) {
|
||||||
|
const selector = `[id="${target.id}"]`;
|
||||||
|
await safeClick(selector, { timeout: 5000 });
|
||||||
|
await waitForStable(ctx.formNum);
|
||||||
|
const ok = await isInputFocused({ allowTextarea: true });
|
||||||
|
return returnFormState({ focused: { field: target.name, id: target.id, ok } });
|
||||||
|
}
|
||||||
|
|||||||
@@ -315,11 +315,11 @@ await clickElement(
|
|||||||
|
|
||||||
### Действия
|
### Действия
|
||||||
|
|
||||||
Все action-функции возвращают **плоский form state** (как `getFormState()`) с action-specific extras (`clicked`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`). Errors всегда на верхнем уровне `.errors` — exec-wrapper автоматически throw'ает на soft validation errors (`modal`/`balloon`).
|
Все action-функции возвращают **плоский form state** (как `getFormState()`) с action-specific extras (`clicked`, `focused`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`). Errors всегда на верхнем уровне `.errors` — exec-wrapper автоматически throw'ает на soft validation errors (`modal`/`balloon`).
|
||||||
|
|
||||||
| Функция | Описание | Возвращает |
|
| Функция | Описание | Возвращает |
|
||||||
|---------|----------|------------|
|
|---------|----------|------------|
|
||||||
| `clickElement(text, {dblclick?, modifier?, table?, scroll?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке spreadsheet или грида формы (`table` форсит грид; `scroll: true \| number` включает reveal-loop через PageDown — см. выше) | form state или `{ submenu }` |
|
| `clickElement(text, {dblclick?, modifier?, table?, scroll?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке spreadsheet или грида формы (`table` форсит грид; `scroll: true \| number` включает reveal-loop через PageDown — см. выше). Если `text` не совпал ни с одним контролом и `table` не задан — как последний fallback фокусирует одноимённое поле ввода (без изменения значения), см. раздел про клавиши | form state (`clicked` / `focused` / `submenu`) |
|
||||||
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | form state с `filled` |
|
| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | form state с `filled` |
|
||||||
| `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` |
|
| `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` |
|
||||||
| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state с `filled` (per-field ошибки как items `ok: false`, см. ниже) + `notFilled?` |
|
| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state с `filled` (per-field ошибки как items `ok: false`, см. ниже) + `notFilled?` |
|
||||||
@@ -364,14 +364,17 @@ await clickElement(
|
|||||||
|
|
||||||
## Клавиатурные сочетания
|
## Клавиатурные сочетания
|
||||||
|
|
||||||
|
Чтобы клавиша применилась к нужному полю, его сперва надо сфокусировать. `clickElement('ИмяПоля')` (без `table`) ставит фокус, ничего не меняя, и возвращает `focused: { field, id, ok }` — после этого жмём клавишу через `getPage()`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
|
await clickElement('Контрагент'); // фокус на ссылочное поле (focused.ok)
|
||||||
const page = await getPage();
|
const page = await getPage();
|
||||||
await page.keyboard.press('F8'); // пример: создать новый элемент в сфокусированном ссылочном поле
|
await page.keyboard.press('F4'); // открыть форму выбора
|
||||||
```
|
```
|
||||||
|
|
||||||
| Клавиша | Контекст | Действие |
|
| Клавиша | Контекст | Действие |
|
||||||
|---------|----------|----------|
|
|---------|----------|----------|
|
||||||
| `F8` | Ссылочное поле | Создать новый элемент |
|
| `F8` | Ссылочное поле | Создать новый элемент (может требовать прав/настройки в 1С) |
|
||||||
| `Shift+F4` | Любое поле | Очистить значение (автоматизировано: `fillFields({ поле: '' })`) |
|
| `Shift+F4` | Любое поле | Очистить значение (автоматизировано: `fillFields({ поле: '' })`) |
|
||||||
| `F4` | Ссылочное поле | Форма выбора |
|
| `F4` | Ссылочное поле | Форма выбора |
|
||||||
| `Alt+F` | Список/таблица | Расширенный поиск |
|
| `Alt+F` | Список/таблица | Расширенный поиск |
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export const name = 'clickElement: фокус на поле ввода (fallback) + клавиши';
|
||||||
|
export const tags = ['click', 'focus', 'smoke'];
|
||||||
|
export const timeout = 120000;
|
||||||
|
|
||||||
|
const findField = (state, name) => state.fields?.find(f => f.name === name || f.label === name);
|
||||||
|
|
||||||
|
export default async function({ navigateSection, openCommand, clickElement, getFormState, closeForm, getPage, wait, assert, step, log }) {
|
||||||
|
|
||||||
|
await step('focus: clickElement по имени поля ставит фокус, не меняя значение', async () => {
|
||||||
|
await navigateSection('Склад');
|
||||||
|
await openCommand('Приходная накладная');
|
||||||
|
await clickElement('Создать');
|
||||||
|
|
||||||
|
const before = findField(await getFormState(), 'Контрагент')?.value || '';
|
||||||
|
|
||||||
|
const r = await clickElement('Контрагент');
|
||||||
|
log('focused: ' + JSON.stringify(r.focused));
|
||||||
|
assert.ok(r.focused, 'должен вернуть focused (а не clicked)');
|
||||||
|
assert.ok(!r.clicked, 'focus-fallback не должен возвращать clicked');
|
||||||
|
assert.equal(r.focused.ok, true, 'фокус должен встать (focused.ok)');
|
||||||
|
assert.includes(r.focused.field, 'Контрагент', 'focused.field — имя/заголовок поля');
|
||||||
|
|
||||||
|
const after = findField(await getFormState(), 'Контрагент')?.value || '';
|
||||||
|
assert.equal(after, before, 'значение поля не должно измениться от фокуса');
|
||||||
|
|
||||||
|
await closeForm({ save: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('keyboard: F4 на сфокусированном поле открывает форму выбора', async () => {
|
||||||
|
await navigateSection('Склад');
|
||||||
|
await openCommand('Приходная накладная');
|
||||||
|
await clickElement('Создать');
|
||||||
|
const formCountBefore = (await getFormState()).formCount;
|
||||||
|
|
||||||
|
const r = await clickElement('Контрагент');
|
||||||
|
assert.equal(r.focused?.ok, true, 'поле сфокусировано перед F4');
|
||||||
|
|
||||||
|
await getPage().keyboard.press('F4');
|
||||||
|
await wait(2);
|
||||||
|
|
||||||
|
const state = await getFormState();
|
||||||
|
log(`formCount: ${formCountBefore} → ${state.formCount}`);
|
||||||
|
assert.ok(state.formCount > formCountBefore, 'F4 должен открыть форму выбора (formCount вырос)');
|
||||||
|
|
||||||
|
await closeForm({ save: false }); // закрыть форму выбора
|
||||||
|
await closeForm({ save: false }); // закрыть накладную
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('regress: clickElement по реальной кнопке возвращает clicked, не focused', async () => {
|
||||||
|
await navigateSection('Склад');
|
||||||
|
await openCommand('Приходная накладная');
|
||||||
|
|
||||||
|
const r = await clickElement('Создать'); // настоящая кнопка
|
||||||
|
assert.ok(r.clicked, 'кнопка → clicked');
|
||||||
|
assert.ok(!r.focused, 'кнопка не должна резолвиться в focus-fallback');
|
||||||
|
|
||||||
|
await closeForm({ save: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await step('negative: несуществующий таргет по-прежнему бросает not_found', async () => {
|
||||||
|
await navigateSection('Склад');
|
||||||
|
await openCommand('Приходная накладная');
|
||||||
|
await clickElement('Создать');
|
||||||
|
|
||||||
|
await assert.throws(
|
||||||
|
() => clickElement('НесуществующееПолеИлиКнопкаXYZ'),
|
||||||
|
'clickElement должен бросить, если нет ни контрола, ни поля',
|
||||||
|
);
|
||||||
|
|
||||||
|
await closeForm({ save: false });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ E2E-тесты движка `web-test` (Playwright + изолированная
|
|||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Полный регресс (все 20 тестов)
|
# Полный регресс (все 21 тестов)
|
||||||
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/
|
node .claude/skills/web-test/scripts/run.mjs test tests/web-test/
|
||||||
|
|
||||||
# Один файл
|
# Один файл
|
||||||
|
|||||||
Reference in New Issue
Block a user