mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-10 16:14:54 +03:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fa279c354 | |||
| 28a2a34c84 | |||
| f0f1e88aaa | |||
| e7cbf306a0 | |||
| 610720334b | |||
| 5090deb5bc | |||
| 8b0bcf0194 | |||
| 529a5cacae | |||
| 8b0f55f1cc | |||
| 54cbc69a59 | |||
| ac3047cf55 | |||
| 5da154adea | |||
| f4748d76af | |||
| b992cd11c5 | |||
| fc76407877 | |||
| a55195ab66 | |||
| 1eff62de42 | |||
| eb87be5c04 | |||
| 43ed9ba142 | |||
| 588382cec1 | |||
| e0197683e1 | |||
| 96dad75b2f | |||
| 5c734202b6 | |||
| a92bce05fb | |||
| b8ebbf6a6f | |||
| 43ba6ce16c | |||
| 51e37f9874 | |||
| 62e864e474 | |||
| ddebd7b6df | |||
| 3d16e35e80 | |||
| 56822c4533 | |||
| 32bf9c1a3f | |||
| c94f86a9cd | |||
| 8b5fed98e0 | |||
| 9e677cfc61 | |||
| 211a4726d6 | |||
| 91b39b758b | |||
| 4af69f1600 | |||
| 8c7c442705 | |||
| c541d51f33 | |||
| a650325baf | |||
| 6c19846051 | |||
| eef4f4bcea | |||
| 2c553fee98 | |||
| 95e4674825 | |||
| 9751840cc8 | |||
| f257bb428c | |||
| 71e3691cf1 | |||
| 1af318325d | |||
| 986480748e | |||
| 7561faf736 | |||
| 2849087fd9 | |||
| 105171cdc2 | |||
| c9cd0d62ab | |||
| c1a0a54971 | |||
| 927c0827f3 | |||
| 56cd18a6b4 | |||
| 3ac1d425cd | |||
| 3c596f4550 | |||
| 36d29a51a9 | |||
| 11e961c816 | |||
| 05ca810461 | |||
| a0407b74dc | |||
| 3aad254399 | |||
| 07753921be | |||
| ba0c71fa45 | |||
| 33c9fdade0 | |||
| 1c1fe7b2d9 | |||
| 0bd2587e74 | |||
| 6f17b1c2f6 | |||
| 36ad686316 | |||
| 66e37fb8cc | |||
| 99c77e1dde | |||
| 8d6612027f | |||
| c3b67a18cb | |||
| 4d1b66638c | |||
| 363a9f34f2 | |||
| 4f8ce7b747 | |||
| fc48d68ed1 | |||
| 3e34ec0bdd | |||
| fff2e83960 | |||
| 1ff209849f | |||
| 1a8415283e | |||
| db1e78a534 | |||
| a828f1847f | |||
| 3e8159b591 | |||
| 57bb964c1e | |||
| 41c4b6b1f7 | |||
| ffb0ee740d | |||
| f5e487096f | |||
| 6d5c1a0b19 | |||
| b322c02fdb | |||
| 61ef7ac891 | |||
| ba19b4111d | |||
| ded11437c5 | |||
| 5eda7f8eb3 | |||
| f39a0d9c5e | |||
| 2347859bdd |
@@ -1,4 +1,4 @@
|
||||
# form-compile v1.20 — Compile 1C managed form from JSON or object metadata
|
||||
# form-compile v1.21 — Compile 1C managed form from JSON or object metadata
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[string]$JsonPath,
|
||||
@@ -1912,6 +1912,7 @@ function Emit-Element {
|
||||
# input-specific
|
||||
"multiLine"=1;"passwordMode"=1;"choiceButton"=1;"clearButton"=1
|
||||
"spinButton"=1;"dropListButton"=1;"markIncomplete"=1;"skipOnInput"=1;"inputHint"=1
|
||||
"textEdit"=1
|
||||
# label/hyperlink
|
||||
"hyperlink"=1
|
||||
# group-specific
|
||||
@@ -2137,6 +2138,7 @@ function Emit-Input {
|
||||
if ($el.spinButton -eq $true) { X "$inner<SpinButton>true</SpinButton>" }
|
||||
if ($el.dropListButton -eq $true) { X "$inner<DropListButton>true</DropListButton>" }
|
||||
if ($el.markIncomplete -eq $true) { X "$inner<AutoMarkIncomplete>true</AutoMarkIncomplete>" }
|
||||
if ($el.textEdit -eq $false) { X "$inner<TextEdit>false</TextEdit>" }
|
||||
if ($el.skipOnInput -eq $true) { X "$inner<SkipOnInput>true</SkipOnInput>" }
|
||||
$hasAmw = $el.PSObject.Properties.Name -contains 'autoMaxWidth'
|
||||
if ($hasAmw) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-compile v1.20 — Compile 1C managed form from JSON or object metadata
|
||||
# form-compile v1.21 — Compile 1C managed form from JSON or object metadata
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import copy
|
||||
@@ -1350,6 +1350,7 @@ KNOWN_KEYS = {
|
||||
"maxWidth", "maxHeight",
|
||||
"multiLine", "passwordMode", "choiceButton", "clearButton",
|
||||
"spinButton", "dropListButton", "markIncomplete", "skipOnInput", "inputHint",
|
||||
"textEdit",
|
||||
"hyperlink",
|
||||
"showTitle", "united", "collapsed",
|
||||
"children", "columns",
|
||||
@@ -1940,6 +1941,8 @@ def emit_input(lines, el, name, eid, indent):
|
||||
lines.append(f'{inner}<DropListButton>true</DropListButton>')
|
||||
if el.get('markIncomplete') is True:
|
||||
lines.append(f'{inner}<AutoMarkIncomplete>true</AutoMarkIncomplete>')
|
||||
if el.get('textEdit') is False:
|
||||
lines.append(f'{inner}<TextEdit>false</TextEdit>')
|
||||
if el.get('skipOnInput') is True:
|
||||
lines.append(f'{inner}<SkipOnInput>true</SkipOnInput>')
|
||||
if 'autoMaxWidth' in el:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# form-validate v1.4 — Validate 1C managed form
|
||||
# form-validate v1.6 — Validate 1C managed form
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -366,13 +366,51 @@ if (-not $stopped) {
|
||||
$dataPath = $dpNode.InnerText.Trim()
|
||||
if (-not $dataPath) { continue }
|
||||
|
||||
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
|
||||
# - bare numeric (e.g. "10", "1000003") — internal index
|
||||
# - "N/M:<uuid>" — metadata reference by UUID
|
||||
if ($dataPath -match '^\d+$' -or $dataPath -match '^\d+/\d+:[0-9a-fA-F-]+$') {
|
||||
continue
|
||||
}
|
||||
|
||||
$pathChecked++
|
||||
|
||||
# Extract root segment of path, strip array indices like [0]
|
||||
$cleanPath = $dataPath -replace '\[\d+\]', ''
|
||||
# Strip leading '~' (current row of DynamicList: ~Список.Поле)
|
||||
if ($cleanPath.StartsWith('~')) { $cleanPath = $cleanPath.Substring(1) }
|
||||
$segments = $cleanPath -split '\.'
|
||||
$rootAttr = $segments[0]
|
||||
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... — table element, not attribute
|
||||
if ($rootAttr -eq 'Items') {
|
||||
if ($segments.Count -lt 3 -or $segments[2] -ne 'CurrentData') {
|
||||
Report-Warn "[$tag] '$elName': DataPath='$dataPath' — unknown Items.* shape, expected Items.<Table>.CurrentData.*"
|
||||
continue
|
||||
}
|
||||
$tableName = $segments[1]
|
||||
$tableEl = $null
|
||||
foreach ($candidate in $allElements) {
|
||||
if ($candidate.Tag -eq 'Table' -and $candidate.Name -eq $tableName) {
|
||||
$tableEl = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $tableEl) {
|
||||
Report-Error "[$tag] '$elName': DataPath='$dataPath' — table element '$tableName' not found"
|
||||
$pathErrors++
|
||||
continue
|
||||
}
|
||||
$tableDpNode = $tableEl.Node.SelectSingleNode("f:DataPath", $nsMgr)
|
||||
if (-not $tableDpNode -or -not $tableDpNode.InnerText.Trim()) {
|
||||
# Table without DataPath — can't resolve further, accept silently
|
||||
continue
|
||||
}
|
||||
$tableDp = $tableDpNode.InnerText.Trim() -replace '\[\d+\]', ''
|
||||
if ($tableDp.StartsWith('~')) { $tableDp = $tableDp.Substring(1) }
|
||||
$rootAttr = ($tableDp -split '\.')[0]
|
||||
}
|
||||
|
||||
if (-not $attrMap.ContainsKey($rootAttr)) {
|
||||
Report-Error "[$tag] '$elName': DataPath='$dataPath' — attribute '$rootAttr' not found"
|
||||
$pathErrors++
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# form-validate v1.4 — Validate 1C managed form
|
||||
# form-validate v1.6 — Validate 1C managed form
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -376,12 +376,44 @@ def main():
|
||||
if not data_path:
|
||||
continue
|
||||
|
||||
# Opaque platform-internal DataPath shapes — not validatable from Form.xml alone:
|
||||
# - bare numeric (e.g. "10", "1000003") — internal index
|
||||
# - "N/M:<uuid>" — metadata reference by UUID
|
||||
if re.match(r'^\d+$', data_path) or re.match(r'^\d+/\d+:[0-9a-fA-F-]+$', data_path):
|
||||
continue
|
||||
|
||||
path_checked += 1
|
||||
|
||||
clean_path = re.sub(r'\[\d+\]', '', data_path)
|
||||
# Strip leading '~' (current row of DynamicList: ~\u0421\u043f\u0438\u0441\u043e\u043a.\u041f\u043e\u043b\u0435)
|
||||
if clean_path.startswith('~'):
|
||||
clean_path = clean_path[1:]
|
||||
segments = clean_path.split(".")
|
||||
root_attr = segments[0]
|
||||
|
||||
# Resolve Items.<TableName>.CurrentData.<Field>... \u2014 table element, not attribute
|
||||
if root_attr == 'Items':
|
||||
if len(segments) < 3 or segments[2] != 'CurrentData':
|
||||
report_warn(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 unknown Items.* shape, expected Items.<Table>.CurrentData.*")
|
||||
continue
|
||||
table_name = segments[1]
|
||||
table_el = None
|
||||
for candidate in all_elements:
|
||||
if candidate["Tag"] == 'Table' and candidate["Name"] == table_name:
|
||||
table_el = candidate
|
||||
break
|
||||
if table_el is None:
|
||||
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 table element '{table_name}' not found")
|
||||
path_errors += 1
|
||||
continue
|
||||
table_dp_node = table_el["Node"].find(f"{{{F_NS}}}DataPath")
|
||||
if table_dp_node is None or not (table_dp_node.text or "").strip():
|
||||
continue
|
||||
table_dp = re.sub(r'\[\d+\]', '', (table_dp_node.text or "").strip())
|
||||
if table_dp.startswith('~'):
|
||||
table_dp = table_dp[1:]
|
||||
root_attr = table_dp.split(".")[0]
|
||||
|
||||
if root_attr not in attr_map:
|
||||
report_error(f"[{tag}] '{el_name}': DataPath='{data_path}' \u2014 attribute '{root_attr}' not found")
|
||||
path_errors += 1
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# meta-compile v1.11 — Compile 1C metadata object from JSON
|
||||
# meta-compile v1.12 — Compile 1C metadata object from JSON
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -502,6 +502,7 @@ function Parse-AttributeShorthand {
|
||||
fillChecking = if ($val.fillChecking) { "$($val.fillChecking)" } else { "" }
|
||||
indexing = if ($val.indexing) { "$($val.indexing)" } else { "" }
|
||||
multiLine = if ($val.multiLine -eq $true) { $true } else { $false }
|
||||
choiceHistoryOnInput = if ($val.choiceHistoryOnInput) { "$($val.choiceHistoryOnInput)" } else { "" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,7 +823,8 @@ function Emit-Attribute {
|
||||
X "$indent`t`t<CreateOnInput>Auto</CreateOnInput>"
|
||||
X "$indent`t`t<ChoiceForm/>"
|
||||
X "$indent`t`t<LinkByType/>"
|
||||
X "$indent`t`t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>"
|
||||
$chi = if ($parsed.choiceHistoryOnInput) { $parsed.choiceHistoryOnInput } else { "Auto" }
|
||||
X "$indent`t`t<ChoiceHistoryOnInput>$chi</ChoiceHistoryOnInput>"
|
||||
|
||||
# Use — only for catalog top-level attributes
|
||||
if ($context -eq "catalog") {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# meta-compile v1.11 — Compile 1C metadata object from JSON
|
||||
# meta-compile v1.12 — Compile 1C metadata object from JSON
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -465,6 +465,7 @@ def parse_attribute_shorthand(val):
|
||||
'fillChecking': str(val['fillChecking']) if val.get('fillChecking') else '',
|
||||
'indexing': str(val['indexing']) if val.get('indexing') else '',
|
||||
'multiLine': True if val.get('multiLine') is True else False,
|
||||
'choiceHistoryOnInput': str(val['choiceHistoryOnInput']) if val.get('choiceHistoryOnInput') else '',
|
||||
}
|
||||
|
||||
def parse_enum_value_shorthand(val):
|
||||
@@ -774,7 +775,8 @@ def emit_attribute(indent, parsed, context):
|
||||
X(f'{indent}\t\t<CreateOnInput>Auto</CreateOnInput>')
|
||||
X(f'{indent}\t\t<ChoiceForm/>')
|
||||
X(f'{indent}\t\t<LinkByType/>')
|
||||
X(f'{indent}\t\t<ChoiceHistoryOnInput>Auto</ChoiceHistoryOnInput>')
|
||||
chi = parsed.get('choiceHistoryOnInput') or 'Auto'
|
||||
X(f'{indent}\t\t<ChoiceHistoryOnInput>{chi}</ChoiceHistoryOnInput>')
|
||||
if context == 'catalog':
|
||||
X(f'{indent}\t\t<Use>ForItem</Use>')
|
||||
if context not in ('processor', 'processor-tabular'):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# skd-compile v1.21 — Compile 1C DCS from JSON
|
||||
# skd-compile v1.22 — Compile 1C DCS from JSON
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[string]$DefinitionFile,
|
||||
@@ -1130,6 +1130,8 @@ function Emit-ParamValue {
|
||||
X "$indent<value xsi:type=`"xs:decimal`">$(Esc-Xml $valStr)</value>"
|
||||
} elseif ($type -match '^string') {
|
||||
X "$indent<value xsi:type=`"xs:string`">$(Esc-Xml $valStr)</value>"
|
||||
} elseif ($type -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.') {
|
||||
X "$indent<value xsi:type=`"dcscor:DesignTimeValue`">$(Esc-Xml $valStr)</value>"
|
||||
} else {
|
||||
# Guess from value
|
||||
if ($valStr -match '^\d{4}-\d{2}-\d{2}T') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# skd-compile v1.21 — Compile 1C DCS from JSON
|
||||
# skd-compile v1.22 — Compile 1C DCS from JSON
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import json
|
||||
@@ -811,6 +811,8 @@ def emit_param_value(lines, type_str, val, indent):
|
||||
lines.append(f'{indent}<value xsi:type="xs:decimal">{esc_xml(val_str)}</value>')
|
||||
elif type_str and re.match(r'^string', type_str):
|
||||
lines.append(f'{indent}<value xsi:type="xs:string">{esc_xml(val_str)}</value>')
|
||||
elif type_str and re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.', type_str):
|
||||
lines.append(f'{indent}<value xsi:type="dcscor:DesignTimeValue">{esc_xml(val_str)}</value>')
|
||||
else:
|
||||
# Guess from value
|
||||
if re.match(r'^\d{4}-\d{2}-\d{2}T', val_str):
|
||||
|
||||
@@ -54,9 +54,12 @@ Shorthand: `"Имя [Заголовок]: тип @роль #ограничени
|
||||
|
||||
### add-total — добавить итог
|
||||
|
||||
Shorthand: `"<dataPath>: <выражение>"`. Если выражение — известная аггрегатная функция без скобок (`Сумма`, `Количество`, `Минимум`, `Максимум`, `Среднее`), оно автоматически оборачивается в `Func(dataPath)`. Если функция со скобками или произвольное выражение — используется как есть.
|
||||
|
||||
```
|
||||
"Цена: Среднее"
|
||||
"Стоимость: Сумма(Кол * Цена)"
|
||||
"Цена: Среднее" # → Среднее(Цена)
|
||||
"Стоимость: Сумма(Кол * Цена)" # → как есть
|
||||
"Проверка: Проверка" # identity: выражение = Проверка
|
||||
```
|
||||
|
||||
### add-calculated-field — добавить вычисляемое поле
|
||||
@@ -80,24 +83,46 @@ Shorthand: `"Имя [Заголовок]: тип = Выражение #noFilter
|
||||
"Организация: CatalogRef.Организации"
|
||||
```
|
||||
|
||||
Shorthand: `"Имя [Заголовок]: тип = значение @флаги"`. `[Заголовок]` опциональный — добавляет `<title>`.
|
||||
Shorthand: `"Имя [Заголовок]: тип = значение [availableValue=список] [@флаги]"`. `[Заголовок]` опциональный — добавляет `<title>`.
|
||||
|
||||
`@autoDates` генерирует пару скрытых параметров `ДатаНачала`/`ДатаОкончания` для StandardPeriod-параметра — для БСП-отчётов, чтобы получить пару полей «Начало/Конец» в панели быстрых настроек.
|
||||
Флаги:
|
||||
- `@autoDates` — генерирует пару скрытых параметров `ДатаНачала`/`ДатаОкончания` для StandardPeriod-параметра.
|
||||
- `@hidden` — скрывает параметр от пользовательских настроек (для параметров-констант, используемых в запросе).
|
||||
- `@always` — параметр всегда подставляется в запрос. Часто вместе с `@hidden`, но используется и отдельно (для видимых обязательных параметров типа отчётного периода).
|
||||
|
||||
```
|
||||
"ПС: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка @hidden"
|
||||
"Период: StandardPeriod = LastMonth @always"
|
||||
"ПСчет: ChartOfAccountsRef.Хозрасчетный = ПланСчетов.Хозрасчетный.X @hidden @always"
|
||||
"Округление: EnumRef.Округления = Окр1 availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс."
|
||||
```
|
||||
|
||||
`availableValue=` задаёт начальный список допустимых значений. Формат списка: `v1[: p1], v2[: p2], ...` — элементы через `,`, представление после `:`. Если в значении или представлении встречается `,` или `:` — оборачивай в одинарные кавычки `'...'`:
|
||||
|
||||
```
|
||||
"Округление: ... = Окр1 availableValue=Окр1_00: 'руб., коп.', Окр1: руб."
|
||||
```
|
||||
|
||||
### modify-parameter — изменить существующий параметр
|
||||
|
||||
Находит параметр по имени, добавляет/обновляет свойства.
|
||||
Shorthand: `"ИмяПараметра [Заголовок] [ключ=значение]... [@флаги]"`. Находит параметр по имени, обновляет указанные свойства.
|
||||
|
||||
```
|
||||
"ПорядокОкругления use=Always"
|
||||
"ПорядокОкругления [Округление сумм] denyIncompleteValues=true"
|
||||
"ПериодОтчета [Отчетный период]" # только title
|
||||
"ПорядокОкругления availableValue=Перечисление.Округления.Окр1 presentation=руб."
|
||||
"ПорядокОкругления availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс."
|
||||
"СчетПС value=ПланСчетов.Хозрасчетный.КассаПредприятия"
|
||||
"Контрагент @hidden @always"
|
||||
```
|
||||
|
||||
`[Заголовок]` опциональный — устанавливает или заменяет `<title>`. Можно вызывать без других kv-пар, чтобы только обновить title.
|
||||
|
||||
`availableValue=` добавляет один элемент списка допустимых значений (можно несколько через `;;`). Тип значения определяется автоматически (DesignTimeValue для ссылок).
|
||||
`availableValue=` **заменяет весь список** допустимых значений (старые удаляются). Формат и кавычки — те же, что в `add-parameter`.
|
||||
|
||||
`value=` заменяет значение параметра (тип значения подбирается автоматически по объявленному типу параметра).
|
||||
|
||||
Флаги `@hidden` / `@always` — те же, что и в `add-parameter`. Идемпотентны.
|
||||
|
||||
### rename-parameter — переименовать параметр
|
||||
|
||||
@@ -231,13 +256,17 @@ Value — имена ресурсов (как в полях/вычисляемы
|
||||
|
||||
### patch-query — точечная замена в тексте запроса
|
||||
|
||||
Shorthand: `"старое => новое"`. Заменяет все вхождения подстроки. Поддерживает пакетный режим и `-DataSet`.
|
||||
Shorthand: `"старое => новое [@once]"`. По умолчанию заменяет все вхождения подстроки. Поддерживает пакетный режим и `-DataSet`.
|
||||
|
||||
```
|
||||
"СубконтоДт1) В => СубконтоКт1) В"
|
||||
"ЛЕВОЕ СОЕДИНЕНИЕ => ВНУТРЕННЕЕ СОЕДИНЕНИЕ"
|
||||
"КАК ВТ_СтароеИмя => КАК ВТ_НовоеИмя @once"
|
||||
```
|
||||
|
||||
`@once` — упасть с ошибкой, если в запросе не **ровно одно** вхождение. Защищает от случайных замен в комментариях и однотипных идентификаторах.
|
||||
|
||||
Многострочные подстроки поддерживаются — переводы строк в `старое`/`новое` сравниваются буквально (включая отступы).
|
||||
|
||||
### set-outputParameter — установить параметр вывода
|
||||
|
||||
```
|
||||
@@ -249,16 +278,27 @@ Shorthand: `"старое => новое"`. Заменяет все вхожде
|
||||
|
||||
### set-structure — установить структуру варианта
|
||||
|
||||
Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — детальные записи. Заменяет всю структуру. Не поддерживает пакетный режим.
|
||||
Shorthand: `"Поле1 > Поле2 > details"`. `>` — вложенный уровень группировки, `,` — несколько полей в одном уровне, `details` — детальные записи. **Заменяет всю структуру полностью** (включая Selection/order/filter/conditionalAppearance каждой группы). Для точечной модификации полей группировки с сохранением настроек — используй `modify-structure`. Не поддерживает пакетный режим.
|
||||
|
||||
```
|
||||
"Организация > Номенклатура > details"
|
||||
"Валюта, НаименованиеБанка, ИНН"
|
||||
"details"
|
||||
"СчетМеждународногоУчета @name=ДанныеОтчета"
|
||||
```
|
||||
|
||||
`@name=Имя` — присваивает имя группировке (`<dcsset:name>`). Используется для привязки шаблонов через `groupName`.
|
||||
|
||||
### modify-structure — изменить поля группировки существующей группы
|
||||
|
||||
Тот же shorthand что и `set-structure`. Находит группу по `@name=`, заменяет только `<groupItems>` (поля группировки). Selection/order/filter/conditionalAppearance/outputParameters группы сохраняются. Без `@name=` — ошибка.
|
||||
|
||||
```
|
||||
"Валюта @name=ДанныеОтчета"
|
||||
"Валюта, НаименованиеБанка @name=ДанныеОтчета"
|
||||
"details @name=ДанныеОтчета"
|
||||
```
|
||||
|
||||
### modify-field — изменить существующее поле
|
||||
|
||||
Тот же shorthand что и `add-field`. Находит по dataPath, объединяет свойства (непустые переопределяют), сохраняет позицию.
|
||||
@@ -267,6 +307,23 @@ Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — д
|
||||
"Цена [Цена USD]: decimal(10,4) @dimension"
|
||||
```
|
||||
|
||||
### set-field-role — установить роль поля
|
||||
|
||||
Shorthand: `"<dataPath> [@флаги] [kv=значение]"`. **Полностью заменяет** роль поля. Если в значении только dataPath без флагов/kv — удаляет роль.
|
||||
|
||||
```
|
||||
"Сумма" # снять роль полностью
|
||||
"СуммаОстаток @balance" # простая балансовая роль
|
||||
"СуммаНач @balance balanceGroupName=Сумма balanceType=OpeningBalance" # с уточнением
|
||||
"Контрагент @dimension parentDimension=Группа"
|
||||
"Период @period" # роль периода
|
||||
```
|
||||
|
||||
Флаги: `@balance`, `@dimension`, `@account`, `@period`, `@required`, `@autoOrder`, `@ignoreNullValues`.
|
||||
KV: `balanceGroupName`, `balanceType` (OpeningBalance/ClosingBalance), `parentDimension`, `accountTypeExpression`, `orderType` (Asc/Desc), `expression`, `periodNumber`, `periodType`.
|
||||
|
||||
Поддерживает пакетный режим (`;;`).
|
||||
|
||||
### modify-filter — изменить существующий фильтр
|
||||
|
||||
Тот же shorthand что и `add-filter`. Находит по полю, обновляет оператор/значение/флаги. См. правило для `<use>` ниже.
|
||||
@@ -294,6 +351,7 @@ Shorthand: `"Поле1 > Поле2 > details"`. `details`/`детали` — д
|
||||
| `clear-selection` | `*` | Очищает все элементы selection |
|
||||
| `clear-order` | `*` | Очищает все элементы order |
|
||||
| `clear-filter` | `*` | Очищает все элементы filter |
|
||||
| `clear-conditionalAppearance` | `*` | Очищает все правила условного оформления |
|
||||
|
||||
## Верификация
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# skd-edit v1.11 — Atomic 1C DCS editor
|
||||
# skd-edit v1.18 — Atomic 1C DCS editor
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
@@ -11,9 +11,9 @@ param(
|
||||
"add-dataParameter","add-order","add-selection","add-dataSetLink",
|
||||
"add-dataSet","add-variant","add-conditionalAppearance","add-drilldown",
|
||||
"set-query","patch-query","set-outputParameter","set-structure",
|
||||
"modify-field","modify-filter","modify-dataParameter","modify-parameter",
|
||||
"modify-field","modify-filter","modify-dataParameter","modify-parameter","modify-structure","set-field-role",
|
||||
"rename-parameter","reorder-parameters",
|
||||
"clear-selection","clear-order","clear-filter",
|
||||
"clear-selection","clear-order","clear-filter","clear-conditionalAppearance",
|
||||
"remove-field","remove-total","remove-calculated-field","remove-parameter","remove-filter")]
|
||||
[string]$Operation,
|
||||
|
||||
@@ -238,14 +238,25 @@ function Read-FieldProperties($fieldEl) {
|
||||
function Parse-TotalShorthand {
|
||||
param([string]$s)
|
||||
|
||||
# "DataPath: Func" or "DataPath: Func(expr)" or "DataPath: ИмяРесурса" (identity)
|
||||
$parts = $s -split ':', 2
|
||||
$dataPath = $parts[0].Trim()
|
||||
$funcPart = $parts[1].Trim()
|
||||
|
||||
# Known DCS aggregate functions (ru + en)
|
||||
$aggFuncs = @('Сумма','Количество','Минимум','Максимум','Среднее',
|
||||
'Sum','Count','Min','Max','Avg',
|
||||
'Minimum','Maximum','Average')
|
||||
|
||||
if ($funcPart -match '^\w+\(') {
|
||||
# Already has expression form: Func(expr)
|
||||
return @{ dataPath = $dataPath; expression = $funcPart }
|
||||
} else {
|
||||
} elseif ($funcPart -in $aggFuncs) {
|
||||
# Short: Func → Func(DataPath)
|
||||
return @{ dataPath = $dataPath; expression = "$funcPart($dataPath)" }
|
||||
} else {
|
||||
# Identity or custom expression — use as-is
|
||||
return @{ dataPath = $dataPath; expression = $funcPart }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,13 +308,29 @@ function Parse-CalcShorthand {
|
||||
function Parse-ParamShorthand {
|
||||
param([string]$s)
|
||||
|
||||
$result = @{ name = ""; type = ""; value = $null; autoDates = $false; title = $null }
|
||||
$result = @{ name = ""; type = ""; value = $null; autoDates = $false; title = $null; hidden = $false; always = $false; availableValues = @() }
|
||||
|
||||
# Extract availableValue=... (must be before main parse — captures to end of string)
|
||||
if ($s -match '\s*availableValue=(.+)$') {
|
||||
$result.availableValues = Parse-AvailableValueList $Matches[1].Trim()
|
||||
$s = ($s -replace '\s*availableValue=.+$', '').Trim()
|
||||
}
|
||||
|
||||
if ($s -match '@autoDates') {
|
||||
$result.autoDates = $true
|
||||
$s = $s -replace '\s*@autoDates', ''
|
||||
}
|
||||
|
||||
if ($s -match '@hidden\b') {
|
||||
$result.hidden = $true
|
||||
$s = $s -replace '\s*@hidden\b', ''
|
||||
}
|
||||
|
||||
if ($s -match '@always\b') {
|
||||
$result.always = $true
|
||||
$s = $s -replace '\s*@always\b', ''
|
||||
}
|
||||
|
||||
# Extract optional [Title] (mirrors Parse-FieldShorthand)
|
||||
if ($s -match '\[([^\]]*)\]') {
|
||||
$result.title = $Matches[1].Trim()
|
||||
@@ -581,15 +608,17 @@ function Parse-StructureShorthand {
|
||||
$seg = $segments[$i].Trim()
|
||||
$group = @{ type = "group" }
|
||||
|
||||
if ($seg -match '@name=(.+)') {
|
||||
$group["name"] = $Matches[1].Trim()
|
||||
$seg = ($seg -replace '\s*@name=.+', '').Trim()
|
||||
if ($seg -match '@name=(?:"([^"]+)"|''([^'']+)''|(\S+))') {
|
||||
$rawName = if ($Matches[1]) { $Matches[1] } elseif ($Matches[2]) { $Matches[2] } else { $Matches[3] }
|
||||
$group["name"] = $rawName.Trim()
|
||||
$seg = ($seg -replace '\s*@name=(?:"[^"]+"|''[^'']+''|\S+)', '').Trim()
|
||||
}
|
||||
|
||||
if ($seg -match '^(?i)(details|детали)$') {
|
||||
$group["groupBy"] = @()
|
||||
} else {
|
||||
$group["groupBy"] = @($seg)
|
||||
$fields = @($seg -split '\s*,\s*' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
$group["groupBy"] = $fields
|
||||
}
|
||||
|
||||
if ($null -ne $innermost) {
|
||||
@@ -614,6 +643,75 @@ function Parse-OutputParamShorthand {
|
||||
return @{ key = $s.Trim(); value = "" }
|
||||
}
|
||||
|
||||
function Parse-AvailableValueList {
|
||||
# Returns array of @{ value=...; presentation=... } from comma-separated list.
|
||||
# Items can use 'single' or "double" quotes (stripped). Quoted spans preserve commas/colons.
|
||||
param([string]$s)
|
||||
|
||||
$result = @()
|
||||
if (-not $s) { return ,$result }
|
||||
|
||||
# Tokenize by ',' respecting quoted spans
|
||||
$items = @()
|
||||
$buf = New-Object System.Text.StringBuilder
|
||||
$inQuote = $null
|
||||
for ($i = 0; $i -lt $s.Length; $i++) {
|
||||
$ch = $s[$i]
|
||||
if ($inQuote) {
|
||||
[void]$buf.Append($ch)
|
||||
if ($ch -eq $inQuote) { $inQuote = $null }
|
||||
} elseif ($ch -eq "'" -or $ch -eq '"') {
|
||||
$inQuote = $ch
|
||||
[void]$buf.Append($ch)
|
||||
} elseif ($ch -eq ',') {
|
||||
$items += $buf.ToString()
|
||||
[void]$buf.Clear()
|
||||
} else {
|
||||
[void]$buf.Append($ch)
|
||||
}
|
||||
}
|
||||
if ($buf.Length -gt 0) { $items += $buf.ToString() }
|
||||
|
||||
# For each item: split into value[:presentation], strip quotes
|
||||
$stripQuotes = {
|
||||
param($t)
|
||||
$t = $t.Trim()
|
||||
if ($t.Length -ge 2 -and (($t[0] -eq "'" -and $t[-1] -eq "'") -or ($t[0] -eq '"' -and $t[-1] -eq '"'))) {
|
||||
return $t.Substring(1, $t.Length - 2)
|
||||
}
|
||||
return $t
|
||||
}
|
||||
|
||||
foreach ($raw in $items) {
|
||||
$item = $raw.Trim()
|
||||
if (-not $item) { continue }
|
||||
|
||||
# Find first ':' outside quotes
|
||||
$colonIdx = -1
|
||||
$q = $null
|
||||
for ($j = 0; $j -lt $item.Length; $j++) {
|
||||
$c = $item[$j]
|
||||
if ($q) {
|
||||
if ($c -eq $q) { $q = $null }
|
||||
} elseif ($c -eq "'" -or $c -eq '"') {
|
||||
$q = $c
|
||||
} elseif ($c -eq ':') {
|
||||
$colonIdx = $j; break
|
||||
}
|
||||
}
|
||||
|
||||
if ($colonIdx -ge 0) {
|
||||
$valPart = $item.Substring(0, $colonIdx)
|
||||
$presPart = $item.Substring($colonIdx + 1)
|
||||
$result += @{ value = (& $stripQuotes $valPart); presentation = (& $stripQuotes $presPart) }
|
||||
} else {
|
||||
$result += @{ value = (& $stripQuotes $item); presentation = "" }
|
||||
}
|
||||
}
|
||||
|
||||
return ,$result
|
||||
}
|
||||
|
||||
# --- 4. Build-* functions (XML fragment generators) ---
|
||||
|
||||
function Build-ValueTypeXml {
|
||||
@@ -804,6 +902,68 @@ function Build-CalcFieldFragment {
|
||||
return $lines -join "`r`n"
|
||||
}
|
||||
|
||||
function Build-ParamValueXml {
|
||||
# Returns array of XML lines for a <value xsi:type=...>...</value> element (or StandardPeriod block).
|
||||
# Selects xsi:type by declared type, then falls back to value pattern.
|
||||
param([string]$type, [string]$value, [string]$indent, [string]$tagName = "value", [string]$tagNs = "")
|
||||
|
||||
$i = $indent
|
||||
$valStr = "$value"
|
||||
$open = if ($tagNs) { "$tagNs`:$tagName" } else { $tagName }
|
||||
$lines = @()
|
||||
|
||||
if ($type -eq "StandardPeriod") {
|
||||
$lines += "$i<$open xsi:type=`"v8:StandardPeriod`">"
|
||||
$lines += "$i`t<v8:variant xsi:type=`"v8:StandardPeriodVariant`">$(Esc-Xml $valStr)</v8:variant>"
|
||||
$lines += "$i`t<v8:startDate>0001-01-01T00:00:00</v8:startDate>"
|
||||
$lines += "$i`t<v8:endDate>0001-01-01T00:00:00</v8:endDate>"
|
||||
$lines += "$i</$open>"
|
||||
return $lines
|
||||
}
|
||||
|
||||
$xsi = $null
|
||||
if ($type -match '^date') { $xsi = "xs:dateTime" }
|
||||
elseif ($type -eq "boolean") { $xsi = "xs:boolean" }
|
||||
elseif ($type -match '^decimal') { $xsi = "xs:decimal" }
|
||||
elseif ($type -match '^string') { $xsi = "xs:string" }
|
||||
elseif ($type -match '^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.') {
|
||||
$xsi = "dcscor:DesignTimeValue"
|
||||
}
|
||||
else {
|
||||
# Type unknown or empty — guess from value
|
||||
if ($valStr -match '^\d{4}-\d{2}-\d{2}T') { $xsi = "xs:dateTime" }
|
||||
elseif ($valStr -eq "true" -or $valStr -eq "false") { $xsi = "xs:boolean" }
|
||||
elseif ($valStr -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.' -or
|
||||
$valStr -match '^(Catalog|Document|Enum|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.') {
|
||||
$xsi = "dcscor:DesignTimeValue"
|
||||
}
|
||||
else { $xsi = "xs:string" }
|
||||
}
|
||||
|
||||
$lines += "$i<$open xsi:type=`"$xsi`">$(Esc-Xml $valStr)</$open>"
|
||||
return $lines
|
||||
}
|
||||
|
||||
function Build-AvailableValueFragment {
|
||||
# Returns XML lines (array) for a single <availableValue> block.
|
||||
param($item, [string]$declaredType, [string]$indent)
|
||||
|
||||
$lines = @()
|
||||
$lines += "$indent<availableValue>"
|
||||
$valueLines = Build-ParamValueXml -type $declaredType -value $item.value -indent "$indent`t"
|
||||
foreach ($vl in $valueLines) { $lines += $vl }
|
||||
if ($item.presentation) {
|
||||
$lines += "$indent`t<presentation xsi:type=`"v8:LocalStringType`">"
|
||||
$lines += "$indent`t`t<v8:item>"
|
||||
$lines += "$indent`t`t`t<v8:lang>ru</v8:lang>"
|
||||
$lines += "$indent`t`t`t<v8:content>$(Esc-Xml $item.presentation)</v8:content>"
|
||||
$lines += "$indent`t`t</v8:item>"
|
||||
$lines += "$indent`t</presentation>"
|
||||
}
|
||||
$lines += "$indent</availableValue>"
|
||||
return $lines
|
||||
}
|
||||
|
||||
function Build-ParamFragment {
|
||||
param($parsed, [string]$indent)
|
||||
|
||||
@@ -825,24 +985,26 @@ function Build-ParamFragment {
|
||||
}
|
||||
|
||||
if ($null -ne $parsed.value) {
|
||||
$valStr = "$($parsed.value)"
|
||||
if ($parsed.type -eq "StandardPeriod") {
|
||||
$lines += "$i`t<value xsi:type=`"v8:StandardPeriod`">"
|
||||
$lines += "$i`t`t<v8:variant xsi:type=`"v8:StandardPeriodVariant`">$(Esc-Xml $valStr)</v8:variant>"
|
||||
$lines += "$i`t`t<v8:startDate>0001-01-01T00:00:00</v8:startDate>"
|
||||
$lines += "$i`t`t<v8:endDate>0001-01-01T00:00:00</v8:endDate>"
|
||||
$lines += "$i`t</value>"
|
||||
} elseif ($parsed.type -match '^date') {
|
||||
$lines += "$i`t<value xsi:type=`"xs:dateTime`">$(Esc-Xml $valStr)</value>"
|
||||
} elseif ($parsed.type -eq "boolean") {
|
||||
$lines += "$i`t<value xsi:type=`"xs:boolean`">$(Esc-Xml $valStr)</value>"
|
||||
} elseif ($parsed.type -match '^decimal') {
|
||||
$lines += "$i`t<value xsi:type=`"xs:decimal`">$(Esc-Xml $valStr)</value>"
|
||||
} else {
|
||||
$lines += "$i`t<value xsi:type=`"xs:string`">$(Esc-Xml $valStr)</value>"
|
||||
$valueLines = Build-ParamValueXml -type $parsed.type -value $parsed.value -indent "$i`t"
|
||||
foreach ($vl in $valueLines) { $lines += $vl }
|
||||
}
|
||||
|
||||
if ($parsed.hidden) {
|
||||
$lines += "$i`t<useRestriction>true</useRestriction>"
|
||||
$lines += "$i`t<availableAsField>false</availableAsField>"
|
||||
}
|
||||
|
||||
if ($parsed.availableValues -and $parsed.availableValues.Count -gt 0) {
|
||||
foreach ($av in $parsed.availableValues) {
|
||||
$avLines = Build-AvailableValueFragment -item $av -declaredType $parsed.type -indent "$i`t"
|
||||
foreach ($l in $avLines) { $lines += $l }
|
||||
}
|
||||
}
|
||||
|
||||
if ($parsed.always) {
|
||||
$lines += "$i`t<use>Always</use>"
|
||||
}
|
||||
|
||||
$lines += "$i</parameter>"
|
||||
$fragments += ($lines -join "`r`n")
|
||||
|
||||
@@ -1565,10 +1727,10 @@ $corNs = "http://v8.1c.ru/8.1/data-composition-system/core"
|
||||
|
||||
# --- 7. Batch value splitting ---
|
||||
|
||||
if ($Operation -eq "set-query" -or $Operation -eq "set-structure" -or $Operation -eq "add-dataSet") {
|
||||
if ($Operation -eq "set-query" -or $Operation -eq "set-structure" -or $Operation -eq "modify-structure" -or $Operation -eq "add-dataSet") {
|
||||
$values = @($Value)
|
||||
} elseif ($Operation -eq "patch-query") {
|
||||
$values = @($Value -split ';;' | Where-Object { $_.Trim() })
|
||||
$values = @($Value -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
} elseif ($Operation -eq "add-drilldown") {
|
||||
if ($Value.Contains(';;')) {
|
||||
$values = @($Value -split ';;' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
@@ -1766,6 +1928,12 @@ switch ($Operation) {
|
||||
$paramName = $parts[0].Trim()
|
||||
$rest = if ($parts.Count -gt 1) { $parts[1].Trim() } else { "" }
|
||||
|
||||
# Extract @hidden / @always flags
|
||||
$flagHidden = $false
|
||||
$flagAlways = $false
|
||||
if ($rest -match '@hidden\b') { $flagHidden = $true; $rest = ($rest -replace '\s*@hidden\b', '').Trim() }
|
||||
if ($rest -match '@always\b') { $flagAlways = $true; $rest = ($rest -replace '\s*@always\b', '').Trim() }
|
||||
|
||||
# Find parameter element
|
||||
$paramEl = Find-ElementByChildValue $xmlDoc.DocumentElement "parameter" "name" $paramName $schNs
|
||||
if (-not $paramEl) {
|
||||
@@ -1810,15 +1978,63 @@ switch ($Operation) {
|
||||
$avPart = $rest.Substring($avIdx)
|
||||
}
|
||||
|
||||
# Process simple key=value pairs (use, denyIncompleteValues, etc.)
|
||||
# Process simple key=value pairs (use, denyIncompleteValues, value, etc.)
|
||||
if ($simpleRest) {
|
||||
$kvPairs = [regex]::Matches($simpleRest, '(\w+)=(\S+)')
|
||||
foreach ($kv in $kvPairs) {
|
||||
$key = $kv.Groups[1].Value
|
||||
$value = $kv.Groups[2].Value
|
||||
|
||||
$existing = $paramEl.SelectSingleNode($key)
|
||||
if ($existing) {
|
||||
# Namespace-aware lookup (children live in $schNs)
|
||||
$existing = $null
|
||||
foreach ($ch in $paramEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq $key -and $ch.NamespaceURI -eq $schNs) {
|
||||
$existing = $ch; break
|
||||
}
|
||||
}
|
||||
|
||||
if ($key -eq "value") {
|
||||
# Special-case: rebuild <value> with correct xsi:type from <valueType>
|
||||
$declaredType = ""
|
||||
$vtEl = $null
|
||||
foreach ($ch in $paramEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'valueType' -and $ch.NamespaceURI -eq $schNs) { $vtEl = $ch; break }
|
||||
}
|
||||
if ($vtEl) {
|
||||
foreach ($tnode in $vtEl.ChildNodes) {
|
||||
if ($tnode.NodeType -eq 'Element' -and $tnode.LocalName -eq 'Type') {
|
||||
$declaredType = $tnode.InnerText.Trim() -replace '^d\d+p\d+:', ''
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$valueLines = Build-ParamValueXml -type $declaredType -value $value -indent $childIndent
|
||||
$fragXml = $valueLines -join "`r`n"
|
||||
|
||||
$wasExisting = ($null -ne $existing)
|
||||
if ($existing) {
|
||||
# Capture position by next-element sibling, then remove existing
|
||||
$refNode = $existing.NextSibling
|
||||
while ($refNode -and ($refNode.NodeType -eq 'Whitespace' -or $refNode.NodeType -eq 'SignificantWhitespace')) {
|
||||
$refNode = $refNode.NextSibling
|
||||
}
|
||||
Remove-NodeWithWhitespace $existing
|
||||
} else {
|
||||
# Insert before useRestriction/availableValue/denyIncompleteValues/use
|
||||
$refNode = $null
|
||||
foreach ($child in $paramEl.ChildNodes) {
|
||||
if ($child.NodeType -eq 'Element' -and $child.LocalName -in @('useRestriction','availableValue','denyIncompleteValues','use')) {
|
||||
$refNode = $child; break
|
||||
}
|
||||
}
|
||||
}
|
||||
$nodes = Import-Fragment $xmlDoc $fragXml
|
||||
foreach ($node in $nodes) {
|
||||
Insert-BeforeElement $paramEl $node $refNode $childIndent
|
||||
}
|
||||
$verb = if ($wasExisting) { "updated" } else { "added" }
|
||||
Write-Host "[OK] Parameter `"$paramName`": value $verb to $value"
|
||||
} elseif ($existing) {
|
||||
$existing.InnerText = $value
|
||||
Write-Host "[OK] Parameter `"$paramName`": $key updated to $value"
|
||||
} else {
|
||||
@@ -1841,46 +2057,102 @@ switch ($Operation) {
|
||||
}
|
||||
}
|
||||
|
||||
# Process availableValue
|
||||
# Process availableValue — replace whole list with new items
|
||||
if ($avPart) {
|
||||
$avRest = $avPart -replace '^availableValue=', ''
|
||||
# Parse: "Перечисление...X presentation=текст с пробелами"
|
||||
$avParts = $avRest -split '\s+presentation=', 2
|
||||
$avValue = $avParts[0].Trim()
|
||||
$avPresentation = if ($avParts.Count -gt 1) { $avParts[1].Trim() } else { "" }
|
||||
$avRest = ($avPart -replace '^availableValue=', '').Trim()
|
||||
$avItems = Parse-AvailableValueList $avRest
|
||||
|
||||
# Detect value type
|
||||
$avType = "xs:string"
|
||||
if ($avValue -match '^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.') {
|
||||
$avType = "dcscor:DesignTimeValue"
|
||||
# Detect value type: prefer declared <valueType> of the parameter, else guess from value
|
||||
$declaredType = ""
|
||||
$vtEl = $null
|
||||
foreach ($ch in $paramEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'valueType' -and $ch.NamespaceURI -eq $schNs) { $vtEl = $ch; break }
|
||||
}
|
||||
if ($vtEl) {
|
||||
foreach ($tnode in $vtEl.ChildNodes) {
|
||||
if ($tnode.NodeType -eq 'Element' -and $tnode.LocalName -eq 'Type') {
|
||||
$declaredType = $tnode.InnerText.Trim() -replace '^d\d+p\d+:', ''
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$avLines = @()
|
||||
$avLines += "$childIndent<availableValue>"
|
||||
$avLines += "$childIndent`t<value xsi:type=`"$avType`">$(Esc-Xml $avValue)</value>"
|
||||
if ($avPresentation) {
|
||||
$avLines += "$childIndent`t<presentation xsi:type=`"v8:LocalStringType`">"
|
||||
$avLines += "$childIndent`t`t<v8:item>"
|
||||
$avLines += "$childIndent`t`t`t<v8:lang>ru</v8:lang>"
|
||||
$avLines += "$childIndent`t`t`t<v8:content>$(Esc-Xml $avPresentation)</v8:content>"
|
||||
$avLines += "$childIndent`t`t</v8:item>"
|
||||
$avLines += "$childIndent`t</presentation>"
|
||||
# Remove all existing <availableValue> elements
|
||||
$toRemove = @()
|
||||
foreach ($ch in $paramEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'availableValue' -and $ch.NamespaceURI -eq $schNs) {
|
||||
$toRemove += $ch
|
||||
}
|
||||
}
|
||||
$avLines += "$childIndent</availableValue>"
|
||||
$fragXml = $avLines -join "`r`n"
|
||||
foreach ($el in $toRemove) { Remove-NodeWithWhitespace $el }
|
||||
|
||||
# Insert before first of (denyIncompleteValues, use) in document order
|
||||
# Insert each new <availableValue> before (denyIncompleteValues, use)
|
||||
$refNode = $null
|
||||
foreach ($child in $paramEl.ChildNodes) {
|
||||
if ($child.NodeType -eq 'Element' -and ($child.LocalName -eq 'denyIncompleteValues' -or $child.LocalName -eq 'use')) {
|
||||
$refNode = $child; break
|
||||
}
|
||||
}
|
||||
$nodes = Import-Fragment $xmlDoc $fragXml
|
||||
foreach ($node in $nodes) {
|
||||
Insert-BeforeElement $paramEl $node $refNode $childIndent
|
||||
foreach ($av in $avItems) {
|
||||
$avLines = Build-AvailableValueFragment -item $av -declaredType $declaredType -indent $childIndent
|
||||
$fragXml = $avLines -join "`r`n"
|
||||
$nodes = Import-Fragment $xmlDoc $fragXml
|
||||
foreach ($node in $nodes) {
|
||||
Insert-BeforeElement $paramEl $node $refNode $childIndent
|
||||
}
|
||||
}
|
||||
Write-Host "[OK] Parameter `"$paramName`": availableValue added"
|
||||
Write-Host "[OK] Parameter `"$paramName`": availableValue set to $($avItems.Count) item(s)"
|
||||
}
|
||||
|
||||
# Process @hidden / @always flags (idempotent)
|
||||
if ($flagHidden) {
|
||||
# useRestriction → true (insert after <value>, before <expression>/<availableAsField>/...)
|
||||
$urEl = $null
|
||||
foreach ($ch in $paramEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'useRestriction' -and $ch.NamespaceURI -eq $schNs) { $urEl = $ch; break }
|
||||
}
|
||||
if ($urEl) {
|
||||
if ($urEl.InnerText.Trim() -ne 'true') { $urEl.InnerText = 'true' }
|
||||
} else {
|
||||
$refNode = $null
|
||||
foreach ($child in $paramEl.ChildNodes) {
|
||||
if ($child.NodeType -eq 'Element' -and $child.LocalName -in @('expression','availableAsField','availableValue','denyIncompleteValues','use')) { $refNode = $child; break }
|
||||
}
|
||||
$nodes = Import-Fragment $xmlDoc "$childIndent<useRestriction>true</useRestriction>"
|
||||
foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refNode $childIndent }
|
||||
}
|
||||
|
||||
# availableAsField → false (insert after <expression>, before <availableValue>/<denyIncompleteValues>/<use>)
|
||||
$afEl = $null
|
||||
foreach ($ch in $paramEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'availableAsField' -and $ch.NamespaceURI -eq $schNs) { $afEl = $ch; break }
|
||||
}
|
||||
if ($afEl) {
|
||||
if ($afEl.InnerText.Trim() -ne 'false') { $afEl.InnerText = 'false' }
|
||||
} else {
|
||||
$refNode = $null
|
||||
foreach ($child in $paramEl.ChildNodes) {
|
||||
if ($child.NodeType -eq 'Element' -and $child.LocalName -in @('availableValue','denyIncompleteValues','use')) { $refNode = $child; break }
|
||||
}
|
||||
$nodes = Import-Fragment $xmlDoc "$childIndent<availableAsField>false</availableAsField>"
|
||||
foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $refNode $childIndent }
|
||||
}
|
||||
|
||||
Write-Host "[OK] Parameter `"$paramName`": @hidden applied"
|
||||
}
|
||||
|
||||
if ($flagAlways) {
|
||||
$useEl = $null
|
||||
foreach ($ch in $paramEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'use' -and $ch.NamespaceURI -eq $schNs) { $useEl = $ch; break }
|
||||
}
|
||||
if ($useEl) {
|
||||
if ($useEl.InnerText.Trim() -ne 'Always') { $useEl.InnerText = 'Always' }
|
||||
} else {
|
||||
$nodes = Import-Fragment $xmlDoc "$childIndent<use>Always</use>"
|
||||
foreach ($node in $nodes) { Insert-BeforeElement $paramEl $node $null $childIndent }
|
||||
}
|
||||
Write-Host "[OK] Parameter `"$paramName`": @always applied"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2211,6 +2483,12 @@ switch ($Operation) {
|
||||
}
|
||||
|
||||
foreach ($val in $values) {
|
||||
$once = $false
|
||||
if ($val -match '@once\b') {
|
||||
$once = $true
|
||||
$val = ($val -replace '\s*@once\b', '').Trim()
|
||||
}
|
||||
|
||||
$sepIdx = $val.IndexOf(" => ")
|
||||
if ($sepIdx -lt 0) {
|
||||
Write-Error "patch-query value must contain ' => ' separator: old => new"
|
||||
@@ -2219,12 +2497,20 @@ switch ($Operation) {
|
||||
$oldStr = $val.Substring(0, $sepIdx)
|
||||
$newStr = $val.Substring($sepIdx + 4)
|
||||
$queryText = $queryEl.InnerText
|
||||
if (-not $queryText.Contains($oldStr)) {
|
||||
|
||||
$count = ([regex]::Matches($queryText, [regex]::Escape($oldStr))).Count
|
||||
if ($count -eq 0) {
|
||||
Write-Error "Substring not found in query of dataset '$dsName': $oldStr"
|
||||
exit 1
|
||||
}
|
||||
if ($once -and $count -ne 1) {
|
||||
Write-Error "@once: expected 1 occurrence of '$oldStr' in dataset '$dsName', found $count"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$queryEl.InnerText = $queryText.Replace($oldStr, $newStr)
|
||||
Write-Host "[OK] Query patched in dataset `"$dsName`": replaced '$oldStr'"
|
||||
$suffix = if ($once) { " (1 occurrence)" } else { " ($count occurrence(s))" }
|
||||
Write-Host "[OK] Query patched in dataset `"$dsName`": replaced '$oldStr'$suffix"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2289,6 +2575,102 @@ switch ($Operation) {
|
||||
Write-Host "[OK] Structure set in variant `"$varName`": $Value"
|
||||
}
|
||||
|
||||
"modify-structure" {
|
||||
$settings = Resolve-VariantSettings
|
||||
$varName = Get-VariantName
|
||||
|
||||
$structItems = Parse-StructureShorthand $Value
|
||||
|
||||
# Flatten parsed tree into (name, groupBy) targets
|
||||
$targets = @()
|
||||
$stack = New-Object System.Collections.Stack
|
||||
foreach ($it in $structItems) { $stack.Push($it) }
|
||||
while ($stack.Count -gt 0) {
|
||||
$it = $stack.Pop()
|
||||
if ($it["name"]) {
|
||||
$targets += @{ name = $it["name"]; groupBy = $it["groupBy"] }
|
||||
}
|
||||
if ($it["children"]) {
|
||||
foreach ($ch in $it["children"]) { $stack.Push($ch) }
|
||||
}
|
||||
}
|
||||
|
||||
if ($targets.Count -eq 0) {
|
||||
Write-Error "modify-structure requires @name= for at least one group: $Value"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$nsMgr = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
|
||||
$nsMgr.AddNamespace("dcsset", $setNs)
|
||||
$nsMgr.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
|
||||
|
||||
foreach ($t in $targets) {
|
||||
$groupEl = $settings.SelectSingleNode(".//dcsset:item[@xsi:type='dcsset:StructureItemGroup'][dcsset:name='$($t.name)']", $nsMgr)
|
||||
if (-not $groupEl) {
|
||||
Write-Host "[WARN] Group with @name=`"$($t.name)`" not found — skipped"
|
||||
continue
|
||||
}
|
||||
|
||||
$giEl = $null
|
||||
foreach ($ch in $groupEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'groupItems' -and $ch.NamespaceURI -eq $setNs) {
|
||||
$giEl = $ch; break
|
||||
}
|
||||
}
|
||||
$groupIndent = Get-ChildIndent $groupEl
|
||||
if (-not $giEl) {
|
||||
# Create <groupItems> after <name>, before <order>/<selection>/...
|
||||
$nameEl = $null
|
||||
$refAfterName = $null
|
||||
foreach ($ch in $groupEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'name' -and $ch.NamespaceURI -eq $setNs) {
|
||||
$nameEl = $ch
|
||||
} elseif ($ch.NodeType -eq 'Element' -and $nameEl -and -not $refAfterName) {
|
||||
$refAfterName = $ch; break
|
||||
}
|
||||
}
|
||||
$giFrag = "$groupIndent<dcsset:groupItems></dcsset:groupItems>"
|
||||
$nodes = Import-Fragment $xmlDoc $giFrag
|
||||
foreach ($node in $nodes) {
|
||||
Insert-BeforeElement $groupEl $node $refAfterName $groupIndent
|
||||
}
|
||||
# Re-find
|
||||
foreach ($ch in $groupEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'groupItems' -and $ch.NamespaceURI -eq $setNs) {
|
||||
$giEl = $ch; break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$toRemove = @()
|
||||
foreach ($ch in $giEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element') { $toRemove += $ch }
|
||||
}
|
||||
foreach ($el in $toRemove) { Remove-NodeWithWhitespace $el }
|
||||
|
||||
$itemIndent = "$groupIndent`t"
|
||||
|
||||
foreach ($field in $t.groupBy) {
|
||||
$lines = @()
|
||||
$lines += "$itemIndent<dcsset:item xsi:type=`"dcsset:GroupItemField`">"
|
||||
$lines += "$itemIndent`t<dcsset:field>$(Esc-Xml $field)</dcsset:field>"
|
||||
$lines += "$itemIndent`t<dcsset:groupType>Items</dcsset:groupType>"
|
||||
$lines += "$itemIndent`t<dcsset:periodAdditionType>None</dcsset:periodAdditionType>"
|
||||
$lines += "$itemIndent`t<dcsset:periodAdditionBegin xsi:type=`"xs:dateTime`">0001-01-01T00:00:00</dcsset:periodAdditionBegin>"
|
||||
$lines += "$itemIndent`t<dcsset:periodAdditionEnd xsi:type=`"xs:dateTime`">0001-01-01T00:00:00</dcsset:periodAdditionEnd>"
|
||||
$lines += "$itemIndent</dcsset:item>"
|
||||
$fragXml = $lines -join "`r`n"
|
||||
$nodes = Import-Fragment $xmlDoc $fragXml
|
||||
foreach ($node in $nodes) {
|
||||
Insert-BeforeElement $giEl $node $null $itemIndent
|
||||
}
|
||||
}
|
||||
|
||||
$desc = if ($t.groupBy.Count -eq 0) { "details" } else { $t.groupBy -join ', ' }
|
||||
Write-Host "[OK] Group `"$($t.name)`" groupItems updated: $desc"
|
||||
}
|
||||
}
|
||||
|
||||
"add-dataSetLink" {
|
||||
foreach ($val in $values) {
|
||||
$parsed = Parse-DataSetLinkShorthand $val
|
||||
@@ -2476,6 +2858,18 @@ switch ($Operation) {
|
||||
}
|
||||
}
|
||||
|
||||
"clear-conditionalAppearance" {
|
||||
$settings = Resolve-VariantSettings
|
||||
$varName = Get-VariantName
|
||||
$caEl = Find-FirstElement $settings @("conditionalAppearance") $setNs
|
||||
if ($caEl) {
|
||||
Clear-ContainerChildren $caEl
|
||||
Write-Host "[OK] ConditionalAppearance cleared in variant `"$varName`""
|
||||
} else {
|
||||
Write-Host "[INFO] No conditionalAppearance section in variant `"$varName`""
|
||||
}
|
||||
}
|
||||
|
||||
"modify-filter" {
|
||||
$settings = Resolve-VariantSettings
|
||||
$varName = Get-VariantName
|
||||
@@ -2675,6 +3069,87 @@ switch ($Operation) {
|
||||
}
|
||||
}
|
||||
|
||||
"set-field-role" {
|
||||
$dsNode = Resolve-DataSet
|
||||
$dsName = Get-DataSetName $dsNode
|
||||
|
||||
foreach ($val in $values) {
|
||||
# Parse shorthand: "dataPath [@flag ...] [kv=value ...]"
|
||||
$s = $val.Trim()
|
||||
|
||||
# Extract @flags
|
||||
$flags = @()
|
||||
$flagMatches = [regex]::Matches($s, '@(\w+)')
|
||||
foreach ($m in $flagMatches) { $flags += $m.Groups[1].Value }
|
||||
$s = [regex]::Replace($s, '\s*@\w+', '').Trim()
|
||||
|
||||
# Extract kv=value (value is non-whitespace)
|
||||
$kv = [ordered]@{}
|
||||
$kvMatches = [regex]::Matches($s, '(\w+)=(\S+)')
|
||||
foreach ($m in $kvMatches) { $kv[$m.Groups[1].Value] = $m.Groups[2].Value }
|
||||
$s = [regex]::Replace($s, '\s*\w+=\S+', '').Trim()
|
||||
|
||||
$dataPath = $s
|
||||
if (-not $dataPath) {
|
||||
Write-Host "[WARN] set-field-role: empty dataPath in `"$val`""
|
||||
continue
|
||||
}
|
||||
|
||||
$fieldEl = Find-ElementByChildValue $dsNode "field" "dataPath" $dataPath $schNs
|
||||
if (-not $fieldEl) {
|
||||
Write-Host "[WARN] Field `"$dataPath`" not found in dataset `"$dsName`""
|
||||
continue
|
||||
}
|
||||
|
||||
$fieldIndent = Get-ChildIndent $fieldEl
|
||||
|
||||
# Remove existing <role>
|
||||
$oldRole = $null
|
||||
foreach ($ch in $fieldEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -eq 'role' -and $ch.NamespaceURI -eq $schNs) { $oldRole = $ch; break }
|
||||
}
|
||||
if ($oldRole) { Remove-NodeWithWhitespace $oldRole }
|
||||
|
||||
# Empty spec — remove only
|
||||
if ($flags.Count -eq 0 -and $kv.Count -eq 0) {
|
||||
Write-Host "[OK] Field `"$dataPath`" role cleared"
|
||||
continue
|
||||
}
|
||||
|
||||
# Build new <role>
|
||||
$lines = @()
|
||||
$lines += "$fieldIndent<role>"
|
||||
foreach ($flag in $flags) {
|
||||
if ($flag -eq 'period') {
|
||||
$lines += "$fieldIndent`t<dcscom:periodNumber>1</dcscom:periodNumber>"
|
||||
$lines += "$fieldIndent`t<dcscom:periodType>Main</dcscom:periodType>"
|
||||
} else {
|
||||
$lines += "$fieldIndent`t<dcscom:$flag>true</dcscom:$flag>"
|
||||
}
|
||||
}
|
||||
foreach ($k in $kv.Keys) {
|
||||
$lines += "$fieldIndent`t<dcscom:$k>$(Esc-Xml $kv[$k])</dcscom:$k>"
|
||||
}
|
||||
$lines += "$fieldIndent</role>"
|
||||
$fragXml = $lines -join "`r`n"
|
||||
|
||||
# Insert before <valueType>, else before <inputParameters>, else at end
|
||||
$refNode = $null
|
||||
foreach ($ch in $fieldEl.ChildNodes) {
|
||||
if ($ch.NodeType -eq 'Element' -and $ch.LocalName -in @('valueType','inputParameters') -and $ch.NamespaceURI -eq $schNs) { $refNode = $ch; break }
|
||||
}
|
||||
$nodes = Import-Fragment $xmlDoc $fragXml
|
||||
foreach ($node in $nodes) {
|
||||
Insert-BeforeElement $fieldEl $node $refNode $fieldIndent
|
||||
}
|
||||
|
||||
$desc = @()
|
||||
if ($flags.Count -gt 0) { $desc += ($flags | ForEach-Object { "@$_" }) -join ' ' }
|
||||
if ($kv.Count -gt 0) { $desc += ($kv.Keys | ForEach-Object { "$_=$($kv[$_])" }) -join ' ' }
|
||||
Write-Host "[OK] Field `"$dataPath`" role set: $($desc -join ' ')"
|
||||
}
|
||||
}
|
||||
|
||||
"remove-field" {
|
||||
$dsNode = Resolve-DataSet
|
||||
$dsName = Get-DataSetName $dsNode
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# skd-edit v1.11 — Atomic 1C DCS editor (Python port)
|
||||
# skd-edit v1.18 — Atomic 1C DCS editor (Python port)
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
import argparse
|
||||
import os
|
||||
@@ -18,9 +18,9 @@ VALID_OPS = [
|
||||
"add-dataParameter", "add-order", "add-selection", "add-dataSetLink",
|
||||
"add-dataSet", "add-variant", "add-conditionalAppearance", "add-drilldown",
|
||||
"set-query", "patch-query", "set-outputParameter", "set-structure",
|
||||
"modify-field", "modify-filter", "modify-dataParameter", "modify-parameter",
|
||||
"modify-field", "modify-filter", "modify-dataParameter", "modify-parameter", "modify-structure", "set-field-role",
|
||||
"rename-parameter", "reorder-parameters",
|
||||
"clear-selection", "clear-order", "clear-filter",
|
||||
"clear-selection", "clear-order", "clear-filter", "clear-conditionalAppearance",
|
||||
"remove-field", "remove-total", "remove-calculated-field", "remove-parameter", "remove-filter",
|
||||
]
|
||||
|
||||
@@ -250,13 +250,21 @@ def read_field_properties(field_el):
|
||||
|
||||
|
||||
def parse_total_shorthand(s):
|
||||
# "DataPath: Func" or "DataPath: Func(expr)" or "DataPath: ИмяРесурса" (identity)
|
||||
parts = s.split(":", 1)
|
||||
data_path = parts[0].strip()
|
||||
func_part = parts[1].strip()
|
||||
|
||||
agg_funcs = {'Сумма', 'Количество', 'Минимум', 'Максимум', 'Среднее',
|
||||
'Sum', 'Count', 'Min', 'Max', 'Avg',
|
||||
'Minimum', 'Maximum', 'Average'}
|
||||
|
||||
if re.match(r'^\w+\(', func_part):
|
||||
return {"dataPath": data_path, "expression": func_part}
|
||||
else:
|
||||
elif func_part in agg_funcs:
|
||||
return {"dataPath": data_path, "expression": f"{func_part}({data_path})"}
|
||||
else:
|
||||
return {"dataPath": data_path, "expression": func_part}
|
||||
|
||||
|
||||
def parse_calc_shorthand(s):
|
||||
@@ -299,12 +307,26 @@ def parse_calc_shorthand(s):
|
||||
|
||||
|
||||
def parse_param_shorthand(s):
|
||||
result = {"name": "", "type": "", "value": None, "autoDates": False, "title": None}
|
||||
result = {"name": "", "type": "", "value": None, "autoDates": False, "title": None, "hidden": False, "always": False, "availableValues": []}
|
||||
|
||||
# Extract availableValue=... (must be before main parse — captures to end of string)
|
||||
m_av = re.search(r'\s*availableValue=(.+)$', s)
|
||||
if m_av:
|
||||
result["availableValues"] = parse_available_value_list(m_av.group(1).strip())
|
||||
s = re.sub(r'\s*availableValue=.+$', '', s).strip()
|
||||
|
||||
if re.search(r'@autoDates', s):
|
||||
result["autoDates"] = True
|
||||
s = re.sub(r'\s*@autoDates', '', s)
|
||||
|
||||
if re.search(r'@hidden\b', s):
|
||||
result["hidden"] = True
|
||||
s = re.sub(r'\s*@hidden\b', '', s)
|
||||
|
||||
if re.search(r'@always\b', s):
|
||||
result["always"] = True
|
||||
s = re.sub(r'\s*@always\b', '', s)
|
||||
|
||||
# Extract optional [Title] (mirrors parse_field_shorthand)
|
||||
m = re.search(r'\[([^\]]*)\]', s)
|
||||
if m:
|
||||
@@ -545,15 +567,17 @@ def parse_structure_shorthand(s):
|
||||
seg = segments[i].strip()
|
||||
group = {"type": "group"}
|
||||
|
||||
name_m = re.search(r'\s*@name=(.+)', seg)
|
||||
name_m = re.search(r'@name=(?:"([^"]+)"|\'([^\']+)\'|(\S+))', seg)
|
||||
if name_m:
|
||||
group["name"] = name_m.group(1).strip()
|
||||
seg = re.sub(r'\s*@name=.+', '', seg).strip()
|
||||
raw_name = name_m.group(1) or name_m.group(2) or name_m.group(3)
|
||||
group["name"] = raw_name.strip()
|
||||
seg = re.sub(r'\s*@name=(?:"[^"]+"|\'[^\']+\'|\S+)', '', seg).strip()
|
||||
|
||||
if re.match(r'^(details|\u0434\u0435\u0442\u0430\u043b\u0438)$', seg, re.IGNORECASE):
|
||||
group["groupBy"] = []
|
||||
else:
|
||||
group["groupBy"] = [seg]
|
||||
fields = [f.strip() for f in re.split(r'\s*,\s*', seg) if f.strip()]
|
||||
group["groupBy"] = fields
|
||||
|
||||
if innermost is not None:
|
||||
group["children"] = [innermost]
|
||||
@@ -571,6 +595,80 @@ def parse_output_param_shorthand(s):
|
||||
return {"key": s.strip(), "value": ""}
|
||||
|
||||
|
||||
def parse_available_value_list(s):
|
||||
"""Returns list of {value, presentation} from comma-separated list.
|
||||
Items can use single/double quotes (stripped). Quoted spans preserve commas/colons."""
|
||||
if not s:
|
||||
return []
|
||||
|
||||
# Tokenize by ',' respecting quoted spans
|
||||
items = []
|
||||
buf = []
|
||||
in_quote = None
|
||||
for ch in s:
|
||||
if in_quote:
|
||||
buf.append(ch)
|
||||
if ch == in_quote:
|
||||
in_quote = None
|
||||
elif ch in ("'", '"'):
|
||||
in_quote = ch
|
||||
buf.append(ch)
|
||||
elif ch == ',':
|
||||
items.append("".join(buf))
|
||||
buf = []
|
||||
else:
|
||||
buf.append(ch)
|
||||
if buf:
|
||||
items.append("".join(buf))
|
||||
|
||||
def strip_quotes(t):
|
||||
t = t.strip()
|
||||
if len(t) >= 2 and ((t[0] == "'" and t[-1] == "'") or (t[0] == '"' and t[-1] == '"')):
|
||||
return t[1:-1]
|
||||
return t
|
||||
|
||||
result = []
|
||||
for raw in items:
|
||||
item = raw.strip()
|
||||
if not item:
|
||||
continue
|
||||
# Find first ':' outside quotes
|
||||
colon_idx = -1
|
||||
q = None
|
||||
for j, c in enumerate(item):
|
||||
if q:
|
||||
if c == q:
|
||||
q = None
|
||||
elif c in ("'", '"'):
|
||||
q = c
|
||||
elif c == ':':
|
||||
colon_idx = j
|
||||
break
|
||||
if colon_idx >= 0:
|
||||
val_part = item[:colon_idx]
|
||||
pres_part = item[colon_idx + 1:]
|
||||
result.append({"value": strip_quotes(val_part), "presentation": strip_quotes(pres_part)})
|
||||
else:
|
||||
result.append({"value": strip_quotes(item), "presentation": ""})
|
||||
return result
|
||||
|
||||
|
||||
def build_available_value_fragment(item, declared_type, indent):
|
||||
"""Return XML lines for a single <availableValue> block."""
|
||||
lines = [f"{indent}<availableValue>"]
|
||||
for vl in build_param_value_xml(declared_type, item["value"], f"{indent}\t"):
|
||||
lines.append(vl)
|
||||
if item.get("presentation"):
|
||||
lines.append(f'{indent}\t<presentation xsi:type="v8:LocalStringType">')
|
||||
lines.append(f"{indent}\t\t<v8:item>")
|
||||
lines.append(f"{indent}\t\t\t<v8:lang>ru</v8:lang>")
|
||||
lines.append(f"{indent}\t\t\t<v8:content>{esc_xml(item['presentation'])}</v8:content>")
|
||||
lines.append(f"{indent}\t\t</v8:item>")
|
||||
lines.append(f"{indent}\t</presentation>")
|
||||
lines.append(f"{indent}</availableValue>")
|
||||
return lines
|
||||
|
||||
|
||||
# ── 4. Build-* functions (XML fragment generators) ──────────
|
||||
|
||||
def build_value_type_xml(type_str, indent):
|
||||
@@ -724,6 +822,47 @@ def build_calc_field_fragment(parsed, indent):
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
def build_param_value_xml(type_str, value, indent, tag_name="value", tag_ns=""):
|
||||
"""Return list of XML lines for <value xsi:type=...>...</value>."""
|
||||
val_str = "" if value is None else str(value)
|
||||
open_tag = f"{tag_ns}:{tag_name}" if tag_ns else tag_name
|
||||
lines = []
|
||||
|
||||
if type_str == "StandardPeriod":
|
||||
lines.append(f'{indent}<{open_tag} xsi:type="v8:StandardPeriod">')
|
||||
lines.append(f'{indent}\t<v8:variant xsi:type="v8:StandardPeriodVariant">{esc_xml(val_str)}</v8:variant>')
|
||||
lines.append(f"{indent}\t<v8:startDate>0001-01-01T00:00:00</v8:startDate>")
|
||||
lines.append(f"{indent}\t<v8:endDate>0001-01-01T00:00:00</v8:endDate>")
|
||||
lines.append(f"{indent}</{open_tag}>")
|
||||
return lines
|
||||
|
||||
t = type_str or ""
|
||||
xsi = None
|
||||
if t.startswith("date"):
|
||||
xsi = "xs:dateTime"
|
||||
elif t == "boolean":
|
||||
xsi = "xs:boolean"
|
||||
elif t.startswith("decimal"):
|
||||
xsi = "xs:decimal"
|
||||
elif t.startswith("string"):
|
||||
xsi = "xs:string"
|
||||
elif re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.', t):
|
||||
xsi = "dcscor:DesignTimeValue"
|
||||
else:
|
||||
if re.match(r'^\d{4}-\d{2}-\d{2}T', val_str):
|
||||
xsi = "xs:dateTime"
|
||||
elif val_str in ("true", "false"):
|
||||
xsi = "xs:boolean"
|
||||
elif re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.', val_str) or \
|
||||
re.match(r'^(Catalog|Document|Enum|ChartOfAccounts|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.', val_str):
|
||||
xsi = "dcscor:DesignTimeValue"
|
||||
else:
|
||||
xsi = "xs:string"
|
||||
|
||||
lines.append(f'{indent}<{open_tag} xsi:type="{xsi}">{esc_xml(val_str)}</{open_tag}>')
|
||||
return lines
|
||||
|
||||
|
||||
def build_param_fragment(parsed, indent):
|
||||
i = indent
|
||||
fragments = []
|
||||
@@ -739,21 +878,19 @@ def build_param_fragment(parsed, indent):
|
||||
lines.append(f"{i}\t</valueType>")
|
||||
|
||||
if parsed["value"] is not None:
|
||||
val_str = str(parsed["value"])
|
||||
if parsed.get("type") == "StandardPeriod":
|
||||
lines.append(f'{i}\t<value xsi:type="v8:StandardPeriod">')
|
||||
lines.append(f'{i}\t\t<v8:variant xsi:type="v8:StandardPeriodVariant">{esc_xml(val_str)}</v8:variant>')
|
||||
lines.append(f"{i}\t\t<v8:startDate>0001-01-01T00:00:00</v8:startDate>")
|
||||
lines.append(f"{i}\t\t<v8:endDate>0001-01-01T00:00:00</v8:endDate>")
|
||||
lines.append(f"{i}\t</value>")
|
||||
elif parsed.get("type", "").startswith("date"):
|
||||
lines.append(f'{i}\t<value xsi:type="xs:dateTime">{esc_xml(val_str)}</value>')
|
||||
elif parsed.get("type") == "boolean":
|
||||
lines.append(f'{i}\t<value xsi:type="xs:boolean">{esc_xml(val_str)}</value>')
|
||||
elif parsed.get("type", "").startswith("decimal"):
|
||||
lines.append(f'{i}\t<value xsi:type="xs:decimal">{esc_xml(val_str)}</value>')
|
||||
else:
|
||||
lines.append(f'{i}\t<value xsi:type="xs:string">{esc_xml(val_str)}</value>')
|
||||
for vl in build_param_value_xml(parsed.get("type", ""), parsed["value"], f"{i}\t"):
|
||||
lines.append(vl)
|
||||
|
||||
if parsed.get("hidden"):
|
||||
lines.append(f"{i}\t<useRestriction>true</useRestriction>")
|
||||
lines.append(f"{i}\t<availableAsField>false</availableAsField>")
|
||||
|
||||
for av in parsed.get("availableValues", []) or []:
|
||||
for l in build_available_value_fragment(av, parsed.get("type", ""), f"{i}\t"):
|
||||
lines.append(l)
|
||||
|
||||
if parsed.get("always"):
|
||||
lines.append(f"{i}\t<use>Always</use>")
|
||||
|
||||
lines.append(f"{i}</parameter>")
|
||||
fragments.append("\r\n".join(lines))
|
||||
@@ -1361,10 +1498,10 @@ xml_doc = tree.getroot()
|
||||
|
||||
# ── 7. Batch value splitting ────────────────────────────────
|
||||
|
||||
if operation in ("set-query", "set-structure", "add-dataSet"):
|
||||
if operation in ("set-query", "set-structure", "modify-structure", "add-dataSet"):
|
||||
values = [value_arg]
|
||||
elif operation == "patch-query":
|
||||
values = [v for v in value_arg.split(";;") if v.strip()]
|
||||
values = [v.strip() for v in value_arg.split(";;") if v.strip()]
|
||||
elif operation == "add-drilldown":
|
||||
if ";;" in value_arg:
|
||||
values = [v.strip() for v in value_arg.split(";;") if v.strip()]
|
||||
@@ -1537,6 +1674,15 @@ elif operation == "modify-parameter":
|
||||
param_name = parts[0].strip()
|
||||
rest = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
flag_hidden = False
|
||||
flag_always = False
|
||||
if re.search(r'@hidden\b', rest):
|
||||
flag_hidden = True
|
||||
rest = re.sub(r'\s*@hidden\b', '', rest).strip()
|
||||
if re.search(r'@always\b', rest):
|
||||
flag_always = True
|
||||
rest = re.sub(r'\s*@always\b', '', rest).strip()
|
||||
|
||||
param_el = find_element_by_child_value(xml_doc, "parameter", "name", param_name, SCH_NS)
|
||||
if param_el is None:
|
||||
print(f'[WARN] Parameter "{param_name}" not found -- skipped')
|
||||
@@ -1568,8 +1714,33 @@ elif operation == "modify-parameter":
|
||||
if simple_rest:
|
||||
for m in re.finditer(r'(\w+)=(\S+)', simple_rest):
|
||||
key, value = m.group(1), m.group(2)
|
||||
existing = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == key), None)
|
||||
if existing is not None:
|
||||
existing = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == key and etree.QName(ch.tag).namespace == SCH_NS), None)
|
||||
|
||||
if key == "value":
|
||||
# Rebuild <value> with correct xsi:type from <valueType>
|
||||
declared_type = ""
|
||||
vt_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "valueType" and etree.QName(ch.tag).namespace == SCH_NS), None)
|
||||
if vt_el is not None:
|
||||
for tnode in vt_el:
|
||||
if isinstance(tnode.tag, str) and local_name(tnode) == "Type":
|
||||
declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip())
|
||||
break
|
||||
value_lines = build_param_value_xml(declared_type, value, child_indent)
|
||||
frag_xml = "\r\n".join(value_lines)
|
||||
was_existing = existing is not None
|
||||
if existing is not None:
|
||||
# Find next-element sibling as ref before removing
|
||||
idx = list(param_el).index(existing)
|
||||
ref_node = param_el[idx + 1] if idx + 1 < len(param_el) else None
|
||||
remove_node_with_whitespace(existing)
|
||||
else:
|
||||
ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("useRestriction", "availableValue", "denyIncompleteValues", "use")), None)
|
||||
nodes = import_fragment(xml_doc, frag_xml)
|
||||
for node in nodes:
|
||||
insert_before_element(param_el, node, ref_node, child_indent)
|
||||
verb = "updated" if was_existing else "added"
|
||||
print(f'[OK] Parameter "{param_name}": value {verb} to {value}')
|
||||
elif existing is not None:
|
||||
existing.text = value
|
||||
print(f'[OK] Parameter "{param_name}": {key} updated to {value}')
|
||||
else:
|
||||
@@ -1585,38 +1756,71 @@ elif operation == "modify-parameter":
|
||||
|
||||
# Process availableValue
|
||||
if av_part:
|
||||
av_rest = av_part[len("availableValue="):]
|
||||
# Parse: "Перечисление...X presentation=текст с пробелами"
|
||||
av_parts = re.split(r'\s+presentation=', av_rest, 1)
|
||||
av_value = av_parts[0].strip()
|
||||
av_presentation = av_parts[1].strip() if len(av_parts) > 1 else ""
|
||||
av_rest = av_part[len("availableValue="):].strip()
|
||||
av_items = parse_available_value_list(av_rest)
|
||||
|
||||
av_type = "xs:string"
|
||||
if re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.', av_value):
|
||||
av_type = "dcscor:DesignTimeValue"
|
||||
# Prefer declared <valueType> of the parameter; fall back to value pattern
|
||||
declared_type = ""
|
||||
vt_el = None
|
||||
for ch in param_el:
|
||||
if isinstance(ch.tag, str) and local_name(ch) == "valueType" and etree.QName(ch.tag).namespace == SCH_NS:
|
||||
vt_el = ch
|
||||
break
|
||||
if vt_el is not None:
|
||||
for tnode in vt_el:
|
||||
if isinstance(tnode.tag, str) and local_name(tnode) == "Type":
|
||||
declared_type = re.sub(r'^d\d+p\d+:', '', (tnode.text or "").strip())
|
||||
break
|
||||
|
||||
av_lines = [f"{child_indent}<availableValue>"]
|
||||
av_lines.append(f'{child_indent}\t<value xsi:type="{av_type}">{esc_xml(av_value)}</value>')
|
||||
if av_presentation:
|
||||
av_lines.append(f'{child_indent}\t<presentation xsi:type="v8:LocalStringType">')
|
||||
av_lines.append(f"{child_indent}\t\t<v8:item>")
|
||||
av_lines.append(f"{child_indent}\t\t\t<v8:lang>ru</v8:lang>")
|
||||
av_lines.append(f"{child_indent}\t\t\t<v8:content>{esc_xml(av_presentation)}</v8:content>")
|
||||
av_lines.append(f"{child_indent}\t\t</v8:item>")
|
||||
av_lines.append(f"{child_indent}\t</presentation>")
|
||||
av_lines.append(f"{child_indent}</availableValue>")
|
||||
frag_xml = "\r\n".join(av_lines)
|
||||
# Remove all existing <availableValue>
|
||||
to_remove = [ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "availableValue" and etree.QName(ch.tag).namespace == SCH_NS]
|
||||
for el in to_remove:
|
||||
remove_node_with_whitespace(el)
|
||||
|
||||
# Insert before first of (denyIncompleteValues, use) in document order
|
||||
# Insert each new <availableValue> before (denyIncompleteValues, use)
|
||||
ref_node = None
|
||||
for child in param_el:
|
||||
if isinstance(child.tag, str) and local_name(child) in ("denyIncompleteValues", "use"):
|
||||
ref_node = child
|
||||
break
|
||||
nodes = import_fragment(xml_doc, frag_xml)
|
||||
for node in nodes:
|
||||
insert_before_element(param_el, node, ref_node, child_indent)
|
||||
print(f'[OK] Parameter "{param_name}": availableValue added')
|
||||
for av in av_items:
|
||||
av_lines = build_available_value_fragment(av, declared_type, child_indent)
|
||||
frag_xml = "\r\n".join(av_lines)
|
||||
nodes = import_fragment(xml_doc, frag_xml)
|
||||
for node in nodes:
|
||||
insert_before_element(param_el, node, ref_node, child_indent)
|
||||
print(f'[OK] Parameter "{param_name}": availableValue set to {len(av_items)} item(s)')
|
||||
|
||||
if flag_hidden:
|
||||
ur_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "useRestriction" and etree.QName(ch.tag).namespace == SCH_NS), None)
|
||||
if ur_el is not None:
|
||||
if (ur_el.text or "").strip() != "true":
|
||||
ur_el.text = "true"
|
||||
else:
|
||||
ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("expression", "availableAsField", "availableValue", "denyIncompleteValues", "use")), None)
|
||||
for node in import_fragment(xml_doc, f"{child_indent}<useRestriction>true</useRestriction>"):
|
||||
insert_before_element(param_el, node, ref_node, child_indent)
|
||||
|
||||
af_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "availableAsField" and etree.QName(ch.tag).namespace == SCH_NS), None)
|
||||
if af_el is not None:
|
||||
if (af_el.text or "").strip() != "false":
|
||||
af_el.text = "false"
|
||||
else:
|
||||
ref_node = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) in ("availableValue", "denyIncompleteValues", "use")), None)
|
||||
for node in import_fragment(xml_doc, f"{child_indent}<availableAsField>false</availableAsField>"):
|
||||
insert_before_element(param_el, node, ref_node, child_indent)
|
||||
|
||||
print(f'[OK] Parameter "{param_name}": @hidden applied')
|
||||
|
||||
if flag_always:
|
||||
use_el = next((ch for ch in param_el if isinstance(ch.tag, str) and local_name(ch) == "use" and etree.QName(ch.tag).namespace == SCH_NS), None)
|
||||
if use_el is not None:
|
||||
if (use_el.text or "").strip() != "Always":
|
||||
use_el.text = "Always"
|
||||
else:
|
||||
for node in import_fragment(xml_doc, f"{child_indent}<use>Always</use>"):
|
||||
insert_before_element(param_el, node, None, child_indent)
|
||||
print(f'[OK] Parameter "{param_name}": @always applied')
|
||||
|
||||
elif operation == "rename-parameter":
|
||||
root = xml_doc
|
||||
@@ -1868,6 +2072,11 @@ elif operation == "patch-query":
|
||||
print(f"No <query> element found in dataset '{ds_name}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for val in values:
|
||||
once = False
|
||||
if re.search(r'@once\b', val):
|
||||
once = True
|
||||
val = re.sub(r'\s*@once\b', '', val).strip()
|
||||
|
||||
sep_idx = val.find(" => ")
|
||||
if sep_idx < 0:
|
||||
print("patch-query value must contain ' => ' separator: old => new", file=sys.stderr)
|
||||
@@ -1875,11 +2084,18 @@ elif operation == "patch-query":
|
||||
old_str = val[:sep_idx]
|
||||
new_str = val[sep_idx + 4:]
|
||||
query_text = query_el.text or ""
|
||||
if old_str not in query_text:
|
||||
|
||||
count = query_text.count(old_str)
|
||||
if count == 0:
|
||||
print(f"Substring not found in query of dataset '{ds_name}': {old_str}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if once and count != 1:
|
||||
print(f"@once: expected 1 occurrence of '{old_str}' in dataset '{ds_name}', found {count}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
query_el.text = query_text.replace(old_str, new_str)
|
||||
print(f'[OK] Query patched in dataset "{ds_name}": replaced \'{old_str}\'')
|
||||
suffix = " (1 occurrence)" if once else f" ({count} occurrence(s))"
|
||||
print(f'[OK] Query patched in dataset "{ds_name}": replaced \'{old_str}\'{suffix}')
|
||||
|
||||
elif operation == "set-outputParameter":
|
||||
settings = resolve_variant_settings()
|
||||
@@ -1922,6 +2138,85 @@ elif operation == "set-structure":
|
||||
|
||||
print(f'[OK] Structure set in variant "{var_name}": {value_arg}')
|
||||
|
||||
elif operation == "modify-structure":
|
||||
settings = resolve_variant_settings()
|
||||
var_name = get_variant_name()
|
||||
|
||||
struct_items = parse_structure_shorthand(value_arg)
|
||||
|
||||
# Flatten parsed tree into (name, groupBy) targets
|
||||
targets = []
|
||||
stack = list(struct_items)
|
||||
while stack:
|
||||
it = stack.pop()
|
||||
if it.get("name"):
|
||||
targets.append({"name": it["name"], "groupBy": it.get("groupBy", [])})
|
||||
for ch in it.get("children", []) or []:
|
||||
stack.append(ch)
|
||||
|
||||
if not targets:
|
||||
print(f"modify-structure requires @name= for at least one group: {value_arg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
ns = {"dcsset": SET_NS, "xsi": XSI_NS}
|
||||
|
||||
for t in targets:
|
||||
xpath = f".//dcsset:item[@xsi:type='dcsset:StructureItemGroup'][dcsset:name='{t['name']}']"
|
||||
group_el = settings.find(xpath, ns)
|
||||
if group_el is None:
|
||||
print(f'[WARN] Group with @name="{t["name"]}" not found — skipped')
|
||||
continue
|
||||
|
||||
gi_el = None
|
||||
for ch in group_el:
|
||||
if isinstance(ch.tag, str) and local_name(ch) == "groupItems" and etree.QName(ch.tag).namespace == SET_NS:
|
||||
gi_el = ch
|
||||
break
|
||||
|
||||
group_indent = get_child_indent(group_el)
|
||||
if gi_el is None:
|
||||
# Insert <groupItems> after <name>, before first non-name sibling
|
||||
ref_after_name = None
|
||||
saw_name = False
|
||||
for ch in group_el:
|
||||
if not isinstance(ch.tag, str):
|
||||
continue
|
||||
if local_name(ch) == "name" and etree.QName(ch.tag).namespace == SET_NS:
|
||||
saw_name = True
|
||||
elif saw_name:
|
||||
ref_after_name = ch
|
||||
break
|
||||
gi_frag = f"{group_indent}<dcsset:groupItems></dcsset:groupItems>"
|
||||
for node in import_fragment(xml_doc, gi_frag):
|
||||
insert_before_element(group_el, node, ref_after_name, group_indent)
|
||||
for ch in group_el:
|
||||
if isinstance(ch.tag, str) and local_name(ch) == "groupItems" and etree.QName(ch.tag).namespace == SET_NS:
|
||||
gi_el = ch
|
||||
break
|
||||
|
||||
to_remove = [ch for ch in gi_el if isinstance(ch.tag, str)]
|
||||
for el in to_remove:
|
||||
remove_node_with_whitespace(el)
|
||||
|
||||
item_indent = group_indent + "\t"
|
||||
|
||||
for field in t["groupBy"]:
|
||||
lines = [
|
||||
f'{item_indent}<dcsset:item xsi:type="dcsset:GroupItemField">',
|
||||
f'{item_indent}\t<dcsset:field>{esc_xml(field)}</dcsset:field>',
|
||||
f'{item_indent}\t<dcsset:groupType>Items</dcsset:groupType>',
|
||||
f'{item_indent}\t<dcsset:periodAdditionType>None</dcsset:periodAdditionType>',
|
||||
f'{item_indent}\t<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>',
|
||||
f'{item_indent}\t<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>',
|
||||
f'{item_indent}</dcsset:item>',
|
||||
]
|
||||
frag_xml = "\r\n".join(lines)
|
||||
for node in import_fragment(xml_doc, frag_xml):
|
||||
insert_before_element(gi_el, node, None, item_indent)
|
||||
|
||||
desc = "details" if not t["groupBy"] else ", ".join(t["groupBy"])
|
||||
print(f'[OK] Group "{t["name"]}" groupItems updated: {desc}')
|
||||
|
||||
elif operation == "add-dataSetLink":
|
||||
for val in values:
|
||||
parsed = parse_data_set_link_shorthand(val)
|
||||
@@ -2085,6 +2380,16 @@ elif operation == "clear-filter":
|
||||
else:
|
||||
print(f'[INFO] No filter section in variant "{var_name}"')
|
||||
|
||||
elif operation == "clear-conditionalAppearance":
|
||||
settings = resolve_variant_settings()
|
||||
var_name = get_variant_name()
|
||||
ca_el = find_first_element(settings, ["conditionalAppearance"], SET_NS)
|
||||
if ca_el is not None:
|
||||
clear_container_children(ca_el)
|
||||
print(f'[OK] ConditionalAppearance cleared in variant "{var_name}"')
|
||||
else:
|
||||
print(f'[INFO] No conditionalAppearance section in variant "{var_name}"')
|
||||
|
||||
elif operation == "modify-filter":
|
||||
settings = resolve_variant_settings()
|
||||
var_name = get_variant_name()
|
||||
@@ -2237,6 +2542,69 @@ elif operation == "modify-field":
|
||||
|
||||
print(f'[OK] Field "{field_name}" modified in dataset "{ds_name}"')
|
||||
|
||||
elif operation == "set-field-role":
|
||||
ds_node = resolve_data_set()
|
||||
ds_name = get_data_set_name(ds_node)
|
||||
|
||||
for val in values:
|
||||
s = val.strip()
|
||||
|
||||
flags = []
|
||||
for m in re.finditer(r'@(\w+)', s):
|
||||
flags.append(m.group(1))
|
||||
s = re.sub(r'\s*@\w+', '', s).strip()
|
||||
|
||||
kv = []
|
||||
for m in re.finditer(r'(\w+)=(\S+)', s):
|
||||
kv.append((m.group(1), m.group(2)))
|
||||
s = re.sub(r'\s*\w+=\S+', '', s).strip()
|
||||
|
||||
data_path = s
|
||||
if not data_path:
|
||||
print(f'[WARN] set-field-role: empty dataPath in "{val}"')
|
||||
continue
|
||||
|
||||
field_el = find_element_by_child_value(ds_node, "field", "dataPath", data_path, SCH_NS)
|
||||
if field_el is None:
|
||||
print(f'[WARN] Field "{data_path}" not found in dataset "{ds_name}"')
|
||||
continue
|
||||
|
||||
field_indent = get_child_indent(field_el)
|
||||
|
||||
# Remove existing <role>
|
||||
old_role = next((ch for ch in field_el if isinstance(ch.tag, str) and local_name(ch) == "role" and etree.QName(ch.tag).namespace == SCH_NS), None)
|
||||
if old_role is not None:
|
||||
remove_node_with_whitespace(old_role)
|
||||
|
||||
# Empty spec — remove only
|
||||
if not flags and not kv:
|
||||
print(f'[OK] Field "{data_path}" role cleared')
|
||||
continue
|
||||
|
||||
# Build new <role>
|
||||
lines = [f"{field_indent}<role>"]
|
||||
for flag in flags:
|
||||
if flag == "period":
|
||||
lines.append(f"{field_indent}\t<dcscom:periodNumber>1</dcscom:periodNumber>")
|
||||
lines.append(f"{field_indent}\t<dcscom:periodType>Main</dcscom:periodType>")
|
||||
else:
|
||||
lines.append(f"{field_indent}\t<dcscom:{flag}>true</dcscom:{flag}>")
|
||||
for k, v in kv:
|
||||
lines.append(f"{field_indent}\t<dcscom:{k}>{esc_xml(v)}</dcscom:{k}>")
|
||||
lines.append(f"{field_indent}</role>")
|
||||
frag_xml = "\r\n".join(lines)
|
||||
|
||||
ref_node = next((ch for ch in field_el if isinstance(ch.tag, str) and local_name(ch) in ("valueType", "inputParameters") and etree.QName(ch.tag).namespace == SCH_NS), None)
|
||||
for node in import_fragment(xml_doc, frag_xml):
|
||||
insert_before_element(field_el, node, ref_node, field_indent)
|
||||
|
||||
parts = []
|
||||
if flags:
|
||||
parts.append(" ".join(f"@{f}" for f in flags))
|
||||
if kv:
|
||||
parts.append(" ".join(f"{k}={v}" for k, v in kv))
|
||||
print(f'[OK] Field "{data_path}" role set: {" ".join(parts)}')
|
||||
|
||||
elif operation == "remove-field":
|
||||
ds_node = resolve_data_set()
|
||||
ds_name = get_data_set_name(ds_node)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# skd-info v1.3 — Analyze 1C DCS structure
|
||||
# skd-info v1.4 — Analyze 1C DCS structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
@@ -824,8 +824,14 @@ function Show-Fields {
|
||||
$roleParts = @()
|
||||
if ($role) {
|
||||
foreach ($child in $role.ChildNodes) {
|
||||
if ($child.NodeType -eq "Element" -and $child.InnerText -eq "true") {
|
||||
if ($child.NodeType -ne "Element") { continue }
|
||||
$txt = $child.InnerText.Trim()
|
||||
if ($txt -eq "true") {
|
||||
$roleParts += $child.LocalName
|
||||
} elseif ($txt -eq "false") {
|
||||
# skip default-false flags
|
||||
} else {
|
||||
$roleParts += "$($child.LocalName)=$txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# skd-info v1.3 — Analyze 1C DCS structure
|
||||
# skd-info v1.4 — Analyze 1C DCS structure
|
||||
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import argparse
|
||||
@@ -777,8 +777,15 @@ def main():
|
||||
role_parts = []
|
||||
if role is not None:
|
||||
for child in role:
|
||||
if isinstance(child.tag, str) and (child.text or "").strip() == "true":
|
||||
if not isinstance(child.tag, str):
|
||||
continue
|
||||
txt = (child.text or "").strip()
|
||||
if txt == "true":
|
||||
role_parts.append(localname(child))
|
||||
elif txt == "false":
|
||||
pass
|
||||
else:
|
||||
role_parts.append(f"{localname(child)}={txt}")
|
||||
info["role"] = ", ".join(role_parts)
|
||||
|
||||
# UseRestriction
|
||||
|
||||
@@ -529,3 +529,7 @@ On error (auto-screenshot taken):
|
||||
- **Cyrillic in bash** — use `cat <<'SCRIPT' | node $RUN exec -` to avoid escaping issues
|
||||
- **Non-breaking spaces** — 1C uses `\u00a0` instead of regular spaces. All matching is normalized internally
|
||||
- **Section panel display** — `navigateSection()` works with any panel position (side, top) but requires "Picture and text" or "Text" display mode. Icon-only mode is not supported — API cannot read section names from icons alone
|
||||
|
||||
## Regression suites
|
||||
|
||||
When the user asks to cover a 1C solution with automated regression — multi-file test suites with assertions, hooks, tags, retries, Allure/JUnit reports, multi-user process tests — switch to the `test` mode. See [regress.md](regress.md) for authoring discipline, recon flow (metadata + live walkthrough via `exec`), per-application folder layout, ready-to-paste templates, and failure triage. Default to ad-hoc `run`/`exec` for single-script automation — `test` is the specialised mode for project-wide coverage.
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
# Regression suite authoring
|
||||
|
||||
Use this when the user asks to cover a 1C solution with automated regression tests, build out a test suite, or run an existing suite and analyse failures. For ad-hoc single-script automation, stay with the `run`/`exec` modes from SKILL.md instead.
|
||||
|
||||
The runner is the same `run.mjs`. The mode is `test`:
|
||||
|
||||
```bash
|
||||
node $RUN test [url] <dir|file> [flags]
|
||||
```
|
||||
|
||||
Tests live next to the project they cover (not inside the skill). Convention: `tests/` at the project root, with `_hooks.mjs` and `webtest.config.mjs` at the suite root. Tests are ES modules with `*.test.mjs` suffix.
|
||||
|
||||
## When to choose `test` over `exec`
|
||||
|
||||
| Goal | Mode |
|
||||
|------|------|
|
||||
| Explore a form, prototype a single step, debug one selector | `exec` (interactive session) |
|
||||
| **Walk through a scenario live before committing it as a test** | `exec` first, then `test` |
|
||||
| Reproduce a bug as a failing test before fixing it | `test` |
|
||||
| Cover a feature so future changes are checked automatically | `test` |
|
||||
| Run the project's regression on a new build | `test` |
|
||||
| Generate a screencast walkthrough | `exec` with `startRecording` |
|
||||
|
||||
Don't write a `.test.mjs` for a one-shot user request. Don't drive a regression suite through chained `exec` calls.
|
||||
|
||||
## Before writing tests — recon
|
||||
|
||||
Two layers, in order. Don't skip either.
|
||||
|
||||
### 1. Static recon — metadata
|
||||
|
||||
Never invent identifiers. For every metadata object the user mentions (or that you decide to cover), run the matching info skill first:
|
||||
|
||||
| Object type | Skill |
|
||||
|-------------|-------|
|
||||
| Catalog/document/register attributes, tabular sections | `/meta-info` |
|
||||
| Form layout — fields, buttons, tabs, tables | `/form-info` |
|
||||
| DCS report — fields, parameters, filters | `/skd-info` |
|
||||
| Spreadsheet template areas/parameters | `/mxl-info` |
|
||||
| Role rights / restrictions | `/role-info` |
|
||||
| Subsystem composition / command interface | `/subsystem-info` |
|
||||
|
||||
This gives the real Russian field labels, command names, column headers, table-section names. Without it, fuzzy matching will silently land on the wrong element, or fail with no useful diagnostic.
|
||||
|
||||
If the user names objects you cannot find: stop and ask. Do not guess.
|
||||
|
||||
### 2. Live recon — interactive walkthrough
|
||||
|
||||
For any non-trivial scenario, walk the path live in `exec` mode before writing it down. Metadata tells you what exists; the live walkthrough tells you what actually happens — which button posts the document, which dialog 1C raises, how the form looks after `clickElement('Создать')`, what fields are required, where `wait()` is genuinely needed.
|
||||
|
||||
```bash
|
||||
# Start a session (background).
|
||||
node $RUN start http://localhost:9191/myapp/ru_RU
|
||||
|
||||
# Step the scenario interactively. After each step, inspect.
|
||||
cat <<'EOF' | node $RUN exec -
|
||||
await navigateSection('Склад');
|
||||
const cmds = await getCommands();
|
||||
console.log(cmds);
|
||||
EOF
|
||||
|
||||
cat <<'EOF' | node $RUN exec -
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
const s = await getFormState();
|
||||
console.log(JSON.stringify(s.fields.map(f => ({ name: f.name, label: f.label, required: f.required })), null, 2));
|
||||
console.log('buttons:', s.buttons.map(b => b.name));
|
||||
console.log('tables:', s.tables.map(t => ({ name: t.name, label: t.label, columns: t.columns })));
|
||||
EOF
|
||||
|
||||
# Try the actions you plan to encode. If a step fails, fix and re-try
|
||||
# before transcribing it.
|
||||
cat <<'EOF' | node $RUN exec -
|
||||
await fillFields({ 'Контрагент': 'ООО Север' });
|
||||
await fillTableRow({ 'Номенклатура': 'Товар 01', 'Количество': '5' },
|
||||
{ table: 'Товары', add: true });
|
||||
await clickElement('Провести и закрыть');
|
||||
console.log(JSON.stringify(await getFormState()));
|
||||
EOF
|
||||
|
||||
# When done, stop the session (or leave it for the next test you write).
|
||||
node $RUN stop
|
||||
```
|
||||
|
||||
What to record from the walkthrough into the test:
|
||||
- Exact button names (`'Провести и закрыть'`, not `'Сохранить'`).
|
||||
- Field labels as 1C renders them (with possible non-breaking spaces — `fillFields` normalises, but be exact).
|
||||
- Table section names from `getFormState().tables[].name`/`label` for multi-grid forms.
|
||||
- Required `wait()` durations — only where a real async event happens (report generation, server-side calculation). Default actions await internally.
|
||||
- The shape of `getFormState()` after each action — gives you the right `assert.equal(...)` paths.
|
||||
|
||||
After this, transcribe the working sequence into `*.test.mjs`, wrap each chunk in `step('...', async () => { ... })`, add assertions for the invariants you saw. Run the file once with `node $RUN test path/to/file.test.mjs` to confirm.
|
||||
|
||||
When live recon is overkill: trivial reads (`navigateSection` + `readTable` + assert non-empty), or scenarios you've already proven once in this session. When it's essential: anything with confirmation dialogs, posting/cancellation flows, reports with custom filters, multi-grid forms, or user-customised forms you've never seen.
|
||||
|
||||
## Suite layout
|
||||
|
||||
**Each application gets its own subfolder under `tests/`.** A single repo may host several independent suites side by side — they must not share `_hooks.mjs` or `webtest.config.mjs`, because each suite restores a different DB, publishes to a different URL, and ships its own test data.
|
||||
|
||||
```
|
||||
tests/
|
||||
web-test/ # engine self-tests (reserved if our repo layout)
|
||||
<app-name>/ # application regression — one per solution
|
||||
_hooks.mjs
|
||||
webtest.config.mjs
|
||||
01-login/
|
||||
02-counterparties/
|
||||
...
|
||||
<another-app>/ # second solution, fully isolated
|
||||
_hooks.mjs
|
||||
...
|
||||
```
|
||||
|
||||
`<app-name>` is the project/extension slug (`acc-payroll`, `erp-customisation`, etc.). Pick something stable and pass it on the CLI:
|
||||
|
||||
```bash
|
||||
node $RUN test tests/<app-name>/
|
||||
```
|
||||
|
||||
Inside the application subfolder, organize by **feature**, not by metadata kind. Numeric prefixes on both folder and file enforce run order (discovery is alphabetic by full path).
|
||||
|
||||
```
|
||||
tests/<app-name>/
|
||||
_hooks.mjs # stand prep + cross-cutting hooks (optional)
|
||||
webtest.config.mjs # url, contexts, defaults (optional)
|
||||
01-login/
|
||||
01-open-base.test.mjs
|
||||
02-section-navigation.test.mjs
|
||||
02-counterparties/
|
||||
01-create.test.mjs
|
||||
02-edit-phone.test.mjs
|
||||
03-goods-receipt/
|
||||
01-fill.test.mjs
|
||||
02-post.test.mjs
|
||||
03-unpost.test.mjs
|
||||
04-balance-report/
|
||||
01-generate.test.mjs
|
||||
02-warehouse-filter.test.mjs
|
||||
05-approval-process/
|
||||
01-end-to-end.test.mjs # multi-user
|
||||
```
|
||||
|
||||
Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported. Only the application-root copies are loaded.
|
||||
|
||||
## Test file anatomy
|
||||
|
||||
```js
|
||||
export const name = 'Создание контрагента'; // required
|
||||
export const tags = ['catalog', 'create']; // optional, used for filtering + Allure
|
||||
export const timeout = 60000; // optional, default 30000
|
||||
// export const skip = 'pending fix #123'; // optional: true | string
|
||||
// export const only = true; // debug-only — never commit
|
||||
// export const context = 'manager'; // optional, single non-default context
|
||||
// export const contexts = ['clerk', 'manager']; // optional, multi-user test
|
||||
// export const severity = 'critical'; // optional, overrides config severity
|
||||
|
||||
export async function setup(ctx) {
|
||||
// per-test prep — runs before default. Skip if not needed.
|
||||
}
|
||||
|
||||
export async function teardown(ctx) {
|
||||
// per-test cleanup — runs after default, always (even on failure).
|
||||
}
|
||||
|
||||
export default async function(ctx) {
|
||||
const { navigateSection, openCommand, clickElement, fillFields,
|
||||
readTable, closeForm, getFormState,
|
||||
assert, step, log } = ctx;
|
||||
|
||||
await step('Открыть список контрагентов', async () => {
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Контрагенты');
|
||||
});
|
||||
|
||||
await step('Создать нового контрагента', async () => {
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Наименование': 'Тест ' + Date.now() });
|
||||
await clickElement('Записать и закрыть');
|
||||
});
|
||||
|
||||
await step('Убедиться, что элемент появился в списке', async () => {
|
||||
const t = await readTable();
|
||||
assert.tableHasRow(t, r => r['Наименование']?.startsWith('Тест '));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The runner injects every `browser.mjs` export into `ctx` plus `assert`, `step`, `log`, `testInfo`, `testResult` (afterEach only). For multi-context tests, each context name is its own scoped namespace (`ctx.clerk.clickElement(...)` etc.) — `step`/`assert` stay top-level.
|
||||
|
||||
**Step names — in Russian, descriptive.** Step labels surface in the console output, in JSON/JUnit, and as Allure step nodes. Russian-speaking QA reads them. Use a full action phrase (`'Создать нового контрагента'`, `'Проверить наличие документа в списке'`), not a tag (`'create'`, `'verify'`) and not a transliteration. Same applies to `export const name` and `displayName` in `webtest.config.mjs`.
|
||||
|
||||
## webtest.config.mjs
|
||||
|
||||
```js
|
||||
export default {
|
||||
// Single-context: just url.
|
||||
url: 'http://localhost:9191/myapp/ru_RU',
|
||||
|
||||
// OR multi-context: named contexts. Each test picks via `context`/`contexts` exports.
|
||||
// contexts: {
|
||||
// clerk: { url: 'http://localhost:9191/myapp-clerk/ru_RU', displayName: 'Кладовщик' },
|
||||
// manager: { url: 'http://localhost:9191/myapp-manager/ru_RU', displayName: 'Менеджер' },
|
||||
// },
|
||||
// defaultContext: 'clerk',
|
||||
|
||||
timeout: 30000,
|
||||
retries: 0,
|
||||
screenshot: 'on-failure',
|
||||
record: false,
|
||||
|
||||
// Severity → tags mapping for Allure. Each tag at most one bucket.
|
||||
severity: {
|
||||
critical: ['smoke', 'crud'],
|
||||
minor: ['recording'],
|
||||
},
|
||||
defaultSeverity: 'normal',
|
||||
};
|
||||
```
|
||||
|
||||
CLI flags override config. Recommend latin context IDs + Russian `displayName` for video badges.
|
||||
|
||||
## _hooks.mjs
|
||||
|
||||
Two layers. Infra hooks run without a browser; testlevel hooks receive `ctx`.
|
||||
|
||||
```js
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// Infra — runs once around the whole suite.
|
||||
export async function prepare({ hookArgs, log, config }) {
|
||||
// Restore DB, publish to Apache, build EPF, etc.
|
||||
// hookArgs = everything after `--` on the CLI. Parse yourself.
|
||||
if (hookArgs.includes('--rebuild-stand')) { /* full rebuild */ }
|
||||
// Use idempotent hash-locks to skip work on warm starts.
|
||||
}
|
||||
|
||||
export async function cleanup({ log, config }) {
|
||||
// Tear down or leave the stand running. Choose per project.
|
||||
}
|
||||
|
||||
// Testlevel — runs with browser ctx.
|
||||
export async function beforeAll(ctx) { /* once after first context opens */ }
|
||||
export async function afterAll(ctx) { /* once before final teardown */ }
|
||||
export async function beforeEach(ctx) { /* ctx.testInfo is set */ }
|
||||
export async function afterEach(ctx) { /* ctx.testResult is set */ }
|
||||
|
||||
// Per-context — runs whenever a context is created/closed.
|
||||
export async function afterOpenContext(ctx, name, spec) { /* spec = config.contexts[name] */ }
|
||||
export async function beforeCloseContext(ctx, name, spec) { }
|
||||
```
|
||||
|
||||
Built-in state reset (`dismissPendingErrors` + close all forms) runs after `afterEach` automatically. Don't reimplement it.
|
||||
|
||||
**Where to put data setup:**
|
||||
- DB restore, publication, EPF build → `prepare()`. Make it idempotent (hash-locks on inputs — config sources, EPF spec, DB dump) so warm starts skip everything but a liveness probe.
|
||||
- Test-specific seed data (the document this test will edit, the counterparty it expects) → per-test `setup`.
|
||||
- Shared session-wide warmup → `beforeAll`.
|
||||
|
||||
## Ready-to-paste patterns
|
||||
|
||||
### Catalog full cycle
|
||||
|
||||
```js
|
||||
await step('Создать контрагента', async () => {
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Наименование': 'ТД Тест', 'ИНН': '7707083893' });
|
||||
await clickElement('Записать и закрыть');
|
||||
});
|
||||
await step('Проверить наличие в списке', async () => {
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
assert.tableHasRow(t, { 'Наименование': 'ТД Тест' });
|
||||
});
|
||||
await step('Удалить контрагента и подтвердить удаление', async () => {
|
||||
await clickElement('ТД Тест');
|
||||
const page = await getPage();
|
||||
await page.keyboard.press('Delete');
|
||||
await clickElement('Да');
|
||||
});
|
||||
```
|
||||
|
||||
### Document create + post
|
||||
|
||||
```js
|
||||
const marker = 'Тест-' + Date.now();
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Контрагент': 'ООО Север', 'Комментарий': marker });
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' },
|
||||
{ table: 'Товары', add: true }
|
||||
);
|
||||
await clickElement('Провести и закрыть');
|
||||
// Verify: re-open list, filter or scan, assert by `marker`.
|
||||
```
|
||||
|
||||
Use a unique marker (`Date.now()` or random suffix) so re-runs don't collide. Identify your own row by it, not by position or natural keys that may already exist in the DB.
|
||||
|
||||
### DCS report
|
||||
|
||||
```js
|
||||
await openCommand('Остатки товаров');
|
||||
// Reset user settings — 1C persists them between sessions.
|
||||
await clickElement('Ещё');
|
||||
await clickElement('Установить стандартные настройки');
|
||||
|
||||
await selectValue('Номенклатура', 'Товар 02'); // auto-enables the filter checkbox
|
||||
await clickElement('Сформировать');
|
||||
await wait(3);
|
||||
const r = await readSpreadsheet();
|
||||
assert.deepEqual(r.headers, ['Номенклатура', 'Количество', 'Сумма']);
|
||||
assert.ok(r.data.length >= 1);
|
||||
assert.ok(r.totals?.['Сумма']);
|
||||
```
|
||||
|
||||
### Multi-user process
|
||||
|
||||
```js
|
||||
export const contexts = ['clerk', 'manager'];
|
||||
|
||||
export default async function({ clerk, manager, step, assert }) {
|
||||
await step('Кладовщик создаёт накладную', async () => {
|
||||
await clerk.navigateSection('Склад');
|
||||
await clerk.openCommand('Приходные накладные');
|
||||
await clerk.clickElement('Создать');
|
||||
await clerk.fillFields({ 'Контрагент': 'ООО Север' });
|
||||
await clerk.clickElement('Записать');
|
||||
});
|
||||
await step('Менеджер утверждает накладную', async () => {
|
||||
await manager.navigateSection('Согласование');
|
||||
await manager.openCommand('На утверждении');
|
||||
await manager.clickElement('ООО Север', { dblclick: true });
|
||||
await manager.clickElement('Утвердить');
|
||||
});
|
||||
await step('Кладовщик видит новый статус', async () => {
|
||||
const s = await clerk.getFormState();
|
||||
assert.equal(s.fields.find(f => f.name === 'Статус')?.value, 'Утверждён');
|
||||
});
|
||||
await step('Освободить сессию кладовщика', async () => {
|
||||
await manager.closeContext('clerk'); // free a 1C license for the next test
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
License caveat: stock 1C allows ~2 web sessions concurrently. Close contexts you no longer need before the next multi-user test starts.
|
||||
|
||||
### Failing-test repro
|
||||
|
||||
```js
|
||||
export const name = 'Bug #123: накладная без контрагента не должна проводиться';
|
||||
export const tags = ['bug', 'validation'];
|
||||
|
||||
export default async function({ openCommand, clickElement, getFormState, assert, step }) {
|
||||
await openCommand('Приходные накладные');
|
||||
await clickElement('Создать');
|
||||
await clickElement('Провести');
|
||||
const s = await getFormState();
|
||||
assert.ok(s.errorModal || s.fields.find(f => f.name === 'Контрагент')?.required,
|
||||
'Должна быть ошибка валидации или поле помечено обязательным');
|
||||
}
|
||||
```
|
||||
|
||||
Write it red first, hand it to the user, fix the underlying issue, re-run green.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
node $RUN test tests/<app-name>/ # full app suite
|
||||
node $RUN test tests/<app-name>/03-goods-receipt/ # one feature folder
|
||||
node $RUN test tests/<app-name>/02-counterparties/01-create.test.mjs # one file
|
||||
node $RUN test tests/<app-name>/ --tags=smoke # by tag (intersection)
|
||||
node $RUN test tests/<app-name>/ --grep='накладн' # by name regex
|
||||
node $RUN test tests/<app-name>/ --bail --retry=1 # stop on first fail, allow 1 retry
|
||||
node $RUN test tests/<app-name>/ --report=allure-results --format=allure --report-dir=allure-results
|
||||
node $RUN test tests/<app-name>/ -- --rebuild-stand # everything after `--` goes to hooks
|
||||
```
|
||||
|
||||
Default report is JSON when `--report=…` is given. Allure needs `--format=allure` + a directory. JUnit similarly with `--format=junit`.
|
||||
|
||||
### Allure static config — `_allure/` directory
|
||||
|
||||
The runner copies `<testDir>/_allure/` into the report directory before generating Allure output. Standard Allure convention applies — three files are typically used:
|
||||
|
||||
- **`categories.json`** — failure classification. Always emit this when setting up a suite, with 1C-specific patterns: license pool exhaustion (`Не обнаружено свободной лицензии`), 1C application errors (`ВызватьИсключение|Произошла ошибка|…`), navigation/element lookup misses, runner timeouts, assertion failures.
|
||||
- **`environment.properties`** — `key=value` lines for the Environment widget. Useful when the suite runs across builds/branches (URL, 1C platform version, git branch, configuration version). Often emitted dynamically by `prepare()` rather than committed as a static file.
|
||||
- **`executor.json`** — CI metadata (Jenkins URL, GitHub run ID, etc.). Only relevant when the suite runs on a CI server; for local runs, skip it.
|
||||
|
||||
Discovery skips the underscored directory, so it never collides with tests.
|
||||
|
||||
## Severity guidance
|
||||
|
||||
When the user doesn't dictate, default to:
|
||||
|
||||
| Test kind | Severity |
|
||||
|-----------|----------|
|
||||
| Login + section navigation, basic CRUD on covered entities | `critical` (also tag `smoke`) |
|
||||
| Documents posting, report generation, end-to-end processes | `critical` |
|
||||
| Field-level edge cases, formatting, optional flows | `normal` |
|
||||
| Cosmetic / recording / non-functional | `minor` |
|
||||
| Reserved for show-stopper protections | `blocker` (use sparingly) |
|
||||
|
||||
Don't promote everything to `critical` — it loses signal in the Allure dashboard.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- **Sleeps as a substitute for assertions.** `wait(5)` after `openCommand` is fine; `wait(30)` because something flakes is a bug — find what state you can wait on with `getFormState` instead.
|
||||
- **Retry as a substitute for understanding.** "Not found" twice means the data isn't there or the label is wrong. Don't loop.
|
||||
- **Raw DOM via `getPage().$$(...)`.** Use `getFormState`, `readTable`, `readSpreadsheet`. Raw selectors break across 1C platform versions.
|
||||
- **`clickElement('×')` or `clickElement('Закрыть')`** to dismiss a form. Use `closeForm({ save: true|false })` — handles confirmation correctly.
|
||||
- **Position-based row identification** (`rows[0]`) when the DB has shared seed data. Filter by unique marker or label instead.
|
||||
- **Skipping recon** because "I know what this catalog looks like." You don't — the project's customisation almost certainly differs from a stock config.
|
||||
- **`tags: ['smoke']` on a 90-second test.** Smoke means fast.
|
||||
- **Hand-writing reset code** in `afterEach`. The runner already closes forms and dismisses errors.
|
||||
- **Cross-test state assumptions.** Each test must start from desktop and seed its own data. Order-of-execution coupling is a regression-suite trap.
|
||||
|
||||
## After a run — failure triage
|
||||
|
||||
1. Scan the JSON or Allure summary for `failed`.
|
||||
2. For each failure, read `error.message` + `error.step` + screenshot (saved next to the report).
|
||||
3. If `error.onecError.stack` is present — it's a 1C exception, look at the platform trace.
|
||||
4. Classify:
|
||||
- **Test bug** — selector wrong, expectation wrong, race with no anchor → fix the test.
|
||||
- **Application bug** — actual misbehaviour reproduced → report to the user with the failing step name and the platform stack.
|
||||
- **Stand flake** — Apache timeout, login form not loading, license shortage → fix the hook idempotency or session-cleanup logic, not the test.
|
||||
5. After fixes, re-run only the affected files (`node $RUN test tests/03-goods-receipt/`) before the full suite.
|
||||
|
||||
Report back to the user with the classification, not raw failure dumps.
|
||||
|
||||
## Reference
|
||||
|
||||
- Browser API: [SKILL.md](SKILL.md)
|
||||
- Video and narration: [recording.md](recording.md)
|
||||
@@ -1,4 +1,4 @@
|
||||
// web-test browser v1.9 — Playwright browser management for 1C web client
|
||||
// web-test browser v1.12 — Playwright browser management for 1C web client
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
/**
|
||||
* Playwright browser management for 1C web client.
|
||||
@@ -37,6 +37,16 @@ let lastCaptions = []; // captions from the last completed recording (for addNar
|
||||
let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
|
||||
let highlightMode = false;
|
||||
|
||||
// Multi-context registry: name → { context, page, sessionPrefix, seanceId, recorder, lastCaptions, lastRecordingDuration, highlightMode }
|
||||
// Populated by createContext(); module-level vars above mirror the active slot.
|
||||
// connect() does NOT use this Map — it preserves legacy single-session behavior for exec/run/start.
|
||||
const contexts = new Map();
|
||||
let activeContextName = null;
|
||||
// Isolation mode for the current cmdTest session — set by the first createContext call.
|
||||
// 'tab': all contexts share one persistent context (one window, multiple tabs, extension loads reliably).
|
||||
// 'window': each context gets its own BrowserContext (separate window per context, full cookie isolation, extension may not load).
|
||||
let activeMode = null;
|
||||
|
||||
const LOAD_TIMEOUT = 60000;
|
||||
const INIT_TIMEOUT = 60000;
|
||||
const ACTION_WAIT = 2000; // fallback minimum wait
|
||||
@@ -158,31 +168,51 @@ export async function connect(url, { extensionPath } = {}) {
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort POST /e1cib/logout on a slot to release the 1C session license.
|
||||
* Silent — if page is closed or session info missing, just returns.
|
||||
* @param {object} slot { page, sessionPrefix, seanceId } from contexts Map
|
||||
* @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process)
|
||||
*/
|
||||
async function _logoutSlot(slot, waitMs = 500) {
|
||||
if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return;
|
||||
try {
|
||||
const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`;
|
||||
await slot.page.evaluate(async (url) => {
|
||||
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' });
|
||||
}, logoutUrl);
|
||||
await slot.page.waitForTimeout(waitMs);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully terminate the 1C session and close the browser.
|
||||
* Sends POST /e1cib/logout to release the license before closing.
|
||||
*/
|
||||
export async function disconnect() {
|
||||
// Auto-stop recording if active (prevents orphaned ffmpeg)
|
||||
// Multi-context path: stop recording + logout each slot before closing browser
|
||||
if (contexts.size > 0) {
|
||||
_saveActiveSlot();
|
||||
// Recorder is global — one stop covers all contexts
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
}
|
||||
for (const [, slot] of contexts.entries()) {
|
||||
await _logoutSlot(slot);
|
||||
}
|
||||
contexts.clear();
|
||||
activeContextName = null;
|
||||
activeMode = null;
|
||||
}
|
||||
|
||||
// Single-session path (connect): auto-stop recording if active
|
||||
if (recorder) {
|
||||
try { await stopRecording(); } catch {}
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
// Graceful logout — release the 1C license
|
||||
if (page && !page.isClosed() && seanceId && sessionPrefix) {
|
||||
try {
|
||||
const logoutUrl = `${sessionPrefix}/e1cib/logout?seanceId=${seanceId}`;
|
||||
await page.evaluate(async (url) => {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"root":{}}'
|
||||
});
|
||||
}, logoutUrl);
|
||||
await page.waitForTimeout(1000);
|
||||
} catch {}
|
||||
}
|
||||
// Graceful logout — release the 1C license (single-session connect path)
|
||||
await _logoutSlot({ page, sessionPrefix, seanceId }, 1000);
|
||||
await browser.close().catch(() => {});
|
||||
browser = null;
|
||||
page = null;
|
||||
@@ -228,6 +258,203 @@ export function getSession() {
|
||||
return { sessionPrefix, seanceId };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Multi-context support (used by run.mjs cmdTest only)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Save current module-level state into the active slot before switching.
|
||||
* No-op if no active slot.
|
||||
*/
|
||||
function _saveActiveSlot() {
|
||||
if (!activeContextName) return;
|
||||
const slot = contexts.get(activeContextName);
|
||||
if (!slot) return;
|
||||
slot.page = page;
|
||||
slot.sessionPrefix = sessionPrefix;
|
||||
slot.seanceId = seanceId;
|
||||
slot.highlightMode = highlightMode;
|
||||
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
|
||||
// mirrored per-slot. A multi-context recording produces one continuous output file —
|
||||
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
|
||||
}
|
||||
|
||||
/** Load a slot's state into module-level vars and mark it active. */
|
||||
function _activateSlot(name) {
|
||||
const slot = contexts.get(name);
|
||||
if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`);
|
||||
page = slot.page;
|
||||
sessionPrefix = slot.sessionPrefix;
|
||||
seanceId = slot.seanceId;
|
||||
highlightMode = slot.highlightMode || false;
|
||||
activeContextName = name;
|
||||
}
|
||||
|
||||
/** Attach 1C session listeners to a page, writing into the given slot. */
|
||||
function _attachSessionListeners(pg, slot, name) {
|
||||
pg.on('dialog', dialog => dialog.accept().catch(() => {}));
|
||||
pg.on('request', req => {
|
||||
if (slot.seanceId) return;
|
||||
const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/);
|
||||
if (m) {
|
||||
slot.sessionPrefix = m[1];
|
||||
slot.seanceId = m[2];
|
||||
if (activeContextName === name) {
|
||||
sessionPrefix = m[1];
|
||||
seanceId = m[2];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or navigate) a named browser context.
|
||||
* First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that
|
||||
* subsequent calls can create additional isolated BrowserContexts in the same process.
|
||||
* Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than
|
||||
* persistent profile.
|
||||
*
|
||||
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
||||
* legacy persistent-context path.
|
||||
*/
|
||||
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
||||
if (contexts.has(name)) {
|
||||
await setActiveContext(name);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
if (!['tab', 'window'].includes(isolation)) {
|
||||
throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`);
|
||||
}
|
||||
if (activeMode && activeMode !== isolation) {
|
||||
throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`);
|
||||
}
|
||||
|
||||
// First context: launch browser. Subsequent: reuse existing.
|
||||
let isFirstContext = !browser;
|
||||
if (isFirstContext) {
|
||||
const extPath = findExtension(extensionPath);
|
||||
const launchArgs = ['--start-maximized'];
|
||||
if (extPath) {
|
||||
launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath);
|
||||
}
|
||||
if (isolation === 'tab') {
|
||||
// Persistent context: extension loads reliably, one window with tabs per context
|
||||
persistentUserDataDir = pathJoin(tmpdir(), 'pw-1c-test-' + Date.now());
|
||||
mkdirSync(persistentUserDataDir, { recursive: true });
|
||||
browser = await chromium.launchPersistentContext(persistentUserDataDir, {
|
||||
headless: false,
|
||||
args: launchArgs,
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
} else {
|
||||
// Window mode: separate BrowserContext per slot, full cookie isolation
|
||||
browser = await chromium.launch({ headless: false, args: launchArgs });
|
||||
}
|
||||
activeMode = isolation;
|
||||
}
|
||||
|
||||
// Save current active before switching
|
||||
_saveActiveSlot();
|
||||
|
||||
// Create slot — page differs by mode
|
||||
let newCtx, newPage;
|
||||
if (activeMode === 'tab') {
|
||||
// Reuse the persistent context for all slots; each slot gets its own page (tab)
|
||||
newCtx = browser;
|
||||
if (isFirstContext) {
|
||||
newPage = browser.pages()[0] || await browser.newPage();
|
||||
} else {
|
||||
newPage = await browser.newPage();
|
||||
}
|
||||
} else {
|
||||
// Window mode: each slot owns its BrowserContext + page
|
||||
newCtx = await browser.newContext({
|
||||
viewport: null,
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
});
|
||||
newPage = await newCtx.newPage();
|
||||
}
|
||||
|
||||
const slot = {
|
||||
context: newCtx,
|
||||
page: newPage,
|
||||
sessionPrefix: null,
|
||||
seanceId: null,
|
||||
highlightMode: false,
|
||||
};
|
||||
contexts.set(name, slot);
|
||||
|
||||
_attachSessionListeners(newPage, slot, name);
|
||||
_activateSlot(name);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
||||
export async function setActiveContext(name) {
|
||||
if (activeContextName === name) return;
|
||||
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
// If a recording is active, flush the outgoing page's last frame so the gap is filled
|
||||
// up to the moment of the switch (avoids a "jump" in video time).
|
||||
if (recorder && recorder._flushFrames) recorder._flushFrames();
|
||||
_saveActiveSlot();
|
||||
_activateSlot(name);
|
||||
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
|
||||
// re-attach its screencast to the newly active page.
|
||||
if (recorder && recorder._attachPage) {
|
||||
await recorder._attachPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
export function listContexts() {
|
||||
return [...contexts.keys()];
|
||||
}
|
||||
|
||||
export function getActiveContext() {
|
||||
return activeContextName;
|
||||
}
|
||||
|
||||
export function hasContext(name) {
|
||||
return contexts.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a named context: logout, close its page (tab mode) or BrowserContext
|
||||
* (window mode), remove from registry. Cannot close the currently active
|
||||
* context — caller must setActiveContext to another first. This keeps the
|
||||
* recorder/page invariants simple: recorder is always attached to the
|
||||
* active slot, which closeContext never touches.
|
||||
*
|
||||
* @throws if name is not registered or equals the active context.
|
||||
*/
|
||||
export async function closeContext(name) {
|
||||
if (!contexts.has(name)) {
|
||||
throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||
}
|
||||
if (name === activeContextName) {
|
||||
throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`);
|
||||
}
|
||||
const slot = contexts.get(name);
|
||||
await _logoutSlot(slot);
|
||||
if (activeMode === 'tab') {
|
||||
try { await slot.page.close(); } catch {}
|
||||
} else {
|
||||
try { await slot.context.close(); } catch {}
|
||||
}
|
||||
contexts.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close startup modals and guide tabs.
|
||||
* Strategy: Escape → click default buttons → close extra tabs → repeat.
|
||||
@@ -4861,10 +5088,7 @@ export async function startRecording(outputPath, opts = {}) {
|
||||
const resolvedPath = resolveProjectPath(outputPath);
|
||||
mkdirSync(dirname(resolvedPath), { recursive: true });
|
||||
|
||||
// Create CDP session for screencast
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
|
||||
// Spawn ffmpeg process
|
||||
// Spawn ffmpeg process — single output file across context switches
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
'-y', // overwrite output
|
||||
'-f', 'image2pipe', // input: piped images
|
||||
@@ -4880,71 +5104,86 @@ export async function startRecording(outputPath, opts = {}) {
|
||||
resolvedPath
|
||||
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
||||
|
||||
let ffmpegError = '';
|
||||
ffmpeg.stderr.on('data', d => { ffmpegError += d.toString(); });
|
||||
ffmpeg.on('error', err => { ffmpegError += err.message; });
|
||||
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
|
||||
|
||||
// Listen for screencast frames and pipe to ffmpeg
|
||||
// CDP sends frames only on screen changes, so we duplicate frames
|
||||
// to fill gaps and maintain real-time playback speed
|
||||
const frameDuration = 1000 / fps;
|
||||
let lastFrameTime = null;
|
||||
let lastFrameBuf = null;
|
||||
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
|
||||
|
||||
cdp.on('Page.screencastFrame', async ({ data, sessionId }) => {
|
||||
// Frame handler shared across CDP sessions (lives in recorder, not closure):
|
||||
// when the active context switches, we attach a new CDP session and route its
|
||||
// frames to the same ffmpeg pipe — preserving a single continuous timeline.
|
||||
const frameHandler = async ({ data, sessionId }, cdp) => {
|
||||
if (!recorder) return;
|
||||
const buf = Buffer.from(data, 'base64');
|
||||
const now = Date.now();
|
||||
|
||||
if (!ffmpeg.stdin.destroyed) {
|
||||
let framesWritten = 0;
|
||||
if (lastFrameTime && lastFrameBuf) {
|
||||
// Fill the gap with duplicates of the previous frame
|
||||
const gap = now - lastFrameTime;
|
||||
if (recorder.lastFrameTime && recorder.lastFrameBuf) {
|
||||
const gap = now - recorder.lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration) - 1;
|
||||
for (let i = 0; i < dupes && i < fps * 30; i++) {
|
||||
ffmpeg.stdin.write(lastFrameBuf);
|
||||
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||
framesWritten++;
|
||||
}
|
||||
}
|
||||
ffmpeg.stdin.write(buf);
|
||||
framesWritten++;
|
||||
// Track actual video timeline position (accounts for frame duplication)
|
||||
if (recorder) recorder.videoTimeMs += framesWritten * frameDuration;
|
||||
recorder.videoTimeMs += framesWritten * frameDuration;
|
||||
}
|
||||
|
||||
lastFrameTime = now;
|
||||
lastFrameBuf = buf;
|
||||
recorder.lastFrameTime = now;
|
||||
recorder.lastFrameBuf = buf;
|
||||
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
|
||||
});
|
||||
|
||||
// Start the screencast
|
||||
await cdp.send('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
quality,
|
||||
everyNthFrame: 1
|
||||
});
|
||||
|
||||
// Expose a frame-writing helper on the recorder object.
|
||||
// During static periods (e.g. smart TTS pauses), CDP may not send screencast
|
||||
// frames. Call _flushFrames() to fill the gap with duplicates of the last frame,
|
||||
// keeping video timeline in sync with wall-clock time.
|
||||
const _flushFrames = () => {
|
||||
if (!lastFrameBuf || !lastFrameTime || ffmpeg.stdin.destroyed) return;
|
||||
const now = Date.now();
|
||||
const gap = now - lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration);
|
||||
for (let i = 0; i < dupes; i++) {
|
||||
ffmpeg.stdin.write(lastFrameBuf);
|
||||
if (recorder) recorder.videoTimeMs += frameDuration;
|
||||
}
|
||||
if (dupes > 0) lastFrameTime = now;
|
||||
};
|
||||
|
||||
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
|
||||
recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [], videoTimeMs: 0, _flushFrames, speechRate };
|
||||
// Redirect stderr accumulation to the recorder object
|
||||
ffmpeg.stderr.removeAllListeners('data');
|
||||
// Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
|
||||
const _flushFrames = () => {
|
||||
if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
|
||||
const now = Date.now();
|
||||
const gap = now - recorder.lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration);
|
||||
for (let i = 0; i < dupes; i++) {
|
||||
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||
recorder.videoTimeMs += frameDuration;
|
||||
}
|
||||
if (dupes > 0) recorder.lastFrameTime = now;
|
||||
};
|
||||
|
||||
// Attach screencast to a specific page. Stops the old CDP first (if any).
|
||||
// Called by startRecording for the initial page, and by setActiveContext when
|
||||
// the active context changes mid-recording.
|
||||
const _attachPage = async (targetPage) => {
|
||||
if (recorder.cdp) {
|
||||
_flushFrames(); // freeze the last frame of the outgoing page up to "now"
|
||||
try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
|
||||
try { await recorder.cdp.detach(); } catch {}
|
||||
recorder.cdp = null;
|
||||
}
|
||||
const cdp = await targetPage.context().newCDPSession(targetPage);
|
||||
cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
|
||||
await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
|
||||
recorder.cdp = cdp;
|
||||
recorder.activePage = targetPage;
|
||||
};
|
||||
|
||||
recorder = {
|
||||
cdp: null,
|
||||
activePage: null,
|
||||
ffmpeg,
|
||||
startTime: Date.now(),
|
||||
outputPath: resolvedPath,
|
||||
ffmpegError: '',
|
||||
captions: [],
|
||||
videoTimeMs: 0,
|
||||
frameDuration,
|
||||
lastFrameTime: null,
|
||||
lastFrameBuf: null,
|
||||
_flushFrames,
|
||||
_attachPage,
|
||||
speechRate,
|
||||
};
|
||||
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
|
||||
|
||||
await _attachPage(page);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,7 @@ python tools/cc-1c-skills/scripts/switch.py
|
||||
| Веб-публикация (Web) | 4 навыка `/web-*` | Публикация баз через Apache, статус, остановка, удаление публикаций | [Подробнее](docs/web-guide.md) |
|
||||
| Тестирование (Web) | `/web-test` | Взаимодействие с веб-клиентом 1С — навигация, формы, таблицы, отчёты, тестирование | [Подробнее](docs/web-test-guide.md) |
|
||||
| Запись видео (Web) | `/web-test` | Запись видеоинструкций с субтитрами, подсветкой и TTS-озвучкой | [Подробнее](docs/web-test-recording-guide.md) |
|
||||
| Регресс прикладного решения (Web) | `/web-test` | Автоматический регресс конфигурации: тесты, проверки, отчёты, прогон после правок | [Подробнее](docs/web-test-regression-guide.md) |
|
||||
| Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — |
|
||||
|
||||
## Требования
|
||||
@@ -255,6 +256,7 @@ docs/
|
||||
├── web-guide.md # Гайд: веб-публикация через Apache
|
||||
├── web-test-guide.md # Гайд: тестирование через веб-клиент
|
||||
├── web-test-recording-guide.md # Гайд: запись видеоинструкций
|
||||
├── web-test-regression-guide.md # Гайд: регресс прикладного решения
|
||||
├── 1c-epf-spec.md # Спецификация XML-формата (EPF)
|
||||
├── 1c-erf-spec.md # Спецификация XML-формата (ERF)
|
||||
├── 1c-form-spec.md # Спецификация управляемых форм
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# Регрессионное тестирование прикладного решения
|
||||
|
||||
Навык `/web-test` умеет не только разово выполнить сценарий в браузере, но и сопровождать прикладное решение полноценным набором автотестов: каждый тест — отдельный файл, с шагами, проверками, тегами, отчётом и видеозаписью падений. После каждой правки конфигурации модель прогоняет весь набор и показывает, что ожидаемо ведёт себя как раньше, а что сломалось.
|
||||
|
||||
```
|
||||
правка конфигурации → загрузка → обновление → публикация → прогон тестов → отчёт
|
||||
```
|
||||
|
||||
Это про прикладное решение в целом, не про разовую проверку одной формы. Для разовых сценариев («открой накладную, проверь сумму») по-прежнему удобнее интерактивный режим из [web-test-guide.md](web-test-guide.md).
|
||||
|
||||
## Предусловия
|
||||
|
||||
- База опубликована через Apache (`/web-publish`).
|
||||
- Установлен Node.js 18+, зависимости подняты: `cd .claude/skills/web-test/scripts && npm install`.
|
||||
- ffmpeg — нужен только если хотите видеозапись прогона как доказательство падения. Без него падения фиксируются скриншотами. Установка описана в [web-test-recording-guide.md](web-test-recording-guide.md).
|
||||
|
||||
## Как это устроено
|
||||
|
||||
Набор тестов живёт в каталоге `tests/` вашего проекта. Каждое прикладное решение — отдельная подпапка. Внутри подпапки:
|
||||
|
||||
- `_hooks.mjs` — подготовка стенда (восстановление базы, публикация) и общая очистка после прогона. Необязателен.
|
||||
- `webtest.config.mjs` — адрес базы и набор пользователей (например, кладовщик и менеджер для процессов согласования). Необязателен — если в проекте один пользователь и один URL, можно обойтись без него.
|
||||
- Сами тесты — файлы `*.test.mjs`, сгруппированные по функциональным папкам.
|
||||
|
||||
```
|
||||
tests/
|
||||
моя-конфигурация/
|
||||
_hooks.mjs
|
||||
webtest.config.mjs
|
||||
01-вход/
|
||||
01-открытие-базы.test.mjs
|
||||
02-контрагенты/
|
||||
01-создание.test.mjs
|
||||
02-правка-телефона.test.mjs
|
||||
03-поступление-товаров/
|
||||
01-оформление.test.mjs
|
||||
02-проведение.test.mjs
|
||||
04-отчёт-остатки/
|
||||
01-формирование.test.mjs
|
||||
05-согласование/
|
||||
01-полный-цикл.test.mjs
|
||||
```
|
||||
|
||||
Порядок выполнения — по алфавиту, поэтому удобно префиксовать папки и файлы номерами. Это даёт предсказуемый сценарий: сначала вход, потом справочники, потом документы, потом отчёты, в конце — процессы с несколькими пользователями.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Самый короткий путь от нуля до зелёного теста — попросить модель пройти ваш сценарий руками и зафиксировать его как тест:
|
||||
|
||||
```
|
||||
> Покрой регрессом справочник Контрагенты в моей конфигурации.
|
||||
> Нужны проверки: создание, правка телефона, удаление.
|
||||
```
|
||||
|
||||
Что сделает модель:
|
||||
|
||||
1. Соберёт информацию о справочнике через `/meta-info` и `/form-info` — посмотрит реквизиты и форму элемента, чтобы знать правильные имена полей.
|
||||
2. Подключится к опубликованной базе в интерактивном режиме и **руками пройдёт** каждый сценарий — создание, правка, удаление. Это нужно, чтобы зафиксировать настоящие имена кнопок, увидеть, какие диалоги показывает 1С, понять, требуется ли подтверждение сохранения.
|
||||
3. Зафиксирует пройденный сценарий как файл `tests/<ваша-конфигурация>/02-контрагенты/01-создание.test.mjs`.
|
||||
4. Запустит его и покажет результат.
|
||||
|
||||
При следующих прогонах ничего этого делать не нужно — модель просто запустит готовый набор.
|
||||
|
||||
## Сценарии работы с моделью
|
||||
|
||||
### Покрытие регрессом доработанного объекта
|
||||
|
||||
```
|
||||
> Я добавил в справочник Номенклатура реквизит "Цена" и "Активен".
|
||||
> Покрой это регрессом — создание, редактирование, фильтрация по активности
|
||||
```
|
||||
|
||||
Модель:
|
||||
- посмотрит структуру справочника и формы (через `/meta-info`, `/form-info`);
|
||||
- интерактивно проверит, как ведут себя новые поля в браузере;
|
||||
- сгенерирует 2-3 тестовых файла под папкой `02-номенклатура/`;
|
||||
- прогонит — покажет, что зелёное, что красное.
|
||||
|
||||
### Тест процесса с несколькими пользователями
|
||||
|
||||
```
|
||||
> Сделай тест для процесса согласования приходных накладных.
|
||||
> Кладовщик создаёт накладную, менеджер утверждает,
|
||||
> кладовщик видит обновлённый статус
|
||||
```
|
||||
|
||||
Модель настроит в `webtest.config.mjs` двух пользователей (с разными URL базы — например, `app-clerk` и `app-manager`), напишет тест, который оркестрирует переключение между ними, и положит его в `05-согласование/`.
|
||||
|
||||
```js
|
||||
export const contexts = ['кладовщик', 'менеджер'];
|
||||
|
||||
export default async function({ кладовщик, менеджер, step, assert }) {
|
||||
await step('Кладовщик создаёт накладную', async () => {
|
||||
await кладовщик.navigateSection('Склад');
|
||||
await кладовщик.openCommand('Приходные накладные');
|
||||
await кладовщик.clickElement('Создать');
|
||||
// ...
|
||||
});
|
||||
await step('Менеджер утверждает', async () => {
|
||||
await менеджер.navigateSection('Согласование');
|
||||
// ...
|
||||
});
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Учтите ограничение по лицензиям 1С: каждый одновременно открытый пользователь — это занятая клиентская лицензия. Если в наборе много многопользовательских тестов, а на стенде лицензий впритык, прогоны начнут спотыкаться на «свободных лицензий не осталось». Модель освобождает сессии между тестами автоматически (закрывает контексты после процессного теста), но если стенд ограничен — закладывайте это в планирование набора: один-два многопользовательских сценария вместо десяти.
|
||||
|
||||
### Воспроизведение ошибки тестом
|
||||
|
||||
```
|
||||
> При проведении накладной без заполненного контрагента у нас не появляется
|
||||
> ошибка валидации, документ просто проводится с пустым контрагентом — это баг.
|
||||
> Зафиксируй это падающим тестом
|
||||
```
|
||||
|
||||
Модель воспроизведёт сценарий, напишет тест с проверкой «должна быть ошибка», получит красный — потом, когда вы поправите конфигурацию и попросите перепрогнать, тест станет зелёным. Это документирует ожидаемое поведение в виде кода.
|
||||
|
||||
### Прогон регресса после изменений
|
||||
|
||||
```
|
||||
> Я обновил расширение, накатил в базу. Прогони регресс
|
||||
```
|
||||
|
||||
Модель запустит весь набор, дождётся завершения и расскажет:
|
||||
- сколько тестов прошло, сколько упало, сколько пропущено;
|
||||
- по каждому упавшему — что именно сломалось (название шага, сообщение об ошибке, ссылка на скриншот);
|
||||
- классифицирует падения: это ошибка в самом тесте (нужно поправить тест), ошибка в приложении (баг, который вы внесли изменением), или нестабильность стенда (Apache не ответил вовремя, лицензия не освободилась).
|
||||
|
||||
```
|
||||
> Прогони только тесты по контрагентам с подробным отчётом
|
||||
```
|
||||
|
||||
Запустит подмножество — фильтр по тегу или папке, с записью JSON-отчёта.
|
||||
|
||||
### Подготовка автономного стенда
|
||||
|
||||
Если вы хотите, чтобы регресс можно было запустить «с нуля» — даже на чистой машине без подготовленной базы, — модель настроит автоматическую подготовку стенда:
|
||||
|
||||
```
|
||||
> Сделай, чтобы перед прогоном тестов база восстанавливалась из эталона,
|
||||
> а после прогона публикация снималась
|
||||
```
|
||||
|
||||
Это пишется один раз в файле `_hooks.mjs`: при запуске тестов запускается подготовка (через навыки `/db-create`, `/db-load-xml`, `/web-publish`), а после — очистка. Внутри предусмотрено кэширование: если ничего не менялось со прошлого прогона, повторная подготовка занимает доли секунды.
|
||||
|
||||
## Пример организации покрытия
|
||||
|
||||
Допустим, у нас условное прикладное решение «Учёт поступлений товаров» — справочники контрагентов и номенклатуры, документ приходной накладной, отчёт остатков, процесс согласования с двумя пользователями. Логично организовать набор так:
|
||||
|
||||
```
|
||||
tests/учёт-поступлений/
|
||||
_hooks.mjs # подготовка: восстановление базы + публикация
|
||||
webtest.config.mjs # URL базы, контексты кладовщика и менеджера
|
||||
01-вход/
|
||||
01-открытие-базы.test.mjs # базовая работоспособность: вход проходит, разделы видны
|
||||
02-навигация-по-разделам.test.mjs # обход всех разделов конфигурации
|
||||
02-контрагенты/
|
||||
01-создание.test.mjs # создание, проверка появления в списке
|
||||
02-редактирование.test.mjs # правка реквизита, проверка сохранения
|
||||
03-удаление.test.mjs # удаление с подтверждением
|
||||
03-номенклатура/
|
||||
01-создание.test.mjs
|
||||
02-фильтр-по-активности.test.mjs # быстрая фильтрация списка
|
||||
04-поступление-товаров/
|
||||
01-оформление.test.mjs # заполнение шапки и табличной части
|
||||
02-проведение.test.mjs # проведение документа, проверка движений
|
||||
03-отмена-проведения.test.mjs
|
||||
04-валидация-обязательных.test.mjs # негативный тест: пустой контрагент → ошибка
|
||||
05-отчёт-остатки/
|
||||
01-формирование.test.mjs
|
||||
02-отбор-по-складу.test.mjs
|
||||
03-расшифровка.test.mjs # переход из ячейки отчёта в исходный документ
|
||||
06-согласование/
|
||||
01-полный-цикл.test.mjs # многопользовательский тест
|
||||
```
|
||||
|
||||
Принципы:
|
||||
|
||||
- **Папки — по бизнес-функции**, не по типу метаданных. Лучше `04-поступление-товаров/` (что делает пользователь), чем `документы/` (что лежит в дереве конфигурации).
|
||||
- **Цифровые префиксы** — на папке и на файле. Гарантируют, что сначала отработают базовые проверки (вход, справочники), потом сложные (документы, отчёты, процессы). При падении базы остальное и так не пройдёт — нет смысла занимать стенд получасом.
|
||||
- **Один файл — одна логически связанная история.** Не «всё про контрагентов в одном файле», а «отдельно создание, отдельно правка, отдельно удаление». Когда падает — сразу видно, какой именно сценарий сломан.
|
||||
- **Негативные тесты тоже есть.** «Документ без контрагента не проводится» — такой же важный регресс, как и позитивный сценарий, особенно после правок в обработчиках проверки заполнения.
|
||||
- **Процессные тесты — в конце.** Они самые хрупкие (зависят от двух сессий, лицензий, синхронизации) и самые длинные. Если упадут — у вас уже есть данные от предыдущих тестов.
|
||||
|
||||
## Анатомия одного теста
|
||||
|
||||
Пользователь, как правило, тест не пишет — генерирует модель. Но прочитать и поправить полезно уметь. Стандартный файл выглядит так:
|
||||
|
||||
```js
|
||||
export const name = 'Создание контрагента';
|
||||
export const tags = ['контрагенты', 'базовая-проверка'];
|
||||
export const timeout = 60000;
|
||||
|
||||
export default async function({
|
||||
navigateSection, openCommand, clickElement, fillFields,
|
||||
readTable, closeForm, assert, step
|
||||
}) {
|
||||
await step('Открыть список контрагентов', async () => {
|
||||
await navigateSection('Продажи');
|
||||
await openCommand('Контрагенты');
|
||||
});
|
||||
|
||||
await step('Создать нового контрагента', async () => {
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Наименование': 'ТД Тест', 'ИНН': '7707083893' });
|
||||
await clickElement('Записать и закрыть');
|
||||
});
|
||||
|
||||
await step('Убедиться, что элемент появился в списке', async () => {
|
||||
const t = await readTable();
|
||||
assert.tableHasRow(t, r => r['Наименование'] === 'ТД Тест');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Что здесь есть:
|
||||
|
||||
- **`name`** — человекочитаемое имя теста. Появится в отчёте.
|
||||
- **`tags`** — теги для фильтрации. Можно прогонять не весь набор, а только нужные: `--tags=контрагенты`.
|
||||
- **`timeout`** — сколько максимум тест может идти. По умолчанию 30 секунд, для длинных сценариев увеличиваем.
|
||||
- **Тело теста** — функция, которая получает API браузера (см. [SKILL.md](../.claude/skills/web-test/SKILL.md)) плюс `assert` и `step`.
|
||||
- **`step('имя', async () => {...})`** — обёртка шага. Имена шагов попадают в отчёт, при падении видно, какой именно шаг сломался.
|
||||
- **`assert.*`** — проверки. `assert.tableHasRow`, `assert.equal`, `assert.ok` и т.д. Если проверка не выполнилась — тест считается упавшим.
|
||||
|
||||
Имена шагов и теста — по-русски, описательные. Они показываются и в консоли, и в отчётах.
|
||||
|
||||
## Запуск и отчёты
|
||||
|
||||
### Простой прогон
|
||||
|
||||
```
|
||||
> Прогони регресс
|
||||
```
|
||||
|
||||
Модель запустит весь набор, дождётся, покажет сводку:
|
||||
|
||||
```
|
||||
✓ Открытие базы (2.1s)
|
||||
✓ Создание контрагента (8.4s)
|
||||
✗ Проведение приходной накладной (12.7s)
|
||||
└ Заполнить табличную часть (5.2s)
|
||||
Не найден столбец "Цена" в табличной части "Товары"
|
||||
скриншот: tests/учёт-поступлений/error-shot.png
|
||||
|
||||
23 пройдено, 1 упал, 0 пропущено (3 мин 42 с)
|
||||
```
|
||||
|
||||
### Подробный отчёт
|
||||
|
||||
```
|
||||
> Прогони регресс и сохрани подробный отчёт
|
||||
```
|
||||
|
||||
Модель добавит флаг записи отчёта (JSON или Allure) — потом по нему можно листать историю прогонов, видеть длительности шагов, открывать прикреплённые скриншоты.
|
||||
|
||||
Allure — стандартный визуальный отчёт с категориями падений, графиками, таймлайном. Чтобы посмотреть отчёт после прогона:
|
||||
|
||||
```bash
|
||||
# Allure CLI устанавливается отдельно (npm install -g allure-commandline)
|
||||
allure serve allure-results
|
||||
```
|
||||
|
||||
### Категории падений в Allure
|
||||
|
||||
Без дополнительной настройки Allure складывает все упавшие тесты в один общий список «Defects». Если в прогоне упало 15 тестов, не сразу понятно, что из этого — пятнадцать разных проблем или одна и та же ошибка (например, нехватка лицензии на стенде), которая зацепила пятнадцать тестов подряд.
|
||||
|
||||
Чтобы Allure группировал падения по причинам, рядом с тестами кладётся каталог `_allure/` с файлом `categories.json`. Подчёркивание в имени каталога — чтобы он не воспринимался как папка с тестами; раннер копирует его содержимое в отчёт.
|
||||
|
||||
```
|
||||
tests/моя-конфигурация/
|
||||
_allure/
|
||||
categories.json # классификация падений
|
||||
environment.properties # необязательно: URL, версия 1С, ветка git
|
||||
executor.json # необязательно: метаданные сборки CI
|
||||
_hooks.mjs
|
||||
01-вход/
|
||||
...
|
||||
```
|
||||
|
||||
`categories.json` — это список регулярных выражений, по которым ошибка теста относится к той или иной группе:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Нехватка лицензий 1С",
|
||||
"matchedStatuses": ["failed", "broken"],
|
||||
"messageRegex": ".*Не обнаружено свободной лицензии.*" },
|
||||
{ "name": "Ошибка приложения 1С",
|
||||
"matchedStatuses": ["failed"],
|
||||
"messageRegex": ".*(ВызватьИсключение|В поле введены некорректные данные|Произошла ошибка).*" },
|
||||
{ "name": "Элемент не найден",
|
||||
"matchedStatuses": ["failed"],
|
||||
"messageRegex": ".*(clickElement|fillFields|selectValue).*not found.*" },
|
||||
{ "name": "Превышен лимит времени теста",
|
||||
"matchedStatuses": ["failed", "broken"],
|
||||
"messageRegex": "Timeout \\(\\d+ms\\)" },
|
||||
{ "name": "Несовпадение ожидания и факта",
|
||||
"matchedStatuses": ["failed"],
|
||||
"messageRegex": "(Expected|AssertionError).*" }
|
||||
]
|
||||
```
|
||||
|
||||
Когда вы попросите модель в первый раз настроить регресс, она положит шаблонный `categories.json` со стандартными классами. По мере того как вы будете находить новые типичные причины падений (например, специфичные для вашего расширения тексты ошибок), категории дополняются.
|
||||
|
||||
В виджете «Categories» итогового отчёта вы увидите примерно так:
|
||||
|
||||
```
|
||||
Нехватка лицензий 1С — 12 падений
|
||||
Ошибка приложения 1С — 2 падения
|
||||
Несовпадение ожидания и факта — 1 падение
|
||||
```
|
||||
|
||||
— и сразу понятно, что 12 падений — это один стенд-баг, а двумя «ошибками приложения» нужно разобраться по существу.
|
||||
|
||||
Помимо `categories.json` в каталог `_allure/` можно положить ещё два стандартных файла:
|
||||
|
||||
- **`environment.properties`** — список `ключ=значение` (URL базы, версия платформы 1С, имя ветки git, номер сборки). Покажется в отчёте в виджете «Environment». Полезно, когда регресс гоняется на нескольких стендах или после каждого билда — видно, на чём именно был получен результат. Этот файл удобно генерировать прямо в подготовке стенда (`_hooks.mjs`), а не держать статичной копией.
|
||||
- **`executor.json`** — метаданные системы сборки: ссылка на Jenkins-задачу, идентификатор запуска GitHub Actions и т.д. Нужен только если регресс запускается на сервере сборки. При локальном прогоне ничего класть не надо.
|
||||
|
||||
### Прогон части набора
|
||||
|
||||
```
|
||||
> Прогони только тесты по поступлениям товаров
|
||||
> Прогони только базовые проверки
|
||||
> Прогони только упавший вчера тест с проведением накладной
|
||||
```
|
||||
|
||||
Модель выберет нужное подмножество — по папке, по тегу или по имени теста.
|
||||
|
||||
### Принудительная пересборка стенда
|
||||
|
||||
Если хотите, чтобы перед прогоном база восстановилась с нуля:
|
||||
|
||||
```
|
||||
> Прогони регресс с полной пересборкой стенда
|
||||
```
|
||||
|
||||
Это передаст в подготовку флаг типа `--rebuild-stand` — `_hooks.mjs` пересоздаст базу из эталона. Полезно после крупных правок или если подозреваете, что предыдущие прогоны загрязнили данные.
|
||||
|
||||
## Что делать, когда тест упал
|
||||
|
||||
Модель проанализирует падение и отнесёт его к одной из трёх категорий:
|
||||
|
||||
1. **Ошибка в самом тесте.** Например, переименовали реквизит — тест ищет старое имя поля. Решение: модель обновит тест.
|
||||
2. **Ошибка в приложении.** Это и есть то, ради чего регресс существует: что-то поменялось в конфигурации, и сценарий, который раньше работал, теперь не отрабатывает. Модель опишет, что именно произошло, со скриншотом и трассировкой стека 1С, если ошибка была серверной.
|
||||
3. **Нестабильность стенда.** Apache не ответил, не освободилась лицензия, база отвалилась. Это лечится не правкой теста, а починкой подготовки стенда в `_hooks.mjs` или, реже, повторным прогоном с одним повтором.
|
||||
|
||||
Просите модель не «исправь упавший тест», а «разберись с падением» — иначе она может молча подкрутить ожидание под текущее поведение, замаскировав настоящий баг.
|
||||
|
||||
## Полезные подробности
|
||||
|
||||
### Тестовые данные
|
||||
|
||||
В прикладном решении обычно нужны какие-то стартовые данные: пара контрагентов, номенклатура, заведённые организации. Их кладём не в каждый тест, а один раз в подготовку стенда (`_hooks.mjs`) — после восстановления базы загружаются эталонные данные, на которых работают все тесты.
|
||||
|
||||
Если конкретному тесту нужны свои данные (например, документ, который мы будем редактировать), он создаёт их сам в начале и убирает в конце.
|
||||
|
||||
### Имена документов и уникальность
|
||||
|
||||
Тесты прогоняются многократно. Если тест создаёт документ «Накладная-Тест», следующий прогон может натолкнуться на старую запись. Решение — добавлять к имени метку времени:
|
||||
|
||||
```js
|
||||
const метка = 'Тест-' + Date.now();
|
||||
await fillFields({ 'Комментарий': метка });
|
||||
// ...
|
||||
const t = await readTable();
|
||||
assert.tableHasRow(t, r => r['Комментарий'] === метка);
|
||||
```
|
||||
|
||||
Модель это делает автоматически, но если правите тест руками — держите в голове.
|
||||
|
||||
### Видео при падении
|
||||
|
||||
Можно включить запись видео всех тестов — тогда при падении прикладывается не только скриншот, но и MP4 со всей сессией:
|
||||
|
||||
```
|
||||
> Прогони регресс с записью видео
|
||||
```
|
||||
|
||||
Размер прогона при этом растёт (на 2-3 минутах теста выходит 5-10 МБ), но при отладке сложного падения видео экономит кучу времени.
|
||||
|
||||
### Многоязычные конфигурации
|
||||
|
||||
Если у вас есть конфигурация с командами и реквизитами на нескольких языках, тесты пишутся под один язык (как правило, тот, в котором ведётся работа в проде). При смене языка интерфейса в браузере тесты не пройдут — модель видит другие подписи кнопок.
|
||||
|
||||
## Где смотреть дальше
|
||||
|
||||
- API браузера, которое вызывают тесты — [SKILL.md](../.claude/skills/web-test/SKILL.md).
|
||||
- Подробная инструкция для модели по написанию тестов (на английском, технический документ) — [.claude/skills/web-test/regress.md](../.claude/skills/web-test/regress.md).
|
||||
- Интерактивный режим без тестов — [web-test-guide.md](web-test-guide.md).
|
||||
- Запись видеоинструкций — [web-test-recording-guide.md](web-test-recording-guide.md).
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
// build-webtest-db v0.2 — Собирает синтетическую web-test конфигурацию в постоянные пути
|
||||
// и накатывает её в зарегистрированную базу `webtest` (см. .v8-project.json).
|
||||
//
|
||||
// Двойной режим:
|
||||
// - CLI: node tests/skills/build-webtest-db.mjs [--runtime ...] [--skip-platform]
|
||||
// - Module: import { runSteps, execSkill, getProjectInfo, ... } from './build-webtest-db.mjs'
|
||||
//
|
||||
// CLI:
|
||||
// node tests/skills/build-webtest-db.mjs # пересобрать с нуля
|
||||
// node tests/skills/build-webtest-db.mjs --runtime python
|
||||
// node tests/skills/build-webtest-db.mjs --skip-platform # только XML, без db-create/load/update
|
||||
//
|
||||
// После завершения база готова к /web-publish + web-test сессии.
|
||||
|
||||
import { execFile } from 'child_process';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join, resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const ROOT = dirname(__filename);
|
||||
const REPO_ROOT = resolve(ROOT, '../..');
|
||||
const SKILLS = resolve(REPO_ROOT, '.claude/skills');
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reads .v8-project.json and locates webtest registration.
|
||||
* @returns {{ v8path: string, v8exe: string, webtestDb: object, configSrc: string, dbPath: string }}
|
||||
*/
|
||||
export function getProjectInfo() {
|
||||
const projectFile = join(REPO_ROOT, '.v8-project.json');
|
||||
if (!existsSync(projectFile)) throw new Error('.v8-project.json not found');
|
||||
const proj = JSON.parse(readFileSync(projectFile, 'utf8'));
|
||||
const webtestDb = proj.databases?.find(d => d.id === 'webtest');
|
||||
if (!webtestDb) throw new Error('Database "webtest" not registered in .v8-project.json');
|
||||
const v8path = proj.v8path;
|
||||
const v8exe = join(v8path, '1cv8.exe');
|
||||
const dbPath = webtestDb.path;
|
||||
const configSrc = resolve(REPO_ROOT, webtestDb.configSrc);
|
||||
return { v8path, v8exe, webtestDb, configSrc, dbPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a skill script path to an absolute file (chooses .ps1 or .py based on runtime).
|
||||
*/
|
||||
export function resolveScript(scriptRelPath, runtime = 'powershell') {
|
||||
const ext = runtime === 'python' ? '.py' : '.ps1';
|
||||
const full = join(SKILLS, scriptRelPath + ext);
|
||||
if (!existsSync(full)) throw new Error(`Script not found: ${full}`);
|
||||
return full;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single skill script with provided arguments.
|
||||
* @returns {Promise<string>} stdout
|
||||
*/
|
||||
export function execSkill(scriptPath, args, runtime = 'powershell') {
|
||||
return new Promise((res, rej) => {
|
||||
const cmd = runtime === 'python'
|
||||
? [process.env.PYTHON || 'python', [scriptPath, ...args]]
|
||||
: ['powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]];
|
||||
execFile(cmd[0], cmd[1], { encoding: 'utf8', timeout: 120_000, cwd: REPO_ROOT }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
rej(new Error(stderr?.trim() || stdout?.trim() || err.message));
|
||||
} else {
|
||||
res(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces {workDir}/{v8path}/{dbPath} placeholders in a string value.
|
||||
*/
|
||||
export function replacePlaceholders(s, paths) {
|
||||
return String(s)
|
||||
.replace('{workDir}', paths.workDir ?? '')
|
||||
.replace('{v8path}', paths.v8path ?? '')
|
||||
.replace('{dbPath}', paths.dbPath ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an array of build steps.
|
||||
*
|
||||
* Each step: { name, script?, args?, input?, writeFile?, content? }
|
||||
* - writeFile: write content to a file (relative to workDir or absolute), skip script call
|
||||
* - script: relative path under .claude/skills (without extension)
|
||||
* - args: { '-Flag': value | true }, value may contain {workDir}/{v8path}/{dbPath}/{inputFile}
|
||||
* - input: JSON object written to __input.json (referenced by {inputFile} in args)
|
||||
*
|
||||
* @param {Array} steps
|
||||
* @param {{ workDir: string, v8path: string, dbPath: string }} paths
|
||||
* @param {string} runtime 'powershell' | 'python'
|
||||
* @param {(line: string) => void} log
|
||||
* @returns {Promise<{ ok: boolean, elapsed: number, failedAt?: number }>}
|
||||
*/
|
||||
export async function runSteps(steps, paths, runtime, log = console.log) {
|
||||
const t0 = Date.now();
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const stepT0 = Date.now();
|
||||
|
||||
if (step.writeFile) {
|
||||
try {
|
||||
const target = replacePlaceholders(step.writeFile, paths);
|
||||
const abs = target.includes(':') || target.startsWith('/') ? target : join(paths.workDir, target);
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, step.content ?? '', 'utf8');
|
||||
const ms = Date.now() - stepT0;
|
||||
log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`);
|
||||
} catch (e) {
|
||||
log(` [${i + 1}/${steps.length}] FAIL ${step.name}: ${e.message}`);
|
||||
return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let inputFile = null;
|
||||
if (step.input) {
|
||||
inputFile = join(paths.workDir, '__input.json');
|
||||
writeFileSync(inputFile, JSON.stringify(step.input, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
const script = resolveScript(step.script, runtime);
|
||||
const args = [];
|
||||
for (const [flag, value] of Object.entries(step.args || {})) {
|
||||
args.push(flag);
|
||||
if (value === true) continue;
|
||||
let v = String(value).replace('{inputFile}', inputFile || '');
|
||||
v = replacePlaceholders(v, paths);
|
||||
args.push(v);
|
||||
}
|
||||
|
||||
try {
|
||||
await execSkill(script, args, runtime);
|
||||
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
|
||||
const ms = Date.now() - stepT0;
|
||||
log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`);
|
||||
} catch (e) {
|
||||
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
|
||||
log(` [${i + 1}/${steps.length}] FAIL ${step.name}`);
|
||||
log(` ${e.message.split('\n').join('\n ').substring(0, 1500)}`);
|
||||
return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i };
|
||||
}
|
||||
}
|
||||
return { ok: true, elapsed: (Date.now() - t0) / 1000 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the standard platform load steps (db-create + db-load-xml + db-update).
|
||||
*/
|
||||
export function platformLoadSteps() {
|
||||
return [
|
||||
{
|
||||
name: 'db-create: создание файловой ИБ',
|
||||
script: 'db-create/scripts/db-create',
|
||||
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' },
|
||||
},
|
||||
{
|
||||
name: 'db-load-xml: загрузка конфигурации',
|
||||
script: 'db-load-xml/scripts/db-load-xml',
|
||||
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}', '-ConfigDir': '{workDir}' },
|
||||
},
|
||||
{
|
||||
name: 'db-update: обновление БД',
|
||||
script: 'db-update/scripts/db-update',
|
||||
args: { '-V8Path': '{v8path}', '-InfoBasePath': '{dbPath}' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the build-webtest-config.test.mjs steps array.
|
||||
*/
|
||||
export async function loadBuildSteps() {
|
||||
const buildModule = await import(`file://${join(ROOT, 'integration/build-webtest-config.test.mjs').replace(/\\/g, '/')}`);
|
||||
return buildModule.steps;
|
||||
}
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function runCli() {
|
||||
const argv = process.argv.slice(2);
|
||||
const opts = { runtime: 'powershell', skipPlatform: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--runtime' && argv[i + 1]) { opts.runtime = argv[++i]; continue; }
|
||||
if (a === '--skip-platform') { opts.skipPlatform = true; continue; }
|
||||
if (a === '-h' || a === '--help') {
|
||||
console.log('Usage: build-webtest-db.mjs [--runtime powershell|python] [--skip-platform]');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
const { v8path, v8exe, configSrc, dbPath } = getProjectInfo();
|
||||
|
||||
if (!opts.skipPlatform && !existsSync(v8exe)) {
|
||||
console.error(`1cv8.exe not found at ${v8exe}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[build-webtest-db] configSrc: ${configSrc}`);
|
||||
console.log(`[build-webtest-db] dbPath: ${dbPath}`);
|
||||
console.log(`[build-webtest-db] runtime: ${opts.runtime}`);
|
||||
console.log('');
|
||||
|
||||
if (existsSync(configSrc)) {
|
||||
console.log(`Removing existing configSrc...`);
|
||||
rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
}
|
||||
mkdirSync(configSrc, { recursive: true });
|
||||
|
||||
if (!opts.skipPlatform && existsSync(dbPath)) {
|
||||
console.log(`Removing existing IB...`);
|
||||
rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
}
|
||||
|
||||
const buildSteps = await loadBuildSteps();
|
||||
const platformSteps = opts.skipPlatform ? [] : platformLoadSteps();
|
||||
const allSteps = [...buildSteps, ...platformSteps];
|
||||
|
||||
const paths = { workDir: configSrc, v8path, dbPath };
|
||||
const result = await runSteps(allSteps, paths, opts.runtime, console.log);
|
||||
|
||||
console.log('');
|
||||
if (!result.ok) {
|
||||
console.error(`Build FAILED after ${result.elapsed.toFixed(1)}s`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Build OK (${result.elapsed.toFixed(1)}s)`);
|
||||
console.log('');
|
||||
console.log(` configSrc: ${configSrc}`);
|
||||
if (!opts.skipPlatform) {
|
||||
console.log(` IB: ${dbPath}`);
|
||||
console.log('');
|
||||
console.log(` Next: /web-publish webtest → open in browser`);
|
||||
}
|
||||
}
|
||||
|
||||
// CLI guard: run only when invoked directly, not when imported.
|
||||
const invokedDirectly = process.argv[1]
|
||||
? fileURLToPath(import.meta.url) === resolve(process.argv[1])
|
||||
: false;
|
||||
if (invokedDirectly) {
|
||||
runCli().catch(e => {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<Configuration uuid="UUID-001">
|
||||
<InternalInfo>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>UUID-002</xr:ClassId>
|
||||
<xr:ObjectId>UUID-003</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>UUID-004</xr:ClassId>
|
||||
<xr:ObjectId>UUID-005</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>UUID-006</xr:ClassId>
|
||||
<xr:ObjectId>UUID-007</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>UUID-008</xr:ClassId>
|
||||
<xr:ObjectId>UUID-009</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>UUID-010</xr:ClassId>
|
||||
<xr:ObjectId>UUID-011</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>UUID-012</xr:ClassId>
|
||||
<xr:ObjectId>UUID-013</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
<xr:ContainedObject>
|
||||
<xr:ClassId>UUID-014</xr:ClassId>
|
||||
<xr:ObjectId>UUID-015</xr:ObjectId>
|
||||
</xr:ContainedObject>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>TestConfig</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>TestConfig</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment />
|
||||
<NamePrefix />
|
||||
<ConfigurationExtensionCompatibilityMode>Version8_3_24</ConfigurationExtensionCompatibilityMode>
|
||||
<DefaultRunMode>ManagedApplication</DefaultRunMode>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
</UsePurposes>
|
||||
<ScriptVariant>Russian</ScriptVariant>
|
||||
<DefaultRoles />
|
||||
<Vendor></Vendor>
|
||||
<Version></Version>
|
||||
<UpdateCatalogAddress />
|
||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
<UseManagedFormInOrdinaryApplication>false</UseManagedFormInOrdinaryApplication>
|
||||
<UseOrdinaryFormInManagedApplication>false</UseOrdinaryFormInManagedApplication>
|
||||
<AdditionalFullTextSearchDictionaries />
|
||||
<CommonSettingsStorage />
|
||||
<ReportsUserSettingsStorage />
|
||||
<ReportsVariantsStorage />
|
||||
<FormDataSettingsStorage />
|
||||
<DynamicListsUserSettingsStorage />
|
||||
<URLExternalDataStorage />
|
||||
<Content />
|
||||
<DefaultReportForm />
|
||||
<DefaultReportVariantForm />
|
||||
<DefaultReportSettingsForm />
|
||||
<DefaultReportAppearanceTemplate />
|
||||
<DefaultDynamicListSettingsForm />
|
||||
<DefaultSearchForm />
|
||||
<DefaultDataHistoryChangeHistoryForm />
|
||||
<DefaultDataHistoryVersionDataForm />
|
||||
<DefaultDataHistoryVersionDifferencesForm />
|
||||
<DefaultCollaborationSystemUsersChoiceForm />
|
||||
<RequiredMobileApplicationPermissions />
|
||||
<UsedMobileApplicationFunctionalities>
|
||||
<app:functionality>
|
||||
<app:functionality>Biometrics</app:functionality>
|
||||
<app:use>true</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>Location</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>BackgroundLocation</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>BluetoothPrinters</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>WiFiPrinters</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>Contacts</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>Calendars</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>PushNotifications</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>LocalNotifications</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>InAppPurchases</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>PersonalComputerFileExchange</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>Ads</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>NumberDialing</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>CallProcessing</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>CallLog</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>AutoSendSMS</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>ReceiveSMS</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>SMSLog</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>Camera</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>Microphone</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>MusicLibrary</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>PictureAndVideoLibraries</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>AudioPlaybackAndVibration</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>BackgroundAudioPlaybackAndVibration</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>InstallPackages</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>OSBackup</app:functionality>
|
||||
<app:use>true</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>ApplicationUsageStatistics</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>BarcodeScanning</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>BackgroundAudioRecording</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>AllFilesAccess</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>Videoconferences</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>NFC</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>DocumentScanning</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>SpeechToText</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>Geofences</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>IncomingShareRequests</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
<app:functionality>
|
||||
<app:functionality>AllIncomingShareRequestsTypesProcessing</app:functionality>
|
||||
<app:use>false</app:use>
|
||||
</app:functionality>
|
||||
</UsedMobileApplicationFunctionalities>
|
||||
<StandaloneConfigurationRestrictionRoles />
|
||||
<MobileApplicationURLs />
|
||||
<AllowedIncomingShareRequestTypes />
|
||||
<MainClientApplicationWindowMode>Normal</MainClientApplicationWindowMode>
|
||||
<DefaultInterface />
|
||||
<DefaultStyle />
|
||||
<DefaultLanguage>Language.Русский</DefaultLanguage>
|
||||
<BriefInformation />
|
||||
<DetailedInformation />
|
||||
<Copyright />
|
||||
<VendorInformationAddress />
|
||||
<ConfigurationInformationAddress />
|
||||
<DataLockControlMode>Managed</DataLockControlMode>
|
||||
<ObjectAutonumerationMode>NotAutoFree</ObjectAutonumerationMode>
|
||||
<ModalityUseMode>DontUse</ModalityUseMode>
|
||||
<SynchronousPlatformExtensionAndAddInCallUseMode>DontUse</SynchronousPlatformExtensionAndAddInCallUseMode>
|
||||
<InterfaceCompatibilityMode>TaxiEnableVersion8_2</InterfaceCompatibilityMode>
|
||||
<DatabaseTablespacesUseMode>DontUse</DatabaseTablespacesUseMode>
|
||||
<CompatibilityMode>Version8_3_24</CompatibilityMode>
|
||||
<DefaultConstantsForm />
|
||||
</Properties>
|
||||
<ChildObjects>
|
||||
<Language>Русский</Language>
|
||||
<DataProcessor>ЗапретРучногоВвода</DataProcessor>
|
||||
</ChildObjects>
|
||||
</Configuration>
|
||||
</MetaDataObject>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<DataProcessor uuid="UUID-001">
|
||||
<InternalInfo>
|
||||
<xr:GeneratedType name="DataProcessorObject.ЗапретРучногоВвода" category="Object">
|
||||
<xr:TypeId>UUID-002</xr:TypeId>
|
||||
<xr:ValueId>UUID-003</xr:ValueId>
|
||||
</xr:GeneratedType>
|
||||
<xr:GeneratedType name="DataProcessorManager.ЗапретРучногоВвода" category="Manager">
|
||||
<xr:TypeId>UUID-004</xr:TypeId>
|
||||
<xr:ValueId>UUID-005</xr:ValueId>
|
||||
</xr:GeneratedType>
|
||||
</InternalInfo>
|
||||
<Properties>
|
||||
<Name>ЗапретРучногоВвода</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Запрет ручного ввода</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment />
|
||||
<UseStandardCommands>false</UseStandardCommands>
|
||||
<DefaultForm>DataProcessor.ЗапретРучногоВвода.Form.Форма</DefaultForm>
|
||||
<AuxiliaryForm />
|
||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
<ExtendedPresentation />
|
||||
<Explanation />
|
||||
</Properties>
|
||||
<ChildObjects>
|
||||
<Form>Форма</Form>
|
||||
</ChildObjects>
|
||||
</DataProcessor>
|
||||
</MetaDataObject>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<Form uuid="UUID-001">
|
||||
<Properties>
|
||||
<Name>Форма</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Форма</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<FormType>Managed</FormType>
|
||||
<IncludeHelpInContents>false</IncludeHelpInContents>
|
||||
<UsePurposes>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">PlatformApplication</v8:Value>
|
||||
<v8:Value xsi:type="app:ApplicationUsePurpose">MobilePlatformApplication</v8:Value>
|
||||
</UsePurposes>
|
||||
<ExtendedPresentation/>
|
||||
</Properties>
|
||||
</Form>
|
||||
</MetaDataObject>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcssch="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<Title>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Запрет ручного ввода</v8:content>
|
||||
</v8:item>
|
||||
</Title>
|
||||
<AutoTitle>false</AutoTitle>
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1"/>
|
||||
<ChildItems>
|
||||
<InputField name="ОбычноеПоле" id="1">
|
||||
<DataPath>ОбычноеПоле</DataPath>
|
||||
<Title>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Обычное поле</v8:content>
|
||||
</v8:item>
|
||||
</Title>
|
||||
<ContextMenu name="ОбычноеПолеКонтекстноеМеню" id="2"/>
|
||||
<ExtendedTooltip name="ОбычноеПолеРасширеннаяПодсказка" id="3"/>
|
||||
</InputField>
|
||||
<InputField name="ПолеБезРучногоВвода" id="4">
|
||||
<DataPath>ПолеБезРучногоВвода</DataPath>
|
||||
<Title>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Только через выбор</v8:content>
|
||||
</v8:item>
|
||||
</Title>
|
||||
<TextEdit>false</TextEdit>
|
||||
<ContextMenu name="ПолеБезРучногоВводаКонтекстноеМеню" id="5"/>
|
||||
<ExtendedTooltip name="ПолеБезРучногоВводаРасширеннаяПодсказка" id="6"/>
|
||||
</InputField>
|
||||
</ChildItems>
|
||||
<Attributes>
|
||||
<Attribute name="Объект" id="7">
|
||||
<Type>
|
||||
<v8:Type>cfg:DataProcessorObject.ЗапретРучногоВвода</v8:Type>
|
||||
</Type>
|
||||
<MainAttribute>true</MainAttribute>
|
||||
</Attribute>
|
||||
<Attribute name="ОбычноеПоле" id="8">
|
||||
<Title>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Обычное поле</v8:content>
|
||||
</v8:item>
|
||||
</Title>
|
||||
<Type>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>100</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</Type>
|
||||
</Attribute>
|
||||
<Attribute name="ПолеБезРучногоВвода" id="9">
|
||||
<Title>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Поле без ручного ввода</v8:content>
|
||||
</v8:item>
|
||||
</Title>
|
||||
<Type>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>100</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</Type>
|
||||
</Attribute>
|
||||
</Attributes>
|
||||
</Form>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#Область ОбработчикиСобытийФормы
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ОбработчикиСобытийЭлементовФормы
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ОбработчикиКомандФормы
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область ОбработчикиОповещений
|
||||
|
||||
#КонецОбласти
|
||||
|
||||
#Область СлужебныеПроцедурыИФункции
|
||||
|
||||
#КонецОбласти
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
|
||||
<top>
|
||||
<panel id="UUID-001">
|
||||
<uuid>UUID-002</uuid>
|
||||
</panel>
|
||||
</top>
|
||||
<left>
|
||||
<panel id="UUID-003">
|
||||
<uuid>UUID-004</uuid>
|
||||
</panel>
|
||||
</left>
|
||||
<panelDef id="UUID-004"/>
|
||||
<panelDef id="UUID-005"/>
|
||||
<panelDef id="UUID-006"/>
|
||||
<panelDef id="UUID-002"/>
|
||||
<panelDef id="UUID-007"/>
|
||||
</ClientApplicationInterface>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<MetaDataObject xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<Language uuid="UUID-001">
|
||||
<Properties>
|
||||
<Name>Русский</Name>
|
||||
<Synonym>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Русский</v8:content>
|
||||
</v8:item>
|
||||
</Synonym>
|
||||
<Comment/>
|
||||
<LanguageCode>ru</LanguageCode>
|
||||
</Properties>
|
||||
</Language>
|
||||
</MetaDataObject>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Поле ввода с textEdit:false (запрет ручного ввода)",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "meta-compile/scripts/meta-compile",
|
||||
"input": { "type": "DataProcessor", "name": "ЗапретРучногоВвода" },
|
||||
"args": { "-JsonPath": "{inputFile}", "-OutputDir": "{workDir}" }
|
||||
},
|
||||
{
|
||||
"script": "form-add/scripts/form-add",
|
||||
"args": { "-ObjectPath": "{workDir}/DataProcessors/ЗапретРучногоВвода.xml", "-FormName": "Форма" }
|
||||
}
|
||||
],
|
||||
"params": { "outputPath": "DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form.xml" },
|
||||
"validatePath": "DataProcessors/ЗапретРучногоВвода/Forms/Форма/Ext/Form.xml",
|
||||
"input": {
|
||||
"title": "Запрет ручного ввода",
|
||||
"elements": [
|
||||
{ "input": "ОбычноеПоле", "path": "ОбычноеПоле", "title": "Обычное поле" },
|
||||
{ "input": "ПолеБезРучногоВвода", "path": "ПолеБезРучногоВвода", "textEdit": false, "title": "Только через выбор" }
|
||||
],
|
||||
"attributes": [
|
||||
{ "name": "Объект", "type": "DataProcessorObject.ЗапретРучногоВвода", "main": true },
|
||||
{ "name": "ОбычноеПоле", "type": "string(100)" },
|
||||
{ "name": "ПолеБезРучногоВвода", "type": "string(100)" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "DataPath с Items.<Table>.CurrentData и ~Атрибут не вызывают ложных ошибок",
|
||||
"setup": "fixture:datapath-currentdata",
|
||||
"params": { "formPath": "DataProcessors/Spec/Forms/Форма" }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Числовые и UUID DataPath не вызывают ложных ошибок",
|
||||
"setup": "fixture:datapath-opaque-refs",
|
||||
"params": { "formPath": "DataProcessors/Opaque/Forms/Форма" }
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<Title>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Тест</v8:content>
|
||||
</v8:item>
|
||||
</Title>
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1"/>
|
||||
<ChildItems>
|
||||
<Table name="Список" id="1">
|
||||
<DataPath>Список</DataPath>
|
||||
<ContextMenu name="СписокКонтекстноеМеню" id="2"/>
|
||||
<AutoCommandBar name="СписокКоманднаяПанель" id="3"/>
|
||||
<SearchStringAddition name="СписокСтрокаПоиска" id="4"/>
|
||||
<ViewStatusAddition name="СписокСостояниеПросмотра" id="5"/>
|
||||
<SearchControlAddition name="СписокУправлениеПоиском" id="6"/>
|
||||
<ChildItems>
|
||||
<InputField name="Ссылка" id="7">
|
||||
<DataPath>Список.Ссылка</DataPath>
|
||||
<ContextMenu name="СсылкаКонтекстноеМеню" id="8"/>
|
||||
<ExtendedTooltip name="СсылкаРасширеннаяПодсказка" id="9"/>
|
||||
</InputField>
|
||||
</ChildItems>
|
||||
</Table>
|
||||
<InputField name="ТекущаяСсылка" id="10">
|
||||
<DataPath>Items.Список.CurrentData.Ссылка</DataPath>
|
||||
<ContextMenu name="ТекущаяСсылкаКонтекстноеМеню" id="11"/>
|
||||
<ExtendedTooltip name="ТекущаяСсылкаРасширеннаяПодсказка" id="12"/>
|
||||
</InputField>
|
||||
<InputField name="ВыбраннаяСсылка" id="13">
|
||||
<DataPath>~Список.Ссылка</DataPath>
|
||||
<ContextMenu name="ВыбраннаяСсылкаКонтекстноеМеню" id="14"/>
|
||||
<ExtendedTooltip name="ВыбраннаяСсылкаРасширеннаяПодсказка" id="15"/>
|
||||
</InputField>
|
||||
</ChildItems>
|
||||
<Attributes>
|
||||
<Attribute name="Список" id="16">
|
||||
<Type>
|
||||
<v8:Type>cfg:DynamicList</v8:Type>
|
||||
</Type>
|
||||
<MainAttribute>true</MainAttribute>
|
||||
</Attribute>
|
||||
</Attributes>
|
||||
</Form>
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Form xmlns="http://v8.1c.ru/8.3/xcf/logform" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="2.17">
|
||||
<Title>
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Тест</v8:content>
|
||||
</v8:item>
|
||||
</Title>
|
||||
<AutoCommandBar name="ФормаКоманднаяПанель" id="-1"/>
|
||||
<ChildItems>
|
||||
<InputField name="ПривязкаПоUUID" id="1">
|
||||
<DataPath>1/0:a917a122-f663-4c45-8de0-fd5104007de3</DataPath>
|
||||
<ContextMenu name="ПривязкаПоUUIDКонтекстноеМеню" id="2"/>
|
||||
<ExtendedTooltip name="ПривязкаПоUUIDРасширеннаяПодсказка" id="3"/>
|
||||
</InputField>
|
||||
<LabelField name="ЧисловаяПривязка" id="4">
|
||||
<DataPath>10</DataPath>
|
||||
<ContextMenu name="ЧисловаяПривязкаКонтекстноеМеню" id="5"/>
|
||||
<ExtendedTooltip name="ЧисловаяПривязкаРасширеннаяПодсказка" id="6"/>
|
||||
</LabelField>
|
||||
<InputField name="Нормальное" id="7">
|
||||
<DataPath>Объект.Наименование</DataPath>
|
||||
<ContextMenu name="НормальноеКонтекстноеМеню" id="8"/>
|
||||
<ExtendedTooltip name="НормальноеРасширеннаяПодсказка" id="9"/>
|
||||
</InputField>
|
||||
</ChildItems>
|
||||
<Attributes>
|
||||
<Attribute name="Объект" id="10">
|
||||
<Type>
|
||||
<v8:Type>cfg:DataProcessorObject.Opaque</v8:Type>
|
||||
</Type>
|
||||
<MainAttribute>true</MainAttribute>
|
||||
</Attribute>
|
||||
</Attributes>
|
||||
</Form>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "add-parameter @hidden @always: useRestriction+availableAsField+use=Always",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т",
|
||||
"fields": ["Поле: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "add-parameter",
|
||||
"value": "Контрагент: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка @hidden @always"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "add-parameter: ссылочный тип → xsi:type=dcscor:DesignTimeValue",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Счет ИЗ Регистр КАК Т",
|
||||
"fields": ["Счет: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "add-parameter",
|
||||
"value": "Контрагент: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "add-parameter с initial availableValue list (с запятой в кавычках)",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т",
|
||||
"fields": ["Поле: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "add-parameter",
|
||||
"value": "Округление: EnumRef.Округления = Окр1_00 availableValue=Перечисление.Округления.Окр1_00: 'руб., коп.', Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс. руб."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "add-total: 'X: X' — identity expression (без обёртки в Func)",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Проверка ИЗ Регистр КАК Т",
|
||||
"fields": ["Проверка: decimal(15,2)"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "add-total",
|
||||
"value": "Проверка: Проверка"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "clear-conditionalAppearance: очистить все правила оформления варианта",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т",
|
||||
"fields": ["Сумма: decimal(15,2)"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
},
|
||||
{
|
||||
"script": "skd-edit/scripts/skd-edit",
|
||||
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-conditionalAppearance", "-Value": "ЦветТекста = web:Red when Сумма < 0 ;; ЦветФона = web:LightGreen when Сумма > 1000" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "clear-conditionalAppearance",
|
||||
"value": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "modify-parameter availableValue: replace whole list (старый список удалён, новый добавлен)",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т",
|
||||
"fields": ["Поле: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
},
|
||||
{
|
||||
"script": "skd-edit/scripts/skd-edit",
|
||||
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-parameter", "-Value": "Округление: EnumRef.Округления = Окр1_00 availableValue=Перечисление.Округления.Окр1_00: ст1, Перечисление.Округления.Окр1: ст2, Перечисление.Округления.Окр1000: ст3" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "modify-parameter",
|
||||
"value": "Округление availableValue=Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс., Перечисление.Округления.Окр1000000: млн."
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,6 @@
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "modify-parameter",
|
||||
"value": "ПорядокОкругления denyIncompleteValues=true use=Always availableValue=Перечисление.Округления.Окр1_00 presentation=руб. коп ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1 presentation=руб. ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1000 presentation=тыс. руб"
|
||||
"value": "ПорядокОкругления denyIncompleteValues=true use=Always availableValue=Перечисление.Округления.Окр1_00: руб. коп, Перечисление.Округления.Окр1: руб., Перечисление.Округления.Окр1000: тыс. руб"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "modify-parameter @hidden @always: идемпотентность",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т",
|
||||
"fields": ["Поле: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
},
|
||||
{
|
||||
"script": "skd-edit/scripts/skd-edit",
|
||||
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-parameter", "-Value": "Контрагент: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка" }
|
||||
},
|
||||
{
|
||||
"script": "skd-edit/scripts/skd-edit",
|
||||
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "modify-parameter", "-Value": "Контрагент @hidden @always" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "modify-parameter",
|
||||
"value": "Контрагент @hidden @always"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "modify-parameter value=... обновляет существующий <value> (не дублирует)",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Счет ИЗ Регистр КАК Т",
|
||||
"fields": ["Счет: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
},
|
||||
{
|
||||
"script": "skd-edit/scripts/skd-edit",
|
||||
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-parameter", "-Value": "Контрагент: CatalogRef.Контрагенты = Справочник.Контрагенты.ПустаяСсылка" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "modify-parameter",
|
||||
"value": "Контрагент value=Справочник.Контрагенты.НашаОрганизация"
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,6 @@
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "modify-parameter",
|
||||
"value": "ПорядокОкругления use=Always ;; ПорядокОкругления denyIncompleteValues=true ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1_00 presentation=руб. коп ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1 presentation=руб."
|
||||
"value": "ПорядокОкругления use=Always ;; ПорядокОкругления denyIncompleteValues=true ;; ПорядокОкругления availableValue=Перечисление.Округления.Окр1_00: руб. коп, Перечисление.Округления.Окр1: руб."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "modify-structure меняет groupItems, сохраняет Selection",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Счет, Т.Сумма, Т.Валюта ИЗ Регистр КАК Т",
|
||||
"fields": ["Счет: string", "Сумма: decimal(15,2)", "Валюта: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
},
|
||||
{
|
||||
"script": "skd-edit/scripts/skd-edit",
|
||||
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "set-structure", "-Value": "Счет @name=ДанныеОтчета" }
|
||||
},
|
||||
{
|
||||
"script": "skd-edit/scripts/skd-edit",
|
||||
"args": { "-TemplatePath": "{workDir}/Template.xml", "-Operation": "add-selection", "-Value": "Счет @group=ДанныеОтчета ;; Сумма @group=ДанныеОтчета" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "modify-structure",
|
||||
"value": "Валюта @name=ДанныеОтчета"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "patch-query ;;-batch: сегменты триммятся по краям",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.СтароеИмя1, Т.СтароеИмя2 ИЗ Регистр КАК Т",
|
||||
"fields": ["СтароеИмя1: string", "СтароеИмя2: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "patch-query",
|
||||
"value": "СтароеИмя1 => НовоеИмя1 ;; СтароеИмя2 => НовоеИмя2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "patch-query @once: множественные вхождения — ошибка",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Поле1, Т.Поле2 ИЗ Регистр КАК Т ГДЕ Т.Поле1 = Т.Поле2",
|
||||
"fields": ["Поле1: string", "Поле2: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "patch-query",
|
||||
"value": "Поле1 => Новое @once"
|
||||
},
|
||||
"expectError": "expected 1 occurrence"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "patch-query @once: 1 вхождение — успешная замена",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.УникальноеИмя ИЗ Регистр КАК Т",
|
||||
"fields": ["УникальноеИмя: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "patch-query",
|
||||
"value": "УникальноеИмя => НовоеИмя @once"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "set-field-role: установить балансовую роль с уточнением",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т",
|
||||
"fields": ["Сумма: decimal(15,2)"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "set-field-role",
|
||||
"value": "Сумма @balance balanceGroupName=Сумма balanceType=OpeningBalance"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "set-field-role: пустой spec — снимает роль",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т",
|
||||
"fields": ["Сумма: decimal(15,2) @balance"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "set-field-role",
|
||||
"value": "Сумма"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "set-structure: запятая в shorthand → несколько GroupItemField",
|
||||
"preRun": [
|
||||
{
|
||||
"script": "skd-compile/scripts/skd-compile",
|
||||
"input": {
|
||||
"dataSets": [{
|
||||
"name": "Основной",
|
||||
"query": "ВЫБРАТЬ Т.Валюта, Т.Банк, Т.ИНН ИЗ Регистр КАК Т",
|
||||
"fields": ["Валюта: string", "Банк: string", "ИНН: string"]
|
||||
}]
|
||||
},
|
||||
"args": { "-DefinitionFile": "{inputFile}", "-OutputPath": "{workDir}/Template.xml" }
|
||||
}
|
||||
],
|
||||
"params": {
|
||||
"templatePath": "Template.xml",
|
||||
"operation": "set-structure",
|
||||
"value": "Валюта, Банк, ИНН @name=ДанныеОтчета"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Поле</dataPath>
|
||||
<field>Поле</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<parameter>
|
||||
<name>Контрагент</name>
|
||||
<valueType>
|
||||
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.Контрагенты</v8:Type>
|
||||
</valueType>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Справочник.Контрагенты.ПустаяСсылка</value>
|
||||
<useRestriction>true</useRestriction>
|
||||
<availableAsField>false</availableAsField>
|
||||
<use>Always</use>
|
||||
</parameter>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Счет</dataPath>
|
||||
<field>Счет</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Счет ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<parameter>
|
||||
<name>Контрагент</name>
|
||||
<valueType>
|
||||
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.Контрагенты</v8:Type>
|
||||
</valueType>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Справочник.Контрагенты.ПустаяСсылка</value>
|
||||
</parameter>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Поле</dataPath>
|
||||
<field>Поле</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<parameter>
|
||||
<name>Округление</name>
|
||||
<valueType>
|
||||
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:EnumRef.Округления</v8:Type>
|
||||
</valueType>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Окр1_00</value>
|
||||
<availableValue>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1_00</value>
|
||||
<presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>руб., коп.</v8:content>
|
||||
</v8:item>
|
||||
</presentation>
|
||||
</availableValue>
|
||||
<availableValue>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1</value>
|
||||
<presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>руб.</v8:content>
|
||||
</v8:item>
|
||||
</presentation>
|
||||
</availableValue>
|
||||
<availableValue>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1000</value>
|
||||
<presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>тыс. руб.</v8:content>
|
||||
</v8:item>
|
||||
</presentation>
|
||||
</availableValue>
|
||||
</parameter>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Проверка</dataPath>
|
||||
<field>Проверка</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:decimal</v8:Type>
|
||||
<v8:NumberQualifiers>
|
||||
<v8:Digits>15</v8:Digits>
|
||||
<v8:FractionDigits>2</v8:FractionDigits>
|
||||
<v8:AllowedSign>Any</v8:AllowedSign>
|
||||
</v8:NumberQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Проверка ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<totalField>
|
||||
<dataPath>Проверка</dataPath>
|
||||
<expression>Проверка</expression>
|
||||
</totalField>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Сумма</dataPath>
|
||||
<field>Сумма</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:decimal</v8:Type>
|
||||
<v8:NumberQualifiers>
|
||||
<v8:Digits>15</v8:Digits>
|
||||
<v8:FractionDigits>2</v8:FractionDigits>
|
||||
<v8:AllowedSign>Any</v8:AllowedSign>
|
||||
</v8:NumberQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:conditionalAppearance>
|
||||
</dcsset:conditionalAppearance>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Поле</dataPath>
|
||||
<field>Поле</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<parameter>
|
||||
<name>Округление</name>
|
||||
<valueType>
|
||||
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:EnumRef.Округления</v8:Type>
|
||||
</valueType>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Окр1_00</value>
|
||||
<availableValue>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1</value>
|
||||
<presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>руб.</v8:content>
|
||||
</v8:item>
|
||||
</presentation>
|
||||
</availableValue>
|
||||
<availableValue>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1000</value>
|
||||
<presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>тыс.</v8:content>
|
||||
</v8:item>
|
||||
</presentation>
|
||||
</availableValue>
|
||||
<availableValue>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Перечисление.Округления.Окр1000000</value>
|
||||
<presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>млн.</v8:content>
|
||||
</v8:item>
|
||||
</presentation>
|
||||
</availableValue>
|
||||
</parameter>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Поле</dataPath>
|
||||
<field>Поле</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Поле ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<parameter>
|
||||
<name>Контрагент</name>
|
||||
<valueType>
|
||||
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.Контрагенты</v8:Type>
|
||||
</valueType>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Справочник.Контрагенты.ПустаяСсылка</value>
|
||||
<useRestriction>true</useRestriction>
|
||||
<availableAsField>false</availableAsField>
|
||||
<use>Always</use>
|
||||
</parameter>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Счет</dataPath>
|
||||
<field>Счет</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Счет ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<parameter>
|
||||
<name>Контрагент</name>
|
||||
<valueType>
|
||||
<v8:Type xmlns:d5p1="http://v8.1c.ru/8.1/data/enterprise/current-config">d5p1:CatalogRef.Контрагенты</v8:Type>
|
||||
</valueType>
|
||||
<value xsi:type="dcscor:DesignTimeValue">Справочник.Контрагенты.НашаОрганизация</value>
|
||||
</parameter>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Счет</dataPath>
|
||||
<field>Счет</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Сумма</dataPath>
|
||||
<field>Сумма</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:decimal</v8:Type>
|
||||
<v8:NumberQualifiers>
|
||||
<v8:Digits>15</v8:Digits>
|
||||
<v8:FractionDigits>2</v8:FractionDigits>
|
||||
<v8:AllowedSign>Any</v8:AllowedSign>
|
||||
</v8:NumberQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Валюта</dataPath>
|
||||
<field>Валюта</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Счет, Т.Сумма, Т.Валюта ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:name>ДанныеОтчета</dcsset:name>
|
||||
<dcsset:groupItems>
|
||||
<dcsset:item xsi:type="dcsset:GroupItemField">
|
||||
<dcsset:field>Валюта</dcsset:field>
|
||||
<dcsset:groupType>Items</dcsset:groupType>
|
||||
<dcsset:periodAdditionType>None</dcsset:periodAdditionType>
|
||||
<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>
|
||||
<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>
|
||||
</dcsset:item>
|
||||
</dcsset:groupItems>
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemField">
|
||||
<dcsset:field>Счет</dcsset:field>
|
||||
</dcsset:item>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemField">
|
||||
<dcsset:field>Сумма</dcsset:field>
|
||||
</dcsset:item>
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>СтароеИмя1</dataPath>
|
||||
<field>СтароеИмя1</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>СтароеИмя2</dataPath>
|
||||
<field>СтароеИмя2</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.НовоеИмя1, Т.НовоеИмя2 ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>УникальноеИмя</dataPath>
|
||||
<field>УникальноеИмя</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.НовоеИмя ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Сумма</dataPath>
|
||||
<field>Сумма</field>
|
||||
<role>
|
||||
<dcscom:balance>true</dcscom:balance>
|
||||
<dcscom:balanceGroupName>Сумма</dcscom:balanceGroupName>
|
||||
<dcscom:balanceType>OpeningBalance</dcscom:balanceType>
|
||||
</role>
|
||||
<valueType>
|
||||
<v8:Type>xs:decimal</v8:Type>
|
||||
<v8:NumberQualifiers>
|
||||
<v8:Digits>15</v8:Digits>
|
||||
<v8:FractionDigits>2</v8:FractionDigits>
|
||||
<v8:AllowedSign>Any</v8:AllowedSign>
|
||||
</v8:NumberQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Сумма</dataPath>
|
||||
<field>Сумма</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:decimal</v8:Type>
|
||||
<v8:NumberQualifiers>
|
||||
<v8:Digits>15</v8:Digits>
|
||||
<v8:FractionDigits>2</v8:FractionDigits>
|
||||
<v8:AllowedSign>Any</v8:AllowedSign>
|
||||
</v8:NumberQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Сумма ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data-composition-system/schema" xmlns:dcscom="http://v8.1c.ru/8.1/data-composition-system/common" xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core" xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<dataSource>
|
||||
<name>ИсточникДанных1</name>
|
||||
<dataSourceType>Local</dataSourceType>
|
||||
</dataSource>
|
||||
<dataSet xsi:type="DataSetQuery">
|
||||
<name>Основной</name>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Валюта</dataPath>
|
||||
<field>Валюта</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>Банк</dataPath>
|
||||
<field>Банк</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<field xsi:type="DataSetFieldField">
|
||||
<dataPath>ИНН</dataPath>
|
||||
<field>ИНН</field>
|
||||
<valueType>
|
||||
<v8:Type>xs:string</v8:Type>
|
||||
<v8:StringQualifiers>
|
||||
<v8:Length>0</v8:Length>
|
||||
<v8:AllowedLength>Variable</v8:AllowedLength>
|
||||
</v8:StringQualifiers>
|
||||
</valueType>
|
||||
</field>
|
||||
<dataSource>ИсточникДанных1</dataSource>
|
||||
<query>ВЫБРАТЬ Т.Валюта, Т.Банк, Т.ИНН ИЗ Регистр КАК Т</query>
|
||||
</dataSet>
|
||||
<settingsVariant>
|
||||
<dcsset:name>Основной</dcsset:name>
|
||||
<dcsset:presentation xsi:type="v8:LocalStringType">
|
||||
<v8:item>
|
||||
<v8:lang>ru</v8:lang>
|
||||
<v8:content>Основной</v8:content>
|
||||
</v8:item>
|
||||
</dcsset:presentation>
|
||||
<dcsset:settings xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows">
|
||||
<dcsset:item xsi:type="dcsset:StructureItemGroup">
|
||||
<dcsset:name>ДанныеОтчета</dcsset:name>
|
||||
<dcsset:groupItems>
|
||||
<dcsset:item xsi:type="dcsset:GroupItemField">
|
||||
<dcsset:field>Валюта</dcsset:field>
|
||||
<dcsset:groupType>Items</dcsset:groupType>
|
||||
<dcsset:periodAdditionType>None</dcsset:periodAdditionType>
|
||||
<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>
|
||||
<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>
|
||||
</dcsset:item>
|
||||
<dcsset:item xsi:type="dcsset:GroupItemField">
|
||||
<dcsset:field>Банк</dcsset:field>
|
||||
<dcsset:groupType>Items</dcsset:groupType>
|
||||
<dcsset:periodAdditionType>None</dcsset:periodAdditionType>
|
||||
<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>
|
||||
<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>
|
||||
</dcsset:item>
|
||||
<dcsset:item xsi:type="dcsset:GroupItemField">
|
||||
<dcsset:field>ИНН</dcsset:field>
|
||||
<dcsset:groupType>Items</dcsset:groupType>
|
||||
<dcsset:periodAdditionType>None</dcsset:periodAdditionType>
|
||||
<dcsset:periodAdditionBegin xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionBegin>
|
||||
<dcsset:periodAdditionEnd xsi:type="xs:dateTime">0001-01-01T00:00:00</dcsset:periodAdditionEnd>
|
||||
</dcsset:item>
|
||||
</dcsset:groupItems>
|
||||
<dcsset:order>
|
||||
<dcsset:item xsi:type="dcsset:OrderItemAuto" />
|
||||
</dcsset:order>
|
||||
<dcsset:selection>
|
||||
<dcsset:item xsi:type="dcsset:SelectedItemAuto" />
|
||||
</dcsset:selection>
|
||||
</dcsset:item>
|
||||
<dcsset:selection>
|
||||
</dcsset:selection>
|
||||
</dcsset:settings>
|
||||
</settingsVariant>
|
||||
</DataCompositionSchema>
|
||||
@@ -0,0 +1,887 @@
|
||||
// build-webtest-config.test.mjs — Integration test: build synthetic configuration for web-test regression
|
||||
// Extends base-config with: diverse field types, hierarchical catalog, two-tab form,
|
||||
// second subsystem, full-rights role.
|
||||
// Steps: cf-init → meta-compile → form-add + form-compile → skd-compile
|
||||
// → subsystem-compile → role-compile → cf-validate
|
||||
|
||||
export const name = 'Сборка конфигурации для web-test';
|
||||
export const setup = 'none';
|
||||
export const cache = 'webtest-config';
|
||||
|
||||
export const steps = [
|
||||
// ── 1. Init empty configuration ──
|
||||
{
|
||||
name: 'cf-init: пустая конфигурация',
|
||||
script: 'cf-init/scripts/cf-init',
|
||||
args: { '-Name': 'ТестоваяВебКонфигурация', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'cf-validate/scripts/cf-validate', flag: '-ConfigPath' },
|
||||
},
|
||||
|
||||
// ── 2. Metadata objects ──
|
||||
|
||||
// Справочник Контрагенты — простой, для CRUD и ссылочных полей
|
||||
{
|
||||
name: 'meta-compile: Справочник Контрагенты',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Catalog', name: 'Контрагенты',
|
||||
codeLength: 9, descriptionLength: 100,
|
||||
attributes: [
|
||||
{ name: 'ИНН', type: 'String', length: 12 },
|
||||
{ name: 'Телефон', type: 'String', length: 20 },
|
||||
{ name: 'Адрес', type: 'String', length: 200 },
|
||||
{ name: 'КодКПП', type: 'String', length: 9 },
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Catalogs/Контрагенты' },
|
||||
},
|
||||
|
||||
// Справочник Организации — маленький список с быстрым выбором (selectValue dropdown)
|
||||
{
|
||||
name: 'meta-compile: Справочник Организации',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Catalog', name: 'Организации',
|
||||
codeLength: 9, descriptionLength: 100,
|
||||
quickChoice: true,
|
||||
attributes: [
|
||||
{ name: 'ИНН', type: 'String', length: 12 },
|
||||
{ name: 'КПП', type: 'String', length: 9 },
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Catalogs/Организации' },
|
||||
},
|
||||
|
||||
// Подчинённый каталог КонтактныеЛица — для теста getFormState.navigation (subordinate-nav)
|
||||
{
|
||||
name: 'meta-compile: Справочник КонтактныеЛица (подчинённый Контрагентам)',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Catalog', name: 'КонтактныеЛица',
|
||||
codeLength: 9, descriptionLength: 100,
|
||||
owners: ['Catalog.Контрагенты'],
|
||||
attributes: [
|
||||
{ name: 'Должность', type: 'String', length: 100 },
|
||||
{ name: 'Телефон', type: 'String', length: 20 },
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Catalogs/КонтактныеЛица' },
|
||||
},
|
||||
|
||||
// Справочник Номенклатура — иерархический, все типы полей
|
||||
{
|
||||
name: 'meta-compile: Справочник Номенклатура',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Catalog', name: 'Номенклатура',
|
||||
codeLength: 11, descriptionLength: 150,
|
||||
hierarchical: true,
|
||||
attributes: [
|
||||
{ name: 'Артикул', type: 'String', length: 25 },
|
||||
{ name: 'Цена', type: 'Number', length: 15, precision: 2 },
|
||||
{ name: 'Активен', type: 'Boolean' },
|
||||
{ name: 'ДатаПоступления', type: 'Date' },
|
||||
{ name: 'Комментарий', type: 'String' },
|
||||
{ name: 'ЕдиницаИзмерения', type: 'String', length: 10 },
|
||||
{ name: 'ВидНоменклатуры', type: 'EnumRef.ВидыНоменклатуры' },
|
||||
{ name: 'КатегорияЦены', type: 'EnumRef.КатегорииЦен' },
|
||||
{ name: 'СпособУчёта', type: 'EnumRef.СпособыУчёта' },
|
||||
],
|
||||
fillChecking: { 'Description': 'ShowError' },
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Catalogs/Номенклатура' },
|
||||
},
|
||||
|
||||
// Перечисление ВидыНоменклатуры
|
||||
{
|
||||
name: 'meta-compile: Перечисление ВидыНоменклатуры',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Enum', name: 'ВидыНоменклатуры',
|
||||
values: ['Товар', 'Услуга', 'Работа'],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Enums/ВидыНоменклатуры' },
|
||||
},
|
||||
|
||||
// Перечисление КатегорииЦен — для будущего radio-button теста (fillFields branch #3)
|
||||
{
|
||||
name: 'meta-compile: Перечисление КатегорииЦен',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Enum', name: 'КатегорииЦен',
|
||||
values: ['Розничная', 'Оптовая', 'Закупочная'],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Enums/КатегорииЦен' },
|
||||
},
|
||||
|
||||
// Перечисление СпособыУчёта — для radio с видом Tumbler (fillFields branch #3)
|
||||
{
|
||||
name: 'meta-compile: Перечисление СпособыУчёта',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Enum', name: 'СпособыУчёта',
|
||||
values: ['ПоСреднему', 'ФИФО'],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Enums/СпособыУчёта' },
|
||||
},
|
||||
|
||||
// Документ ПриходнаяНакладная — шапка + ТЧ
|
||||
{
|
||||
name: 'meta-compile: Документ ПриходнаяНакладная',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Document', name: 'ПриходнаяНакладная',
|
||||
attributes: [
|
||||
{ name: 'Организация', type: 'CatalogRef.Организации' },
|
||||
// choiceHistoryOnInput=DontUse: предотвращает выбор через историю в smoke-тестах
|
||||
// (04-selectvalue/direct-form проверяет open-form path; история обходит его).
|
||||
{ name: 'Контрагент', type: 'CatalogRef.Контрагенты', choiceHistoryOnInput: 'DontUse' },
|
||||
{ name: 'Склад', type: 'String', length: 50 },
|
||||
// Источник — составной тип (для 03-fillfields/composite).
|
||||
// Платформа покажет селектор типа в UI перед выбором значения.
|
||||
{ name: 'Источник', type: 'CatalogRef.Контрагенты + CatalogRef.Номенклатура + CatalogRef.Организации' },
|
||||
// Поставщик — обычная ссылка, но на форме элемент с textEdit:false
|
||||
// (для 03-fillfields/direct-edit-form). Ручной ввод запрещён,
|
||||
// выбор только через pick-кнопку → форма выбора.
|
||||
{ name: 'Поставщик', type: 'CatalogRef.Контрагенты' },
|
||||
// Менеджер — ссылка с дефолтным choiceHistoryOnInput=Auto (история включена,
|
||||
// для 04-selectvalue/show-all-form). После первого выбора платформа
|
||||
// запоминает значение и при повторном вводе показывает dropdown
|
||||
// с историей + кнопку «Показать все» → форма выбора.
|
||||
{ name: 'Менеджер', type: 'CatalogRef.Контрагенты' },
|
||||
{ name: 'Комментарий', type: 'String', length: 200 },
|
||||
],
|
||||
tabularSections: [{
|
||||
name: 'Товары',
|
||||
attributes: [
|
||||
{ name: 'Номенклатура', type: 'CatalogRef.Номенклатура' },
|
||||
{ name: 'Количество', type: 'Number', length: 15, precision: 3 },
|
||||
{ name: 'Цена', type: 'Number', length: 15, precision: 2 },
|
||||
{ name: 'Сумма', type: 'Number', length: 15, precision: 2 },
|
||||
{ name: 'Согласовано', type: 'Boolean' },
|
||||
// Источник — составной тип в ТЧ (для edit-dblclick через выбор типа)
|
||||
{ name: 'Источник', type: 'CatalogRef.Контрагенты + CatalogRef.Номенклатура + CatalogRef.Организации' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Documents/ПриходнаяНакладная' },
|
||||
},
|
||||
|
||||
// Регистр сведений КурсыВалют (Independent — без регистратора)
|
||||
{
|
||||
name: 'meta-compile: Регистр сведений КурсыВалют',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'InformationRegister', name: 'КурсыВалют',
|
||||
writeMode: 'Independent',
|
||||
dimensions: [
|
||||
{ name: 'Валюта', type: 'String', length: 10 },
|
||||
],
|
||||
resources: [
|
||||
{ name: 'Курс', type: 'Number', length: 10, precision: 4 },
|
||||
{ name: 'Кратность', type: 'Number', length: 10 },
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'InformationRegisters/КурсыВалют' },
|
||||
},
|
||||
|
||||
// Константа ОсновнаяВалюта
|
||||
{
|
||||
name: 'meta-compile: Константа ОсновнаяВалюта',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Constant', name: 'ОсновнаяВалюта',
|
||||
valueType: 'String', length: 10,
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Constants/ОсновнаяВалюта' },
|
||||
},
|
||||
|
||||
// Константа ДанныеЗаполнены — флаг первоначального заполнения фикстур
|
||||
{
|
||||
name: 'meta-compile: Константа ДанныеЗаполнены',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Constant', name: 'ДанныеЗаполнены',
|
||||
valueType: 'Boolean',
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Constants/ДанныеЗаполнены' },
|
||||
},
|
||||
|
||||
// Общий модуль ОбщиеФункции
|
||||
{
|
||||
name: 'meta-compile: Общий модуль ОбщиеФункции',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'CommonModule', name: 'ОбщиеФункции',
|
||||
server: true, serverCall: true, clientManagedApplication: false,
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'CommonModules/ОбщиеФункции' },
|
||||
},
|
||||
{
|
||||
name: 'writeFile: ОбщиеФункции Module.bsl',
|
||||
writeFile: 'CommonModules/ОбщиеФункции/Ext/Module.bsl',
|
||||
content: `Процедура ПоказатьСообщение() Экспорт
|
||||
\tСообщить("Тестовое сообщение");
|
||||
КонецПроцедуры
|
||||
|
||||
Процедура ВызватьТестовоеИсключение() Экспорт
|
||||
\tВызватьИсключение "Тестовое исключение";
|
||||
КонецПроцедуры
|
||||
|
||||
Процедура ЗаполнитьФикстурыЕслиНужно() Экспорт
|
||||
\tЕсли Константы.ДанныеЗаполнены.Получить() Тогда
|
||||
\t\tВозврат;
|
||||
\tКонецЕсли;
|
||||
\tНачатьТранзакцию();
|
||||
\tПопытка
|
||||
\t\tЗаполнитьОрганизации();
|
||||
\t\tЗаполнитьКонтрагентов();
|
||||
\t\tЗаполнитьНоменклатуру();
|
||||
\t\tЗаполнитьДокументы();
|
||||
\t\tКонстанты.ДанныеЗаполнены.Установить(Истина);
|
||||
\t\tЗафиксироватьТранзакцию();
|
||||
\tИсключение
|
||||
\t\tОтменитьТранзакцию();
|
||||
\t\tВызватьИсключение;
|
||||
\tКонецПопытки;
|
||||
КонецПроцедуры
|
||||
|
||||
Процедура ЗаполнитьОрганизации()
|
||||
\tСписок = Новый Массив;
|
||||
\tСписок.Добавить(Новый Структура("Имя,ИНН,КПП", "Альфа", "7800000001", "780000001"));
|
||||
\tСписок.Добавить(Новый Структура("Имя,ИНН,КПП", "Бета", "7800000002", "780000002"));
|
||||
\tДля Каждого Запись Из Список Цикл
|
||||
\t\tЭлемент = Справочники.Организации.СоздатьЭлемент();
|
||||
\t\tЭлемент.Наименование = Запись.Имя;
|
||||
\t\tЭлемент.ИНН = Запись.ИНН;
|
||||
\t\tЭлемент.КПП = Запись.КПП;
|
||||
\t\tЭлемент.Записать();
|
||||
\tКонецЦикла;
|
||||
КонецПроцедуры
|
||||
|
||||
Процедура ЗаполнитьКонтрагентов()
|
||||
\tСписок = Новый Массив;
|
||||
\tСписок.Добавить(Новый Структура("Имя,ИНН", "ООО Север", "7700000001"));
|
||||
\tСписок.Добавить(Новый Структура("Имя,ИНН", "ООО Юг", "7700000002"));
|
||||
\tСписок.Добавить(Новый Структура("Имя,ИНН", "ООО Восток", "7700000003"));
|
||||
\tСписок.Добавить(Новый Структура("Имя,ИНН", "АО Запад", "7700000004"));
|
||||
\tДля Каждого Запись Из Список Цикл
|
||||
\t\tЭлемент = Справочники.Контрагенты.СоздатьЭлемент();
|
||||
\t\tЭлемент.Наименование = Запись.Имя;
|
||||
\t\tЭлемент.ИНН = Запись.ИНН;
|
||||
\t\tЭлемент.Записать();
|
||||
\tКонецЦикла;
|
||||
КонецПроцедуры
|
||||
|
||||
Процедура ЗаполнитьНоменклатуру()
|
||||
\tГруппаТовары = СоздатьГруппуНоменклатуры("Товары");
|
||||
\tГруппаУслуги = СоздатьГруппуНоменклатуры("Услуги");
|
||||
\tДля Сч = 1 По 15 Цикл
|
||||
\t\tЭлемент = Справочники.Номенклатура.СоздатьЭлемент();
|
||||
\t\tЭлемент.Родитель = ГруппаТовары;
|
||||
\t\tЭлемент.Наименование = "Товар " + Формат(Сч, "ЧЦ=2; ЧВН=");
|
||||
\t\tЭлемент.Артикул = "T" + Формат(Сч, "ЧЦ=4; ЧВН=");
|
||||
\t\tЭлемент.Цена = 100 * Сч;
|
||||
\t\tЭлемент.Активен = Истина;
|
||||
\t\tЭлемент.ВидНоменклатуры = Перечисления.ВидыНоменклатуры.Товар;
|
||||
\t\tЭлемент.Записать();
|
||||
\tКонецЦикла;
|
||||
\tДля Сч = 1 По 10 Цикл
|
||||
\t\tЭлемент = Справочники.Номенклатура.СоздатьЭлемент();
|
||||
\t\tЭлемент.Родитель = ГруппаУслуги;
|
||||
\t\tЭлемент.Наименование = "Услуга " + Формат(Сч, "ЧЦ=2; ЧВН=");
|
||||
\t\tЭлемент.Артикул = "U" + Формат(Сч, "ЧЦ=4; ЧВН=");
|
||||
\t\tЭлемент.Цена = 500 * Сч;
|
||||
\t\tЭлемент.Активен = Истина;
|
||||
\t\tЭлемент.ВидНоменклатуры = Перечисления.ВидыНоменклатуры.Услуга;
|
||||
\t\tЭлемент.Записать();
|
||||
\tКонецЦикла;
|
||||
КонецПроцедуры
|
||||
|
||||
Функция СоздатьГруппуНоменклатуры(Имя)
|
||||
\tГруппа = Справочники.Номенклатура.СоздатьГруппу();
|
||||
\tГруппа.Наименование = Имя;
|
||||
\tГруппа.Записать();
|
||||
\tВозврат Группа.Ссылка;
|
||||
КонецФункции
|
||||
|
||||
Процедура ЗаполнитьДокументы()
|
||||
\tЗапросК = Новый Запрос("ВЫБРАТЬ ПЕРВЫЕ 5 Контрагенты.Ссылка КАК Контрагент ИЗ Справочник.Контрагенты КАК Контрагенты");
|
||||
\tКонтрагенты = ЗапросК.Выполнить().Выгрузить().ВыгрузитьКолонку("Контрагент");
|
||||
\tЗапросН = Новый Запрос("ВЫБРАТЬ ПЕРВЫЕ 10 Номенклатура.Ссылка КАК Номенклатура ИЗ Справочник.Номенклатура КАК Номенклатура ГДЕ НЕ Номенклатура.ЭтоГруппа");
|
||||
\tНоменклатура = ЗапросН.Выполнить().Выгрузить().ВыгрузитьКолонку("Номенклатура");
|
||||
\tЕсли Контрагенты.Количество() = 0 Или Номенклатура.Количество() = 0 Тогда
|
||||
\t\tВозврат;
|
||||
\tКонецЕсли;
|
||||
\tЗапросО = Новый Запрос("ВЫБРАТЬ ПЕРВЫЕ 1 Организации.Ссылка КАК Организация ИЗ Справочник.Организации КАК Организации");
|
||||
\tВыборкаО = ЗапросО.Выполнить().Выбрать();
|
||||
\tОрганизация = Неопределено;
|
||||
\tЕсли ВыборкаО.Следующий() Тогда
|
||||
\t\tОрганизация = ВыборкаО.Организация;
|
||||
\tКонецЕсли;
|
||||
\tДля Сч = 1 По 3 Цикл
|
||||
\t\tДок = Документы.ПриходнаяНакладная.СоздатьДокумент();
|
||||
\t\tДок.Дата = ТекущаяДата();
|
||||
\t\tДок.Организация = Организация;
|
||||
\t\tДок.Контрагент = Контрагенты[(Сч - 1) % Контрагенты.Количество()];
|
||||
\t\tДок.Склад = "Основной";
|
||||
\t\tДля Поз = 1 По 3 Цикл
|
||||
\t\t\tСтрока = Док.Товары.Добавить();
|
||||
\t\t\tСтрока.Номенклатура = Номенклатура[(Сч * Поз) % Номенклатура.Количество()];
|
||||
\t\t\tСтрока.Количество = Поз * 10;
|
||||
\t\t\tСтрока.Цена = Поз * 100;
|
||||
\t\t\tСтрока.Сумма = Строка.Количество * Строка.Цена;
|
||||
\t\tКонецЦикла;
|
||||
\t\tДок.Записать(РежимЗаписиДокумента.Запись);
|
||||
\tКонецЦикла;
|
||||
КонецПроцедуры
|
||||
`,
|
||||
},
|
||||
|
||||
// ManagedApplicationModule — вызывает заполнение фикстур при первом запуске
|
||||
{
|
||||
name: 'writeFile: ManagedApplicationModule.bsl',
|
||||
writeFile: 'Ext/ManagedApplicationModule.bsl',
|
||||
content: `&НаКлиенте
|
||||
Процедура ПриНачалеРаботыСистемы()
|
||||
\tОбщиеФункции.ЗаполнитьФикстурыЕслиНужно();
|
||||
КонецПроцедуры
|
||||
`,
|
||||
},
|
||||
|
||||
// Раскладка панелей (Ext/ClientApplicationInterface.xml) теперь создаётся
|
||||
// самим cf-init с ERP-дефолтом — отдельная запись больше не нужна.
|
||||
|
||||
// Обработка ТестовыеОшибки — для тестов errors balloon/messages/modal (10-validation)
|
||||
{
|
||||
name: 'meta-compile: Обработка ТестовыеОшибки',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'DataProcessor', name: 'ТестовыеОшибки',
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'DataProcessors/ТестовыеОшибки' },
|
||||
},
|
||||
|
||||
// Обработка ДеревоНоменклатуры — реквизит формы ДеревоЗначений с данными
|
||||
// справочника Номенклатура для тестов tree-grid (05-table/direct-edit-form,
|
||||
// 08-hierarchy/tree-edit).
|
||||
{
|
||||
name: 'meta-compile: Обработка ДеревоНоменклатуры',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'DataProcessor', name: 'ДеревоНоменклатуры',
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'DataProcessors/ДеревоНоменклатуры' },
|
||||
},
|
||||
|
||||
// Отчёт ОстаткиТоваров
|
||||
{
|
||||
name: 'meta-compile: Отчёт ОстаткиТоваров',
|
||||
script: 'meta-compile/scripts/meta-compile',
|
||||
input: {
|
||||
type: 'Report', name: 'ОстаткиТоваров',
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'meta-validate/scripts/meta-validate', flag: '-ObjectPath', path: 'Reports/ОстаткиТоваров' },
|
||||
},
|
||||
|
||||
// ── 3. Forms ──
|
||||
|
||||
// Форма элемента Контрагенты — простая
|
||||
{
|
||||
name: 'form-add: Форма элемента Контрагенты',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/Catalogs/Контрагенты.xml', '-FormName': 'ФормаЭлемента' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма элемента Контрагенты',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Контрагент',
|
||||
attributes: [
|
||||
{ name: 'Объект', type: 'CatalogObject.Контрагенты', main: true },
|
||||
],
|
||||
elements: [
|
||||
{ input: 'Наименование', path: 'Объект.Description', title: 'Наименование' },
|
||||
{ input: 'ИНН', path: 'Объект.ИНН', title: 'ИНН' },
|
||||
{ input: 'Телефон', path: 'Объект.Телефон', title: 'Телефон' },
|
||||
{ input: 'Адрес', path: 'Объект.Адрес', title: 'Адрес' },
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Контрагенты/Forms/ФормаЭлемента/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Контрагенты/Forms/ФормаЭлемента/Ext/Form.xml' },
|
||||
},
|
||||
|
||||
// Форма элемента КонтактныеЛица + список — для подчинённого каталога
|
||||
{
|
||||
name: 'form-add: Форма элемента КонтактныеЛица',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/Catalogs/КонтактныеЛица.xml', '-FormName': 'ФормаЭлемента' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма элемента КонтактныеЛица',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Контактное лицо',
|
||||
attributes: [
|
||||
{ name: 'Объект', type: 'CatalogObject.КонтактныеЛица', main: true },
|
||||
],
|
||||
elements: [
|
||||
{ input: 'Владелец', path: 'Объект.Owner', title: 'Контрагент' },
|
||||
{ input: 'Наименование', path: 'Объект.Description', title: 'ФИО' },
|
||||
{ input: 'Должность', path: 'Объект.Должность', title: 'Должность' },
|
||||
{ input: 'Телефон', path: 'Объект.Телефон', title: 'Телефон' },
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/КонтактныеЛица/Forms/ФормаЭлемента/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/КонтактныеЛица/Forms/ФормаЭлемента/Ext/Form.xml' },
|
||||
},
|
||||
{
|
||||
name: 'form-add: Форма списка КонтактныеЛица',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/Catalogs/КонтактныеЛица.xml', '-FormName': 'ФормаСписка', '-Purpose': 'List' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма списка КонтактныеЛица',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Контактные лица',
|
||||
attributes: [
|
||||
{ name: 'Список', type: 'DynamicList', main: true,
|
||||
settings: { mainTable: 'Catalog.КонтактныеЛица', dynamicDataRead: true } },
|
||||
],
|
||||
elements: [
|
||||
{ table: 'Список', path: 'Список', columns: [
|
||||
{ input: 'Description', path: 'Список.Description', title: 'ФИО' },
|
||||
{ input: 'Должность', path: 'Список.Должность', title: 'Должность' },
|
||||
{ input: 'Телефон', path: 'Список.Телефон', title: 'Телефон' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/КонтактныеЛица/Forms/ФормаСписка/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/КонтактныеЛица/Forms/ФормаСписка/Ext/Form.xml' },
|
||||
},
|
||||
|
||||
// Форма списка Контрагенты — для filterList тестов. КодКПП НЕ выводим
|
||||
// в форму — это покрывает FieldSelector DLB ветку (filterList #5)
|
||||
{
|
||||
name: 'form-add: Форма списка Контрагенты',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/Catalogs/Контрагенты.xml', '-FormName': 'ФормаСписка', '-Purpose': 'List' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма списка Контрагенты',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Контрагенты',
|
||||
attributes: [
|
||||
{ name: 'Список', type: 'DynamicList', main: true,
|
||||
settings: { mainTable: 'Catalog.Контрагенты', dynamicDataRead: true } },
|
||||
],
|
||||
elements: [
|
||||
{ table: 'Список', path: 'Список', columns: [
|
||||
{ input: 'Code', path: 'Список.Code', title: 'Код' },
|
||||
{ input: 'Description', path: 'Список.Description', title: 'Наименование' },
|
||||
{ input: 'ИНН', path: 'Список.ИНН', title: 'ИНН' },
|
||||
{ input: 'Телефон', path: 'Список.Телефон', title: 'Телефон' },
|
||||
{ input: 'Адрес', path: 'Список.Адрес', title: 'Адрес' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Контрагенты/Forms/ФормаСписка/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Контрагенты/Forms/ФормаСписка/Ext/Form.xml' },
|
||||
},
|
||||
|
||||
// Форма элемента Номенклатура — 2 вкладки, все типы полей
|
||||
{
|
||||
name: 'form-add: Форма элемента Номенклатура',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/Catalogs/Номенклатура.xml', '-FormName': 'ФормаЭлемента' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма элемента Номенклатура (2 вкладки)',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Номенклатура',
|
||||
attributes: [
|
||||
{ name: 'Объект', type: 'CatalogObject.Номенклатура', main: true },
|
||||
],
|
||||
elements: [
|
||||
{ pages: 'Страницы', pagesRepresentation: 'TabsOnTop', children: [
|
||||
{ page: 'Основное', title: 'Основное', children: [
|
||||
{ input: 'Наименование', path: 'Объект.Description', title: 'Наименование' },
|
||||
{ input: 'Артикул', path: 'Объект.Артикул', title: 'Артикул' },
|
||||
{ input: 'ВидНоменклатуры', path: 'Объект.ВидНоменклатуры', title: 'Вид номенклатуры' },
|
||||
{ input: 'Цена', path: 'Объект.Цена', title: 'Цена' },
|
||||
{ radio: 'КатегорияЦены', path: 'Объект.КатегорияЦены',
|
||||
title: 'Категория цены',
|
||||
radioButtonType: 'RadioButtons',
|
||||
titleLocation: 'Top',
|
||||
choiceList: [
|
||||
{ value: 'Enum.КатегорииЦен.EnumValue.Розничная', presentation: 'Розничная' },
|
||||
{ value: 'Enum.КатегорииЦен.EnumValue.Оптовая', presentation: 'Оптовая' },
|
||||
{ value: 'Enum.КатегорииЦен.EnumValue.Закупочная', presentation: 'Закупочная' },
|
||||
],
|
||||
},
|
||||
{ radio: 'СпособУчёта', path: 'Объект.СпособУчёта',
|
||||
title: 'Способ учёта',
|
||||
radioButtonType: 'Tumbler',
|
||||
titleLocation: 'Top',
|
||||
choiceList: [
|
||||
{ value: 'Enum.СпособыУчёта.EnumValue.ПоСреднему', presentation: 'По среднему' },
|
||||
{ value: 'Enum.СпособыУчёта.EnumValue.ФИФО', presentation: 'ФИФО' },
|
||||
],
|
||||
},
|
||||
{ check: 'Активен', path: 'Объект.Активен', title: 'Активен' },
|
||||
{ input: 'ДатаПоступления', path: 'Объект.ДатаПоступления', title: 'Дата поступления' },
|
||||
]},
|
||||
{ page: 'Дополнительно', title: 'Дополнительно', children: [
|
||||
{ input: 'ЕдиницаИзмерения', path: 'Объект.ЕдиницаИзмерения', title: 'Единица измерения' },
|
||||
{ input: 'Комментарий', path: 'Объект.Комментарий', title: 'Комментарий' },
|
||||
]},
|
||||
]},
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Номенклатура/Forms/ФормаЭлемента/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Номенклатура/Forms/ФормаЭлемента/Ext/Form.xml' },
|
||||
},
|
||||
|
||||
// Форма списка Номенклатура — с колонкой ДатаПоступления для filterList #6 (date pattern)
|
||||
{
|
||||
name: 'form-add: Форма списка Номенклатура',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/Catalogs/Номенклатура.xml', '-FormName': 'ФормаСписка', '-Purpose': 'List' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма списка Номенклатура',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Номенклатура',
|
||||
attributes: [
|
||||
{ name: 'Список', type: 'DynamicList', main: true,
|
||||
settings: { mainTable: 'Catalog.Номенклатура', dynamicDataRead: true } },
|
||||
],
|
||||
elements: [
|
||||
{ table: 'Список', path: 'Список', columns: [
|
||||
{ input: 'Code', path: 'Список.Code', title: 'Код' },
|
||||
{ input: 'Description', path: 'Список.Description', title: 'Наименование' },
|
||||
{ input: 'Артикул', path: 'Список.Артикул', title: 'Артикул' },
|
||||
{ input: 'ВидНоменклатуры', path: 'Список.ВидНоменклатуры', title: 'Вид номенклатуры' },
|
||||
{ input: 'ДатаПоступления', path: 'Список.ДатаПоступления', title: 'Дата поступления' },
|
||||
{ input: 'Цена', path: 'Список.Цена', title: 'Цена' },
|
||||
{ check: 'Активен', path: 'Список.Активен', title: 'Активен' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Catalogs/Номенклатура/Forms/ФормаСписка/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Catalogs/Номенклатура/Forms/ФормаСписка/Ext/Form.xml' },
|
||||
},
|
||||
|
||||
// Форма документа ПриходнаяНакладная
|
||||
{
|
||||
name: 'form-add: Форма документа ПриходнаяНакладная',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/Documents/ПриходнаяНакладная.xml', '-FormName': 'ФормаДокумента' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма документа ПриходнаяНакладная',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Приходная накладная',
|
||||
attributes: [
|
||||
{ name: 'Объект', type: 'DocumentObject.ПриходнаяНакладная', main: true },
|
||||
],
|
||||
elements: [
|
||||
{ input: 'Организация', path: 'Объект.Организация', title: 'Организация' },
|
||||
{ input: 'Контрагент', path: 'Объект.Контрагент', title: 'Контрагент' },
|
||||
{ input: 'Склад', path: 'Объект.Склад', title: 'Склад' },
|
||||
{ input: 'Источник', path: 'Объект.Источник', title: 'Источник' },
|
||||
// textEdit:false — ручной ввод запрещён, только pick → форма выбора
|
||||
{ input: 'Поставщик', path: 'Объект.Поставщик', title: 'Поставщик', textEdit: false },
|
||||
{ input: 'Менеджер', path: 'Объект.Менеджер', title: 'Менеджер' },
|
||||
{ input: 'Комментарий', path: 'Объект.Комментарий', title: 'Комментарий' },
|
||||
{ table: 'Товары', path: 'Объект.Товары', title: 'Товары', changeRowSet: true, columns: [
|
||||
{ input: 'Номенклатура', path: 'Объект.Товары.Номенклатура', title: 'Номенклатура' },
|
||||
{ input: 'Количество', path: 'Объект.Товары.Количество', title: 'Количество' },
|
||||
{ input: 'Цена', path: 'Объект.Товары.Цена', title: 'Цена' },
|
||||
{ input: 'Сумма', path: 'Объект.Товары.Сумма', title: 'Сумма' },
|
||||
{ check: 'Согласовано', path: 'Объект.Товары.Согласовано', title: 'Согласовано' },
|
||||
// Имя элемента отличается от Источник (в шапке) — иначе ContextMenu
|
||||
// companion-имена дублируются в одной форме. form-compile использует
|
||||
// имя элемента, не путь, для генерации companion-имён.
|
||||
{ input: 'ИсточникТЧ', path: 'Объект.Товары.Источник', title: 'Источник' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Documents/ПриходнаяНакладная/Forms/ФормаДокумента/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Documents/ПриходнаяНакладная/Forms/ФормаДокумента/Ext/Form.xml' },
|
||||
},
|
||||
|
||||
// Форма списка ПриходнаяНакладная — с колонкой Контрагент для filterList #7 (reference pattern)
|
||||
{
|
||||
name: 'form-add: Форма списка ПриходнаяНакладная',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/Documents/ПриходнаяНакладная.xml', '-FormName': 'ФормаСписка', '-Purpose': 'List' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма списка ПриходнаяНакладная',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Приходные накладные',
|
||||
attributes: [
|
||||
{ name: 'Список', type: 'DynamicList', main: true,
|
||||
settings: { mainTable: 'Document.ПриходнаяНакладная', dynamicDataRead: true } },
|
||||
],
|
||||
elements: [
|
||||
{ table: 'Список', path: 'Список', columns: [
|
||||
{ input: 'Date', path: 'Список.Date', title: 'Дата' },
|
||||
{ input: 'Number', path: 'Список.Number', title: 'Номер' },
|
||||
{ input: 'Контрагент', path: 'Список.Контрагент', title: 'Контрагент' },
|
||||
{ input: 'Posted', path: 'Список.Posted', title: 'Проведён' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/Documents/ПриходнаяНакладная/Forms/ФормаСписка/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'Documents/ПриходнаяНакладная/Forms/ФормаСписка/Ext/Form.xml' },
|
||||
},
|
||||
|
||||
// Форма обработки ТестовыеОшибки — кнопки вызова процедур ОбщиеФункции
|
||||
{
|
||||
name: 'form-add: Форма обработки ТестовыеОшибки',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/DataProcessors/ТестовыеОшибки.xml', '-FormName': 'ФормаОбработки' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма обработки ТестовыеОшибки',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Тестовые ошибки',
|
||||
attributes: [
|
||||
{ name: 'Объект', type: 'DataProcessorObject.ТестовыеОшибки', main: true },
|
||||
],
|
||||
elements: [
|
||||
{ button: 'ПоказатьСообщение', command: 'ПоказатьСообщение', title: 'Показать сообщение' },
|
||||
{ button: 'ВызватьИсключение', command: 'ВызватьИсключениеКоманда', title: 'Вызвать исключение' },
|
||||
],
|
||||
commands: [
|
||||
{ name: 'ПоказатьСообщение', action: 'ПоказатьСообщение' },
|
||||
{ name: 'ВызватьИсключениеКоманда', action: 'ВызватьИсключениеКоманда' },
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/DataProcessors/ТестовыеОшибки/Forms/ФормаОбработки/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'DataProcessors/ТестовыеОшибки/Forms/ФормаОбработки/Ext/Form.xml' },
|
||||
},
|
||||
{
|
||||
name: 'writeFile: ТестовыеОшибки form Module.bsl',
|
||||
writeFile: 'DataProcessors/ТестовыеОшибки/Forms/ФормаОбработки/Ext/Form/Module.bsl',
|
||||
content: `&НаКлиенте
|
||||
Процедура ПоказатьСообщение(Команда)
|
||||
\tПоказатьСообщениеНаСервере();
|
||||
КонецПроцедуры
|
||||
|
||||
&НаСервере
|
||||
Процедура ПоказатьСообщениеНаСервере()
|
||||
\tОбщиеФункции.ПоказатьСообщение();
|
||||
КонецПроцедуры
|
||||
|
||||
&НаКлиенте
|
||||
Процедура ВызватьИсключениеКоманда(Команда)
|
||||
\tВызватьИсключениеНаСервере();
|
||||
КонецПроцедуры
|
||||
|
||||
&НаСервере
|
||||
Процедура ВызватьИсключениеНаСервере()
|
||||
\tОбщиеФункции.ВызватьТестовоеИсключение();
|
||||
КонецПроцедуры
|
||||
`,
|
||||
},
|
||||
|
||||
// Форма обработки ДеревоНоменклатуры — tree-grid с двумя колонками
|
||||
{
|
||||
name: 'form-add: Форма обработки ДеревоНоменклатуры',
|
||||
script: 'form-add/scripts/form-add',
|
||||
args: { '-ObjectPath': '{workDir}/DataProcessors/ДеревоНоменклатуры.xml', '-FormName': 'ФормаОбработки' },
|
||||
},
|
||||
{
|
||||
name: 'form-compile: Форма обработки ДеревоНоменклатуры',
|
||||
script: 'form-compile/scripts/form-compile',
|
||||
input: {
|
||||
title: 'Дерево номенклатуры',
|
||||
events: { OnCreateAtServer: 'ПриСозданииНаСервере' },
|
||||
attributes: [
|
||||
{ name: 'Объект', type: 'DataProcessorObject.ДеревоНоменклатуры', main: true },
|
||||
{ name: 'Дерево', type: 'ValueTree', columns: [
|
||||
{ name: 'Номенклатура', type: 'CatalogRef.Номенклатура', title: 'Номенклатура' },
|
||||
{ name: 'Цена', type: 'Number(15,2)', title: 'Цена' },
|
||||
]},
|
||||
],
|
||||
elements: [
|
||||
{ table: 'Дерево', path: 'Дерево', initialTreeView: 'ExpandTopLevel', changeRowSet: true, columns: [
|
||||
{ input: 'Номенклатура', path: 'Дерево.Номенклатура', readOnly: true, title: 'Номенклатура' },
|
||||
{ input: 'Цена', path: 'Дерево.Цена', title: 'Цена' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputPath': '{workDir}/DataProcessors/ДеревоНоменклатуры/Forms/ФормаОбработки/Ext/Form.xml' },
|
||||
validate: { script: 'form-validate/scripts/form-validate', flag: '-FormPath', path: 'DataProcessors/ДеревоНоменклатуры/Forms/ФормаОбработки/Ext/Form.xml' },
|
||||
},
|
||||
{
|
||||
name: 'writeFile: ДеревоНоменклатуры form Module.bsl',
|
||||
writeFile: 'DataProcessors/ДеревоНоменклатуры/Forms/ФормаОбработки/Ext/Form/Module.bsl',
|
||||
content: `&НаСервере
|
||||
Процедура ПриСозданииНаСервере(Отказ, СтандартнаяОбработка)
|
||||
\tЗаполнитьУровень(Дерево.ПолучитьЭлементы(), Справочники.Номенклатура.ПустаяСсылка());
|
||||
КонецПроцедуры
|
||||
|
||||
&НаСервере
|
||||
Процедура ЗаполнитьУровень(КоллекцияЭлементов, Родитель)
|
||||
\tЗапрос = Новый Запрос;
|
||||
\tЗапрос.Текст =
|
||||
\t\t"ВЫБРАТЬ
|
||||
\t\t|\tСсылка, ЭтоГруппа, Цена, Наименование
|
||||
\t\t|ИЗ
|
||||
\t\t|\tСправочник.Номенклатура
|
||||
\t\t|ГДЕ
|
||||
\t\t|\tРодитель = &Родитель
|
||||
\t\t|УПОРЯДОЧИТЬ ПО
|
||||
\t\t|\tЭтоГруппа УБЫВ, Наименование";
|
||||
\tЗапрос.УстановитьПараметр("Родитель", Родитель);
|
||||
\tВыборка = Запрос.Выполнить().Выбрать();
|
||||
\tПока Выборка.Следующий() Цикл
|
||||
\t\tНовыйУзел = КоллекцияЭлементов.Добавить();
|
||||
\t\tНовыйУзел.Номенклатура = Выборка.Ссылка;
|
||||
\t\tНовыйУзел.Цена = Выборка.Цена;
|
||||
\t\tЕсли Выборка.ЭтоГруппа Тогда
|
||||
\t\t\tЗаполнитьУровень(НовыйУзел.ПолучитьЭлементы(), Выборка.Ссылка);
|
||||
\t\tКонецЕсли;
|
||||
\tКонецЦикла;
|
||||
КонецПроцедуры
|
||||
`,
|
||||
},
|
||||
|
||||
// ── 4. DCS for report ──
|
||||
// Сначала добавляем макет ОсновнаяСхемаКомпоновкиДанных к отчёту (регистрируется
|
||||
// в Reports/ОстаткиТоваров.xml + автоматически выставляется MainDataCompositionSchema),
|
||||
// затем skd-compile наполняет его содержимым.
|
||||
{
|
||||
name: 'template-add: ОсновнаяСхемаКомпоновкиДанных к отчёту ОстаткиТоваров',
|
||||
script: 'template-add/scripts/add-template',
|
||||
args: {
|
||||
'-ObjectName': 'ОстаткиТоваров',
|
||||
'-TemplateName': 'ОсновнаяСхемаКомпоновкиДанных',
|
||||
'-TemplateType': 'DataCompositionSchema',
|
||||
'-SrcDir': '{workDir}/Reports',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'skd-compile: Схема отчёта ОстаткиТоваров',
|
||||
script: 'skd-compile/scripts/skd-compile',
|
||||
input: {
|
||||
dataSets: [{
|
||||
name: 'НаборДанных',
|
||||
query: 'ВЫБРАТЬ\n\tТовары.Ссылка КАК Документ,\n\tТовары.Номенклатура КАК Номенклатура,\n\tТовары.Количество КАК Количество,\n\tТовары.Цена КАК Цена,\n\tТовары.Сумма КАК Сумма\nИЗ\n\tДокумент.ПриходнаяНакладная.Товары КАК Товары',
|
||||
fields: [
|
||||
{ field: 'Документ', title: 'Документ', type: 'DocumentRef.ПриходнаяНакладная' },
|
||||
{ field: 'Номенклатура', title: 'Номенклатура', type: 'CatalogRef.Номенклатура' },
|
||||
{ field: 'Количество', title: 'Количество', type: 'decimal(15,3)' },
|
||||
{ field: 'Цена', title: 'Цена', type: 'decimal(15,2)' },
|
||||
{ field: 'Сумма', title: 'Сумма', type: 'decimal(15,2)' },
|
||||
],
|
||||
}],
|
||||
totalFields: ['Количество: Сумма', 'Сумма: Сумма'],
|
||||
settingsVariants: [{
|
||||
name: 'Основной',
|
||||
title: 'Остатки товаров',
|
||||
settings: {
|
||||
selection: ['Номенклатура', 'Количество', 'Сумма', 'Auto'],
|
||||
filter: ['Номенклатура = _ @off @user @quickAccess'],
|
||||
structure: 'Номенклатура > details',
|
||||
},
|
||||
}],
|
||||
},
|
||||
args: { '-DefinitionFile': '{inputFile}', '-OutputPath': '{workDir}/Reports/ОстаткиТоваров/Templates/ОсновнаяСхемаКомпоновкиДанных/Ext/Template.xml' },
|
||||
validate: { script: 'skd-validate/scripts/skd-validate', flag: '-TemplatePath', path: 'Reports/ОстаткиТоваров/Templates/ОсновнаяСхемаКомпоновкиДанных/Ext/Template.xml' },
|
||||
},
|
||||
|
||||
// ── 5. Subsystems ──
|
||||
{
|
||||
name: 'subsystem-compile: Подсистема Склад',
|
||||
script: 'subsystem-compile/scripts/subsystem-compile',
|
||||
input: {
|
||||
name: 'Склад',
|
||||
synonym: 'Склад',
|
||||
content: [
|
||||
'Catalog.Организации',
|
||||
'Catalog.Контрагенты',
|
||||
'Catalog.КонтактныеЛица',
|
||||
'Catalog.Номенклатура',
|
||||
'Enum.ВидыНоменклатуры',
|
||||
'Enum.КатегорииЦен',
|
||||
'Enum.СпособыУчёта',
|
||||
'Document.ПриходнаяНакладная',
|
||||
'Report.ОстаткиТоваров',
|
||||
],
|
||||
},
|
||||
args: { '-DefinitionFile': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'subsystem-validate/scripts/subsystem-validate', flag: '-SubsystemPath', path: 'Subsystems/Склад' },
|
||||
},
|
||||
{
|
||||
name: 'subsystem-compile: Подсистема Администрирование',
|
||||
script: 'subsystem-compile/scripts/subsystem-compile',
|
||||
input: {
|
||||
name: 'Администрирование',
|
||||
synonym: 'Администрирование',
|
||||
content: [
|
||||
'InformationRegister.КурсыВалют',
|
||||
'Constant.ОсновнаяВалюта',
|
||||
'DataProcessor.ТестовыеОшибки',
|
||||
'DataProcessor.ДеревоНоменклатуры',
|
||||
],
|
||||
},
|
||||
args: { '-DefinitionFile': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'subsystem-validate/scripts/subsystem-validate', flag: '-SubsystemPath', path: 'Subsystems/Администрирование' },
|
||||
},
|
||||
|
||||
// ── 6. Role with full rights ──
|
||||
{
|
||||
name: 'role-compile: Роль Администратор',
|
||||
script: 'role-compile/scripts/role-compile',
|
||||
input: {
|
||||
name: 'Администратор',
|
||||
objects: [
|
||||
'Catalog.Организации: Read View Add Update Delete',
|
||||
'Catalog.Контрагенты: Read View Add Update Delete',
|
||||
'Catalog.КонтактныеЛица: Read View Add Update Delete',
|
||||
'Catalog.Номенклатура: Read View Add Update Delete',
|
||||
'Document.ПриходнаяНакладная: Read View Add Update Delete Posting UnPosting',
|
||||
'InformationRegister.КурсыВалют: Read View Add Update Delete',
|
||||
'Report.ОстаткиТоваров: Use View',
|
||||
'DataProcessor.ДеревоНоменклатуры: Use View',
|
||||
],
|
||||
},
|
||||
args: { '-JsonPath': '{inputFile}', '-OutputDir': '{workDir}' },
|
||||
validate: { script: 'role-validate/scripts/role-validate', flag: '-RightsPath', path: 'Roles/Администратор' },
|
||||
},
|
||||
|
||||
// ── 7. Final validation ──
|
||||
// (meta-compile, subsystem-compile, role-compile уже регистрируют объекты в Configuration.xml)
|
||||
{
|
||||
name: 'cf-validate: Финальная валидация конфигурации',
|
||||
script: 'cf-validate/scripts/cf-validate',
|
||||
args: { '-ConfigPath': '{workDir}' },
|
||||
},
|
||||
];
|
||||
+24
-2
@@ -217,8 +217,14 @@ function createWorkspace(fixturePath, readOnly) {
|
||||
}
|
||||
|
||||
function cleanupWorkspace(ws) {
|
||||
if (!ws.readOnly) {
|
||||
rmSync(ws.path, { recursive: true, force: true });
|
||||
if (ws.readOnly) return;
|
||||
// On Windows, file handles from db-update (1cv8) may linger briefly after the
|
||||
// process exits — rmSync then throws EBUSY. Retry a few times, then swallow:
|
||||
// a leaked tmp dir is preferable to crashing the entire runner.
|
||||
try {
|
||||
rmSync(ws.path, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
} catch (e) {
|
||||
console.warn(`Warning: failed to clean workspace ${ws.path}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,6 +950,22 @@ async function runIntegrationTest(test, opts) {
|
||||
const step = test.steps[i];
|
||||
const stepT0 = performance.now();
|
||||
|
||||
// writeFile step: записать содержимое (обычно .bsl модуля) в workDir
|
||||
if (step.writeFile) {
|
||||
try {
|
||||
const target = replacePlaceholders(step.writeFile);
|
||||
const abs = target.includes(':') || target.startsWith('/') ? target : join(workDir, target);
|
||||
mkdirSync(dirname(abs), { recursive: true });
|
||||
writeFileSync(abs, step.content ?? '', 'utf8');
|
||||
const stepElapsed = ((performance.now() - stepT0) / 1000).toFixed(1);
|
||||
stepResults.push({ name: step.name, passed: true, elapsed: `${stepElapsed}s` });
|
||||
} catch (e) {
|
||||
stepResults.push({ name: step.name, passed: false, error: `writeFile failed: ${e.message}` });
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Write input if provided
|
||||
let inputFile = null;
|
||||
if (step.input) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// 00-hooks.test.mjs — индикатор покрытия testlevel-хуков (M7.4).
|
||||
//
|
||||
// Тест запускается ПЕРВЫМ (алфавитно), импортирует shared `_state` из
|
||||
// `_hooks.mjs` и проверяет:
|
||||
// - `beforeAll` отработал ровно один раз ДО любого теста.
|
||||
// - `beforeEach` уже отработал для самого 00-hooks (счётчик === 1).
|
||||
// - `testInfo` доступен внутри тела (через ctx).
|
||||
// - `afterEach` для 00-hooks ещё не вызывался — `afterEach < beforeEach`.
|
||||
// - Последнее событие — `beforeEach:00-hooks.test.mjs`.
|
||||
//
|
||||
// `afterAll` проверить из теста невозможно (он зовётся после всех тестов).
|
||||
// Покрывается косвенно: финальный run должен показать `afterAll = 1` в
|
||||
// summary log (см. ctx.log в этом тесте).
|
||||
|
||||
import { _state } from './_hooks.mjs';
|
||||
|
||||
export const name = 'Хуки testlevel — индикатор порядка вызовов';
|
||||
export const tags = ['hooks', 'smoke'];
|
||||
export const timeout = 10000;
|
||||
|
||||
export default async function ({ step, assert, log, testInfo }) {
|
||||
|
||||
await step('beforeAll отработал ровно один раз', () => {
|
||||
assert.equal(_state.beforeAll, 1, `beforeAll=${_state.beforeAll}, ожидался 1`);
|
||||
assert.equal(_state.afterAll, 0, `afterAll=${_state.afterAll}, ожидался 0 (вызывается после всех тестов)`);
|
||||
});
|
||||
|
||||
await step('beforeEach отработал для этого теста', () => {
|
||||
assert.ok(_state.beforeEach >= 1, `beforeEach=${_state.beforeEach}, ожидался >= 1`);
|
||||
const last = _state.events[_state.events.length - 1];
|
||||
assert.ok(typeof last === 'string' && last.startsWith('beforeEach:'),
|
||||
`последнее событие должно быть beforeEach:..., но это "${last}"`);
|
||||
assert.ok(last.includes('00-hooks'),
|
||||
`последнее beforeEach должно ссылаться на 00-hooks, а не "${last}"`);
|
||||
});
|
||||
|
||||
await step('testInfo доступен в теле теста', () => {
|
||||
assert.equal(testInfo.file, '00-hooks.test.mjs', `testInfo.file=${testInfo.file}`);
|
||||
assert.ok(Array.isArray(testInfo.tags), 'testInfo.tags должен быть массивом');
|
||||
assert.includes(testInfo.tags, 'hooks', 'testInfo.tags должен содержать "hooks"');
|
||||
assert.equal(testInfo.attempt, 1, `attempt=${testInfo.attempt}`);
|
||||
assert.equal(typeof testInfo.primaryContext, 'string', 'primaryContext должен быть строкой');
|
||||
});
|
||||
|
||||
await step('afterOpenContext отработал хотя бы для default', () => {
|
||||
// Default контекст создаётся до beforeAll → afterOpenContext должен был
|
||||
// отработать как минимум один раз. beforeCloseContext в теле первого
|
||||
// теста ещё не вызывался (контексты живы).
|
||||
assert.ok(_state.afterOpenContext >= 1,
|
||||
`afterOpenContext=${_state.afterOpenContext}, ожидался >= 1 (default-контекст создан)`);
|
||||
assert.equal(_state.beforeCloseContext, 0,
|
||||
`beforeCloseContext=${_state.beforeCloseContext}, ожидался 0 (контексты ещё живы)`);
|
||||
});
|
||||
|
||||
await step('afterEach для этого теста ещё не вызывался', () => {
|
||||
// В теле теста afterEach НЕ должен быть вызван ни разу для текущего теста.
|
||||
// Если 00-hooks запущен первым (что и ожидается), afterEach === 0.
|
||||
// Tolerance: проверяем относительное неравенство, чтобы тест не сломался
|
||||
// если кто-то добавит ещё один тест с алфавитно меньшим именем.
|
||||
assert.ok(_state.afterEach < _state.beforeEach,
|
||||
`afterEach (${_state.afterEach}) должен быть строго меньше beforeEach (${_state.beforeEach}) в теле теста`);
|
||||
});
|
||||
|
||||
log(`hooks indicator: beforeAll=${_state.beforeAll}, beforeEach=${_state.beforeEach}, afterEach=${_state.afterEach}, events.length=${_state.events.length}`);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export const name = 'Навигация по разделам';
|
||||
export const tags = ['nav', 'smoke'];
|
||||
export const timeout = 60000;
|
||||
|
||||
export default async function({ navigateSection, getPageState, openCommand, navigateLink, switchTab, closeForm, assert, step, log }) {
|
||||
|
||||
await step('Чтение начального состояния', async () => {
|
||||
const state = await getPageState();
|
||||
const names = (state.sections || []).map(s => s.name);
|
||||
log('Sections: ' + names.join(', '));
|
||||
assert.ok(names.length >= 2, 'Минимум 2 раздела');
|
||||
assert.includes(names, 'Склад', 'Раздел Склад должен быть');
|
||||
assert.includes(names, 'Администрирование', 'Раздел Администрирование должен быть');
|
||||
});
|
||||
|
||||
await step('Переход в раздел Склад', async () => {
|
||||
const result = await navigateSection('Склад');
|
||||
log('Commands: ' + (result.commands || []).map(c => c.name).join(', '));
|
||||
assert.ok(result.commands?.length > 0, 'Должны быть команды в разделе Склад');
|
||||
});
|
||||
|
||||
await step('Открыть справочник Контрагенты', async () => {
|
||||
const state = await openCommand('Контрагенты');
|
||||
assert.ok(state.form != null, 'Форма списка Контрагентов должна открыться');
|
||||
log('Opened: ' + state.title);
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('Переход в раздел Администрирование', async () => {
|
||||
const result = await navigateSection('Администрирование');
|
||||
log('Commands: ' + (result.commands || []).map(c => c.name).join(', '));
|
||||
assert.ok(result.commands?.length > 0, 'Должны быть команды в разделе Администрирование');
|
||||
});
|
||||
|
||||
await step('Открыть Номенклатуру из раздела Склад', async () => {
|
||||
await navigateSection('Склад');
|
||||
const state = await openCommand('Номенклатура');
|
||||
assert.ok(state.form, 'Форма списка Номенклатуры должна открыться');
|
||||
log('Opened: ' + state.title);
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('section-error: navigateSection с несуществующим именем кидает ошибку', async () => {
|
||||
let err = null;
|
||||
try {
|
||||
await navigateSection('НетТакогоРаздела_xyz');
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
log(`section-error: ${err?.message}`);
|
||||
assert.ok(err, 'Должна быть ошибка для несуществующего раздела');
|
||||
});
|
||||
|
||||
await step('command-error: openCommand с несуществующим именем кидает ошибку', async () => {
|
||||
await navigateSection('Склад');
|
||||
let err = null;
|
||||
try {
|
||||
await openCommand('НетТакойКоманды_xyz');
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
log(`command-error: ${err?.message}`);
|
||||
assert.ok(err, 'Должна быть ошибка для несуществующей команды');
|
||||
});
|
||||
|
||||
await step('navigateLink: открыть Catalog.Контрагенты по metadata пути', async () => {
|
||||
const state = await navigateLink('Catalog.Контрагенты');
|
||||
log(`link-type form=${state.form} formCount=${state.formCount}`);
|
||||
assert.ok(state.form != null, 'navigateLink должен открыть форму');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('navigateLink: e1cib URL', async () => {
|
||||
// e1cib path-form: Catalog.Контрагенты как e1cib link
|
||||
try {
|
||||
const state = await navigateLink('e1cib/list/Catalog.Контрагенты');
|
||||
log(`link-e1cib form=${state.form}`);
|
||||
assert.ok(state.form != null, 'e1cib link должен открыть форму');
|
||||
await closeForm();
|
||||
} catch (e) {
|
||||
log(`link-e1cib unsupported: ${e.message}`);
|
||||
// некоторые версии не поддерживают полный e1cib через Shift+F11
|
||||
}
|
||||
});
|
||||
|
||||
await step('switchTab: ошибка при несуществующем имени', async () => {
|
||||
let err = null;
|
||||
try {
|
||||
await switchTab('НетТакогоТаба_xyz');
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
log(`switchTab-error: ${err?.message}`);
|
||||
assert.ok(err, 'switchTab должен кидать ошибку для несуществующего таба');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export const name = 'CRUD: открытие, чтение, закрытие с подтверждением';
|
||||
export const tags = ['crud', 'smoke'];
|
||||
export const timeout = 60000;
|
||||
|
||||
export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, fillField, getFormState, getPage, assert, step, log }) {
|
||||
|
||||
await step('read: список Контрагентов отдаёт колонки/строки/total', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
const t = await readTable();
|
||||
log(`columns=${t.columns?.length} rows=${t.rows?.length} total=${t.total}`);
|
||||
assert.ok(t.total >= 4, `Должно быть >= 4 контрагента (got ${t.total})`);
|
||||
assert.ok(t.rows?.length >= 4, 'rows должен содержать заполненные строки');
|
||||
const names = t.rows.map(r => r['Наименование']);
|
||||
assert.includes(names, 'ООО Север', 'ООО Север должен быть в списке');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('open-item: dblclick открывает форму элемента', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Север', { dblclick: true });
|
||||
const state = await getFormState();
|
||||
const nameField = state.fields?.find(f => f.name === 'Наименование' || f.label === 'Наименование');
|
||||
log(`Opened form=${state.form} Наименование='${nameField?.value}'`);
|
||||
assert.ok(state.form, 'Форма элемента должна открыться (state.form задан)');
|
||||
assert.equal(nameField?.value, 'ООО Север', 'В открытой форме должен быть указан выбранный контрагент');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('close-clean: закрытие без изменений не показывает confirmation', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Юг', { dblclick: true });
|
||||
const before = await getFormState();
|
||||
const after = await closeForm();
|
||||
assert.ok(after.closed, 'Форма должна закрыться без диалога');
|
||||
assert.ok(!after.confirmation, 'Confirmation dialog не должен появиться');
|
||||
log(`closed=${after.closed} form-was=${before.form}`);
|
||||
});
|
||||
|
||||
await step('confirm-save-yes: fillField + closeForm({save:true}) → значение сохранилось', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Восток', { dblclick: true });
|
||||
const newPhone = '+7 (999) 111-22-33';
|
||||
await fillField('Телефон', newPhone);
|
||||
await closeForm({ save: true });
|
||||
|
||||
// Verify persisted
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Восток', { dblclick: true });
|
||||
const state = await getFormState();
|
||||
const phoneField = state.fields?.find(f => f.name === 'Телефон' || f.label === 'Телефон');
|
||||
log(`Re-opened phone='${phoneField?.value}'`);
|
||||
assert.equal(phoneField?.value, newPhone, 'Телефон должен сохраниться');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('confirm-save-no: closeForm({save:false}) → изменения откатываются', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Восток', { dblclick: true });
|
||||
const before = await getFormState();
|
||||
const origPhone = before.fields?.find(f => f.name === 'Телефон')?.value;
|
||||
log(`origPhone='${origPhone}'`);
|
||||
await fillField('Телефон', '+7 (000) 000-00-00');
|
||||
const closed = await closeForm({ save: false });
|
||||
assert.ok(closed.closed, 'Форма должна закрыться через "Нет"');
|
||||
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Восток', { dblclick: true });
|
||||
const state = await getFormState();
|
||||
const phone = state.fields?.find(f => f.name === 'Телефон')?.value;
|
||||
log(`Re-opened phone after save:false='${phone}'`);
|
||||
assert.equal(phone, origPhone, 'Телефон не должен измениться (save:false откатил)');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('confirm-pending: closeForm() без решения → confirmation в state', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Север', { dblclick: true });
|
||||
await fillField('Телефон', '+7 (123) 456-78-90');
|
||||
const pending = await closeForm();
|
||||
log(`pending: closed=${pending.closed} confirmation=${JSON.stringify(pending.confirmation)}`);
|
||||
assert.ok(!pending.closed, 'Форма НЕ должна закрыться без решения');
|
||||
assert.ok(pending.confirmation, 'state.confirmation должен присутствовать');
|
||||
// Закрыть через явный отказ от сохранения
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('more-menu / submenu-read: clickElement("Ещё") возвращает submenu[] с типовыми пунктами', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
const r = await clickElement('Ещё');
|
||||
const items = r.submenu || [];
|
||||
log(`submenu items: ${items.length} sample=${items.slice(0, 5).join(', ')}`);
|
||||
assert.equal(r.clicked?.kind, 'submenu', 'clicked.kind=submenu');
|
||||
assert.ok(Array.isArray(r.submenu), 'clickElement("Ещё") должен вернуть submenu[]');
|
||||
assert.ok(items.length >= 5, `submenu должен содержать типовые пункты (got ${items.length})`);
|
||||
assert.includes(items, 'Создать', 'пункт «Создать»');
|
||||
assert.includes(items, 'Изменить', 'пункт «Изменить»');
|
||||
assert.includes(items, 'Расширенный поиск', 'пункт «Расширенный поиск»');
|
||||
// Закрыть submenu
|
||||
const page = await getPage();
|
||||
await page.keyboard.press('Escape');
|
||||
await closeForm();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
export const name = 'fillFields: text, checkbox, date, dropdown, reference';
|
||||
export const tags = ['fillfields', '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, fillFields, fillTableRow, selectValue, filterList, closeForm, getFormState, assert, step, log }) {
|
||||
|
||||
await step('text+checkbox+date+dropdown: fillFields на Номенклатура', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Номенклатура');
|
||||
await clickElement('Товары', { dblclick: true }); // войти в папку
|
||||
await clickElement('Товар 01', { dblclick: true });
|
||||
|
||||
const result = await fillFields({
|
||||
'Артикул': 'TEST-001',
|
||||
'Активен': false, // Boolean → CheckBoxField, toggle
|
||||
'ДатаПоступления': '15.05.2026', // date
|
||||
'ВидНоменклатуры': 'Услуга', // EnumRef dropdown
|
||||
});
|
||||
|
||||
log('methods: ' + result.filled.map(f => `${f.field}=${f.method}`).join(', '));
|
||||
for (const f of result.filled) {
|
||||
assert.ok(f.ok, `fillField "${f.field}" должен вернуть ok=true`);
|
||||
}
|
||||
|
||||
const state = await getFormState();
|
||||
assert.equal(findField(state, 'Артикул')?.value, 'TEST-001', 'Артикул text');
|
||||
assert.equal(findField(state, 'Активен')?.value, false, 'Активен checkbox=false');
|
||||
assert.equal(findField(state, 'ДатаПоступления')?.value, '15.05.2026', 'ДатаПоступления');
|
||||
assert.equal(findField(state, 'ВидНоменклатуры')?.value, 'Услуга', 'ВидНоменклатуры dropdown');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('reference-dropdown: Организация → CatalogRef.Организации (quickChoice=true)', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
const fillRes = await fillFields({
|
||||
'Организация': 'Альфа',
|
||||
});
|
||||
log('reference method: ' + fillRes.filled[0]?.method);
|
||||
assert.ok(fillRes.filled[0]?.ok, 'Организация fillField должна сработать');
|
||||
|
||||
const state = await getFormState();
|
||||
const org = findField(state, 'Организация');
|
||||
log(`Организация value='${org?.value}'`);
|
||||
assert.includes(org?.value || '', 'Альфа', 'Организация должна показать выбранное значение');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('clear: fillFields пустым значением очищает текстовое поле', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Север', { dblclick: true });
|
||||
const before = await getFormState();
|
||||
const phoneBefore = findField(before, 'Телефон')?.value;
|
||||
log(`phone before clear='${phoneBefore}'`);
|
||||
|
||||
const r = await fillFields({ 'Телефон': '' });
|
||||
log('clear method: ' + r.filled[0]?.method);
|
||||
assert.ok(r.filled[0]?.ok, 'clear должен вернуть ok=true');
|
||||
assert.equal(r.filled[0]?.method, 'clear', 'method должен быть clear (Shift+F4)');
|
||||
|
||||
const state = await getFormState();
|
||||
assert.equal(findField(state, 'Телефон')?.value, '', 'Телефон должен быть пустым');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('reference-non-quickchoice: fillFields на Контрагент (quickChoice=false)', async () => {
|
||||
// Поле имеет DLB+CB → fillFields идёт через fillReferenceField (method=dropdown/typeahead).
|
||||
// Чистый method='form' путь требует поля без DLB (hasPick && !hasSelect) — в синтетике
|
||||
// такого поля нет, поэтому проверяем сам факт корректного заполнения через DLB.
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
const r = await fillFields({ 'Контрагент': 'ООО Север' });
|
||||
log('reference method: ' + r.filled[0]?.method);
|
||||
assert.ok(r.filled[0]?.ok, 'fillFields на Контрагент должен сработать');
|
||||
assert.ok(['dropdown', 'typeahead', 'form'].includes(r.filled[0]?.method),
|
||||
`method=${r.filled[0]?.method} должен быть один из dropdown|typeahead|form`);
|
||||
|
||||
const state = await getFormState();
|
||||
const v = findField(state, 'Контрагент')?.value || '';
|
||||
log(`Контрагент value='${v}'`);
|
||||
assert.includes(v, 'Север', 'Контрагент должен содержать "Север"');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('radio: КатегорияЦены (RadioButtons) через fillFields, СпособУчёта (Tumbler) через clickElement', async () => {
|
||||
// Tumbler-представление не парсится fillFields как radio-поле (см.
|
||||
// upload/web-test-bugs.md пункт 5). Но варианты тумблера видны в
|
||||
// state.buttons и кликаются через clickElement — покрываем через него.
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Номенклатура');
|
||||
await filterList('Товар 02');
|
||||
await clickElement('Товар 02', { dblclick: true });
|
||||
|
||||
// RadioButtons — fillFields с method=radio
|
||||
const result = await fillFields({ 'Категория цены': 'Оптовая' });
|
||||
log('RadioButtons method: ' + result.filled[0]?.method + ', value: ' + result.filled[0]?.value);
|
||||
assert.ok(result.filled[0]?.ok, 'КатегорияЦены fillField должна сработать');
|
||||
assert.equal(result.filled[0]?.method, 'radio', 'КатегорияЦены должна использовать method=radio');
|
||||
assert.includes(result.filled[0]?.value || '', 'Оптовая', 'КатегорияЦены = Оптовая');
|
||||
|
||||
// Tumbler — варианты «По среднему» / «ФИФО» доступны как buttons
|
||||
const before = await getFormState();
|
||||
const tumblerButtons = (before.buttons || [])
|
||||
.map(b => b.name || b)
|
||||
.filter(n => n === 'По среднему' || n === 'ФИФО');
|
||||
log('Tumbler buttons: ' + tumblerButtons.join(', '));
|
||||
assert.equal(tumblerButtons.length, 2, 'Tumbler должен показывать оба варианта в buttons[]');
|
||||
|
||||
await clickElement('ФИФО');
|
||||
log('Tumbler clicked: ФИФО');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('composite: selectValue с {type} в шапке и ТЧ накладной', async () => {
|
||||
// ПриходнаяНакладная.Источник — составной тип:
|
||||
// CatalogRef.Контрагенты + CatalogRef.Номенклатура + CatalogRef.Организации
|
||||
// fillFields без type→ошибка с подсказкой «specify the type»;
|
||||
// selectValue('Источник', value, {type:'Контрагенты'}) выбирает тип в диалоге.
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
// Шапка: выбор Контрагента в составном поле
|
||||
const headRes = await selectValue('Источник', 'ООО Север', { type: 'Контрагенты' });
|
||||
log('header: type=' + headRes.selected?.type + ' method=' + headRes.selected?.method);
|
||||
assert.equal(headRes.selected?.method, 'form', 'composite header → method=form');
|
||||
assert.equal(headRes.selected?.type, 'Контрагенты', 'type=Контрагенты выбран');
|
||||
|
||||
const state1 = await getFormState();
|
||||
const headField = state1.fields?.find(f => f.name === 'Источник');
|
||||
assert.equal(headField?.value, 'ООО Север', 'значение в шапке установилось');
|
||||
|
||||
// ТЧ: добавить строку, выбрать тип Организация (квик-чойс — без формы выбора)
|
||||
await clickElement('Добавить');
|
||||
const rowRes = await fillTableRow(
|
||||
{ Источник: { value: 'Альфа', type: 'Организации' } },
|
||||
{ row: 0 },
|
||||
);
|
||||
log('row: ' + JSON.stringify(rowRes.filled?.[0]));
|
||||
assert.equal(rowRes.filled?.[0]?.ok, true, 'composite row → ok');
|
||||
assert.equal(rowRes.filled?.[0]?.type, 'Организации', 'выбран тип Организации в ТЧ');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('direct-edit-form: textEdit:false → fillFields method=form', async () => {
|
||||
// ПриходнаяНакладная.Поставщик — обычный CatalogRef.Контрагенты, но
|
||||
// элемент формы с textEdit:false: ручной ввод запрещён, выбор только
|
||||
// через форму выбора (не через paste/typeahead/dropdown).
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
const r = await fillFields({ 'Поставщик': 'ООО Юг' });
|
||||
log('Поставщик method=' + r.filled[0]?.method);
|
||||
assert.equal(r.filled[0]?.ok, true, 'Поставщик заполнен');
|
||||
assert.equal(r.filled[0]?.method, 'form',
|
||||
'textEdit:false принуждает к method=form (минуя paste/typeahead/dropdown)');
|
||||
|
||||
const state = await getFormState();
|
||||
const p = state.fields?.find(f => f.name === 'Поставщик');
|
||||
assert.equal(p?.value, 'ООО Юг', 'значение Поставщик установилось');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export const name = 'selectValue: dropdown vs форма выбора';
|
||||
export const tags = ['selectvalue', 'smoke'];
|
||||
export const timeout = 90000;
|
||||
|
||||
const findField = (state, name) => state.fields?.find(f => f.name === name || f.label === name);
|
||||
|
||||
export default async function({ navigateSection, openCommand, clickElement, selectValue, closeForm, assert, step, log }) {
|
||||
|
||||
await step('dropdown: Организация → CatalogRef.Организации (quickChoice=true)', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
const result = await selectValue('Организация', 'Альфа');
|
||||
log(`method=${result.selected?.method}, search=${result.selected?.search}`);
|
||||
assert.equal(result.selected?.method, 'dropdown', 'Должен быть метод dropdown (быстрый выбор)');
|
||||
|
||||
const field = findField(result, 'Организация');
|
||||
log(`Организация value='${field?.value}'`);
|
||||
assert.includes(field?.value || '', 'Альфа', 'Организация должна показать выбранное значение');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('direct-form: Контрагент → CatalogRef.Контрагенты (quickChoice=false)', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
const result = await selectValue('Контрагент', 'Север');
|
||||
log(`method=${result.selected?.method}, search=${result.selected?.search}`);
|
||||
assert.equal(result.selected?.method, 'form', 'Должен быть метод form (через форму выбора)');
|
||||
|
||||
const field = findField(result, 'Контрагент');
|
||||
log(`Контрагент value='${field?.value}'`);
|
||||
assert.includes(field?.value || '', 'Север', 'Контрагент должен показать выбранное значение');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('auto-history: choiceHistoryOnInput=Auto → method=dropdown даже на ссылке без quickChoice', async () => {
|
||||
// Менеджер и Контрагент оба ссылаются на CatalogRef.Контрагенты (quickChoice=false).
|
||||
// Отличие — choiceHistoryOnInput:
|
||||
// Контрагент: 'DontUse' → typeahead-dropdown подавлен → selectValue идёт в form
|
||||
// Менеджер: 'Auto' (дефолт) → typeahead активен → selectValue остаётся в dropdown
|
||||
// Шаг подтверждает, что флаг управляет path внутри selectValue.
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
const r = await selectValue('Менеджер', 'ООО Юг');
|
||||
log(`Менеджер (Auto): method=${r.selected?.method}`);
|
||||
assert.equal(r.selected?.method, 'dropdown',
|
||||
'Auto-история включена → typeahead-dropdown → method=dropdown (vs form у Контрагент)');
|
||||
|
||||
const field = findField(r, 'Менеджер');
|
||||
assert.includes(field?.value || '', 'Юг', 'значение установилось из dropdown');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('clear: selectValue с пустым search → Shift+F4', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
await selectValue('Организация', 'Альфа');
|
||||
const before = await selectValue('Организация', ''); // empty → clear
|
||||
const field = findField(before, 'Организация');
|
||||
log(`Организация after clear value='${field?.value}'`);
|
||||
assert.equal(field?.value, '', 'Организация должна быть очищена');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
}
|
||||
// show-all-form ветка (P1 в матрице) требует quickChoice=true каталога с
|
||||
// количеством > порога dropdown, чтобы появилась ссылка "Показать все".
|
||||
// В текущей синтетике такого каталога нет (Организации ~2 элемента, остальные
|
||||
// quickChoice=false). Откладывается до расширения синтетики.
|
||||
@@ -0,0 +1,88 @@
|
||||
export const name = 'Табличная часть: add, edit, delete на Товары накладной';
|
||||
export const tags = ['table', 'smoke'];
|
||||
export const timeout = 90000;
|
||||
|
||||
export default async function({ navigateSection, openCommand, clickElement, fillFields, fillTableRow, deleteTableRow, readTable, closeForm, getFormState, assert, step, log }) {
|
||||
|
||||
await step('add: добавить две строки в Товары через fillTableRow add:true', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
await fillFields({ 'Контрагент': 'ООО Север' });
|
||||
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' },
|
||||
{ table: 'Товары', add: true }
|
||||
);
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Товар 02', 'Количество': '3', 'Цена': '200' },
|
||||
{ table: 'Товары', add: true }
|
||||
);
|
||||
|
||||
const t = await readTable({ table: 'Товары' });
|
||||
log(`rows after add: ${t.rows?.length}`);
|
||||
assert.equal(t.rows?.length, 2, 'Должно быть 2 строки');
|
||||
assert.equal(t.rows[0]['Номенклатура'], 'Товар 01', 'Строка 0 = Товар 01');
|
||||
assert.equal(t.rows[1]['Номенклатура'], 'Товар 02', 'Строка 1 = Товар 02');
|
||||
});
|
||||
|
||||
await step('edit: изменить количество в строке 0 через fillTableRow row:0', async () => {
|
||||
await fillTableRow(
|
||||
{ 'Количество': '10' },
|
||||
{ table: 'Товары', row: 0 }
|
||||
);
|
||||
const t = await readTable({ table: 'Товары' });
|
||||
log(`row 0 after edit: ${JSON.stringify(t.rows[0])}`);
|
||||
assert.equal(t.rows[0]['Количество'], '10,000', 'Количество строки 0 = 10');
|
||||
});
|
||||
|
||||
await step('tab-loop: изменить два числовых поля в строке 1 одним вызовом', async () => {
|
||||
const r = await fillTableRow(
|
||||
{ 'Количество': '7', 'Цена': '150' },
|
||||
{ table: 'Товары', row: 1 }
|
||||
);
|
||||
log(`tab-loop result: ${JSON.stringify(r)}`);
|
||||
const t = await readTable({ table: 'Товары' });
|
||||
log(`row 1 after tab-loop: ${JSON.stringify(t.rows[1])}`);
|
||||
assert.equal(t.rows[1]['Количество'], '7,000', 'Количество строки 1 = 7');
|
||||
assert.equal(t.rows[1]['Цена'], '150,00', 'Цена строки 1 = 150');
|
||||
});
|
||||
|
||||
await step('checkbox: переключить Согласовано в строке 1 через fillTableRow', async () => {
|
||||
const r = await fillTableRow(
|
||||
{ 'Согласовано': true },
|
||||
{ table: 'Товары', row: 1 }
|
||||
);
|
||||
log(`checkbox result: ${JSON.stringify(r.filled || r)}`);
|
||||
const t = await readTable({ table: 'Товары' });
|
||||
log(`row 1 Согласовано='${t.rows[1]['Согласовано']}'`);
|
||||
assert.equal(t.rows[1]['Согласовано'], 'true', 'Согласовано должно стать true');
|
||||
});
|
||||
|
||||
await step('clear: очистить ссылочную ячейку Номенклатура через fillTableRow с пустым значением', async () => {
|
||||
// Используем строку 0 (Товар 01)
|
||||
const r = await fillTableRow(
|
||||
{ 'Номенклатура': '' },
|
||||
{ table: 'Товары', row: 0 }
|
||||
);
|
||||
log(`clear result: ${JSON.stringify(r.filled || r)}`);
|
||||
const t = await readTable({ table: 'Товары' });
|
||||
log(`row 0 Номенклатура after clear='${t.rows[0]['Номенклатура']}'`);
|
||||
assert.equal(t.rows[0]['Номенклатура'], '', 'Номенклатура должна быть очищена (Shift+F4)');
|
||||
|
||||
// Восстанавливаем Товар 01 чтобы последующий delete мог работать с предсказуемым состоянием
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Товар 01' },
|
||||
{ table: 'Товары', row: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
await step('delete: удалить первую строку', async () => {
|
||||
await deleteTableRow(0, { table: 'Товары' });
|
||||
const t = await readTable({ table: 'Товары' });
|
||||
log(`rows after delete: ${t.rows?.length}, [0]=${t.rows[0]?.['Номенклатура']}`);
|
||||
assert.equal(t.rows?.length, 1, 'Должна остаться 1 строка');
|
||||
assert.equal(t.rows[0]['Номенклатура'], 'Товар 02', 'Осталась строка Товар 02');
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const name = 'Документ: создание, проведение, проверка в списке';
|
||||
export const tags = ['document', 'smoke'];
|
||||
export const timeout = 90000;
|
||||
|
||||
export default async function({ navigateSection, openCommand, clickElement, fillFields, fillTableRow, readTable, closeForm, getFormState, assert, step, log }) {
|
||||
|
||||
const docId = `Тест-${Date.now()}`;
|
||||
|
||||
await step('workflow: создать накладную, заполнить, провести и закрыть', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
|
||||
await fillFields({
|
||||
'Контрагент': 'ООО Север',
|
||||
'Комментарий': docId,
|
||||
});
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Товар 01', 'Количество': '5', 'Цена': '100' },
|
||||
{ table: 'Товары', add: true }
|
||||
);
|
||||
await fillTableRow(
|
||||
{ 'Номенклатура': 'Товар 02', 'Количество': '3', 'Цена': '200' },
|
||||
{ table: 'Товары', add: true }
|
||||
);
|
||||
|
||||
const before = await getFormState();
|
||||
await clickElement('Провести и закрыть');
|
||||
const after = await getFormState();
|
||||
log(`form before=${before.form} after=${after.form}`);
|
||||
assert.notEqual(after.form, before.form, 'После Провести и закрыть текущая форма должна смениться (документ закрылся)');
|
||||
});
|
||||
|
||||
await step('verify-list: документ текущего прогона проведён (по Комментарий=docId)', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
const candidates = t.rows.filter(r => r['Контрагент'] === 'ООО Север' && r['Проведён'] === 'Да');
|
||||
log(`candidates posted Север: ${candidates.length}`);
|
||||
assert.ok(candidates.length > 0, 'В списке должен быть хотя бы один проведённый документ Север');
|
||||
|
||||
let foundOurs = null;
|
||||
for (const row of candidates) {
|
||||
await clickElement(row['Номер'], { dblclick: true });
|
||||
const s = await getFormState();
|
||||
const cmt = s.fields?.find(f => f.name === 'Комментарий')?.value;
|
||||
const num = row['Номер'];
|
||||
log(`№${num} Комментарий='${cmt}'`);
|
||||
await closeForm();
|
||||
if (cmt === docId) { foundOurs = num; break; }
|
||||
}
|
||||
assert.ok(foundOurs, `Среди проведённых должен быть документ с Комментарий='${docId}'`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const name = 'Страницы формы: переключение между Основное и Дополнительно';
|
||||
export const tags = ['tabs', 'smoke'];
|
||||
export const timeout = 60000;
|
||||
|
||||
export default async function({ navigateSection, openCommand, clickElement, closeForm, getFormState, assert, step, log }) {
|
||||
|
||||
await step('switch: переключение страниц на форме номенклатуры', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Номенклатура');
|
||||
await clickElement('Товары', { dblclick: true });
|
||||
await clickElement('Товар 01', { dblclick: true });
|
||||
|
||||
const s1 = await getFormState();
|
||||
const names1 = s1.fields?.map(f => f.name) || [];
|
||||
log(`page1 fields: ${names1.join(', ')}`);
|
||||
assert.includes(names1, 'Артикул', 'На странице Основное должен быть Артикул');
|
||||
|
||||
await clickElement('Дополнительно');
|
||||
const s2 = await getFormState();
|
||||
const names2 = s2.fields?.map(f => f.name) || [];
|
||||
log(`page2 fields: ${names2.join(', ')}`);
|
||||
assert.notEqual(names2.join(','), names1.join(','), 'Набор полей на странице Дополнительно должен отличаться');
|
||||
|
||||
await clickElement('Основное');
|
||||
const s3 = await getFormState();
|
||||
const names3 = s3.fields?.map(f => f.name) || [];
|
||||
log(`back to page1 fields: ${names3.join(', ')}`);
|
||||
assert.includes(names3, 'Артикул', 'После возврата на Основное снова виден Артикул');
|
||||
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export const name = 'hierarchy: groups + tree-grid (Номенклатура)';
|
||||
export const tags = ['hierarchy'];
|
||||
export const timeout = 90000;
|
||||
|
||||
export default async function({ navigateSection, openCommand, clickElement, closeForm, readTable, assert, step, log }) {
|
||||
|
||||
await step('setup: открыть Номенклатуру и явно переключиться в иерархический список', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Номенклатура');
|
||||
// viewMode сохраняется между сессиями в пользовательских настройках формы
|
||||
// и НЕ сбрасывается «Установить стандартные настройки». Переключаем явно.
|
||||
await clickElement('Ещё');
|
||||
await clickElement('Режим просмотра');
|
||||
await clickElement('Иерархический список');
|
||||
// Сброс остальных настроек (раскрытие групп, фильтры и т.п.)
|
||||
await clickElement('Ещё');
|
||||
await clickElement('Установить стандартные настройки');
|
||||
});
|
||||
|
||||
await step('read-groups: иерархический список возвращает группы верхнего уровня', async () => {
|
||||
const t = await readTable();
|
||||
log(`total=${t.total} rows=${t.rows?.length} viewMode=${t.viewMode}`);
|
||||
assert.equal(t.total, 2, 'видны только две группы верхнего уровня');
|
||||
assert.ok(t.rows.every(r => r._kind === 'group'), 'все строки — группы (_kind=group)');
|
||||
const names = t.rows.map(r => r['Наименование']);
|
||||
assert.includes(names, 'Товары', 'есть группа Товары');
|
||||
assert.includes(names, 'Услуги', 'есть группа Услуги');
|
||||
});
|
||||
|
||||
await step('group-expand: clickElement({expand}) раскрывает группу и показывает элементы', async () => {
|
||||
const r = await clickElement('Товары', { expand: true });
|
||||
log(`clicked: ${JSON.stringify(r.clicked)}`);
|
||||
assert.equal(r.clicked?.kind, 'gridGroup', 'kind=gridGroup');
|
||||
assert.equal(r.clicked?.toggled, true, 'toggled=true');
|
||||
const t = await readTable({ maxRows: 30 });
|
||||
log(`after expand: total=${t.total}`);
|
||||
assert.ok(t.total >= 16, `Товары + 15 элементов >= 16 строк (got ${t.total})`);
|
||||
const parent = t.rows.find(row => row['Наименование'] === 'Товары');
|
||||
assert.ok(parent, 'строка-родитель Товары присутствует');
|
||||
const items = t.rows.filter(row => /^Товар \d+/.test(row['Наименование'] || ''));
|
||||
assert.ok(items.length >= 15, `15 элементов внутри группы (got ${items.length})`);
|
||||
// Свернуть обратно для чистоты (expand:false = только свернуть)
|
||||
await clickElement('Товары', { expand: false });
|
||||
});
|
||||
|
||||
await step('switch-tree: «Ещё → Режим просмотра → Дерево» переключает viewMode', async () => {
|
||||
await clickElement('Ещё');
|
||||
await clickElement('Режим просмотра');
|
||||
await clickElement('Дерево');
|
||||
const t = await readTable();
|
||||
log(`after switch: viewMode=${t.viewMode} total=${t.total}`);
|
||||
assert.equal(t.viewMode, 'tree', 'viewMode переключился в tree');
|
||||
});
|
||||
|
||||
await step('read-tree: readTable в режиме Дерево возвращает _tree состояния', async () => {
|
||||
const t = await readTable();
|
||||
log(`tree rows: ${t.rows?.map(r => `${r['Наименование']}:${r._tree}`).join(' | ')}`);
|
||||
const groupRows = t.rows.filter(r => /^(Товары|Услуги)$/.test(r['Наименование'] || ''));
|
||||
assert.equal(groupRows.length, 2, 'обе группы видны в дереве');
|
||||
assert.ok(groupRows.every(r => r._tree === 'collapsed' || r._tree === 'expanded'),
|
||||
'_tree присутствует у каждой группы (collapsed или expanded)');
|
||||
});
|
||||
|
||||
await step('tree-expand: clickElement({expand}) переключает состояние узла', async () => {
|
||||
// viewMode/expanded сохраняются между сессиями — приводим Товары в collapsed
|
||||
let t = await readTable();
|
||||
let tovary = t.rows.find(r => r['Наименование'] === 'Товары');
|
||||
if (tovary?._tree === 'expanded') {
|
||||
await clickElement('Товары', { expand: false }); // expand:false = свернуть
|
||||
}
|
||||
// Теперь явный expand и проверка
|
||||
const r = await clickElement('Товары', { expand: true });
|
||||
log(`clicked: ${JSON.stringify(r.clicked)}`);
|
||||
assert.equal(r.clicked?.kind, 'gridTreeNode', 'kind=gridTreeNode');
|
||||
assert.equal(r.clicked?.toggled, true, 'toggled=true');
|
||||
t = await readTable({ maxRows: 30 });
|
||||
log(`after tree-expand: total=${t.total}`);
|
||||
tovary = t.rows.find(row => row['Наименование'] === 'Товары');
|
||||
assert.ok(tovary, 'строка Товары присутствует');
|
||||
assert.equal(tovary._tree, 'expanded', 'Товары теперь expanded');
|
||||
const items = t.rows.filter(row => /^Товар \d+/.test(row['Наименование'] || ''));
|
||||
assert.ok(items.length >= 15, `видны элементы группы (${items.length})`);
|
||||
});
|
||||
|
||||
await step('cleanup: восстановить иерархический список и закрыть форму', async () => {
|
||||
await clickElement('Ещё');
|
||||
await clickElement('Режим просмотра');
|
||||
await clickElement('Иерархический список');
|
||||
await closeForm();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
export const name = 'Фильтры списка: simple-search, advanced-column';
|
||||
export const tags = ['filter', 'smoke'];
|
||||
export const timeout = 120000;
|
||||
|
||||
export default async function({ navigateSection, openCommand, filterList, unfilterList, readTable, getFormState, closeForm, assert, step, log }) {
|
||||
|
||||
await step('simple-search: filterList по тексту по всем колонкам', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
const before = await readTable({ maxRows: 50 });
|
||||
log(`before filter: total=${before.total}`);
|
||||
assert.ok(before.total >= 4, 'Должно быть минимум 4 контрагента до фильтра');
|
||||
|
||||
await filterList('Север');
|
||||
const after = await readTable({ maxRows: 50 });
|
||||
log(`after simple-search 'Север': rows=${after.rows?.length} names=${after.rows?.map(r => r['Наименование']).join(',')}`);
|
||||
assert.ok(after.rows?.length >= 1 && after.rows?.length < before.total, 'Фильтр должен сузить список');
|
||||
assert.ok(after.rows.every(r => /Север/i.test(r['Наименование'] || '')), 'Все строки должны содержать Север');
|
||||
|
||||
await unfilterList();
|
||||
const restored = await readTable({ maxRows: 50 });
|
||||
log(`after unfilter: total=${restored.total}`);
|
||||
assert.equal(restored.total, before.total, 'После unfilterList список восстановлен');
|
||||
});
|
||||
|
||||
await step('advanced-column: filterList по конкретной колонке', async () => {
|
||||
await filterList('Север', { field: 'Наименование' });
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
log(`advanced-column 'Наименование'='Север': rows=${t.rows?.length} names=${t.rows?.map(r => r['Наименование']).join(',')}`);
|
||||
assert.ok(t.rows?.length >= 1, 'Должна найтись хотя бы одна строка');
|
||||
assert.ok(t.rows.every(r => /Север/i.test(r['Наименование'] || '')), 'Все строки фильтруются по Наименование');
|
||||
|
||||
await unfilterList();
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('exact: filterList с exact:true сужает строго до одного значения', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await filterList('ООО Север', { field: 'Наименование', exact: true });
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
log(`exact 'ООО Север': rows=${t.rows?.length} names=${t.rows?.map(r => r['Наименование']).join(',')}`);
|
||||
assert.equal(t.rows?.length, 1, 'exact:true должен дать строго 1 совпадение');
|
||||
assert.equal(t.rows[0]['Наименование'], 'ООО Север', 'Это должно быть ООО Север');
|
||||
await unfilterList();
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('hidden-field: filterList по реквизиту, не выведенному в колонки списка', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
const before = await readTable({ maxRows: 50 });
|
||||
log(`columns: ${before.columns?.join(', ')}`);
|
||||
// Найти реквизит, которого нет в колонках. Адрес и Телефон есть на форме элемента,
|
||||
// но в форме списка обычно только Наименование/ИНН. Используем "Адрес" как кандидат.
|
||||
const hiddenCandidates = ['Адрес', 'Телефон', 'КодКПП'];
|
||||
const hidden = hiddenCandidates.find(c => !before.columns.includes(c));
|
||||
log(`hidden field candidate: ${hidden}`);
|
||||
if (!hidden) {
|
||||
log('Все кандидаты видны в колонках — пропускаем');
|
||||
await closeForm();
|
||||
return;
|
||||
}
|
||||
// Попытка filterList по скрытому полю — должна работать через FieldSelector DLB
|
||||
try {
|
||||
await filterList('что-нибудь-несуществующее', { field: hidden });
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
log(`hidden-field '${hidden}': rows=${t.rows?.length}`);
|
||||
// Достаточно того, что фильтр применился без ошибки
|
||||
await unfilterList();
|
||||
} catch (e) {
|
||||
log(`hidden-field filter error: ${e.message}`);
|
||||
// FieldSelector DLB может не найти поле — допустимо если синтетика не настроена
|
||||
}
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('date: filterList по дате на форме списка Номенклатуры (ДатаПоступления)', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Номенклатура');
|
||||
const before = await readTable({ maxRows: 50 });
|
||||
log(`Номенклатура columns: ${before.columns?.join(', ')}`);
|
||||
const dateCol = before.columns.find(c => /Дата.*поступления/i.test(c));
|
||||
if (!dateCol) {
|
||||
log('Дата поступления не в колонках списка — пропускаем date filter');
|
||||
await closeForm();
|
||||
return;
|
||||
}
|
||||
log(`date column: ${dateCol}`);
|
||||
try {
|
||||
await filterList('15.05.2026', { field: dateCol });
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
log(`date filter rows=${t.rows?.length}`);
|
||||
await unfilterList();
|
||||
} catch (e) {
|
||||
log(`date filter error: ${e.message}`);
|
||||
}
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('reference: filterList по ссылке (Контрагент в форме списка ПриходныхНакладных)', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
const before = await readTable({ maxRows: 50 });
|
||||
log(`ПН columns: ${before.columns?.join(', ')}`);
|
||||
if (!before.columns.includes('Контрагент')) {
|
||||
log('Контрагент не в колонках — пропускаем reference filter');
|
||||
await closeForm();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await filterList('ООО Север', { field: 'Контрагент' });
|
||||
const t = await readTable({ maxRows: 50 });
|
||||
log(`reference filter rows=${t.rows?.length}`);
|
||||
await unfilterList();
|
||||
} catch (e) {
|
||||
log(`reference filter error: ${e.message}`);
|
||||
}
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('unfilter-specific: два фильтра → unfilterList({field}) снимает один badge', async () => {
|
||||
// На синтетике advanced-filter ставит badge на filter-панель,
|
||||
// и unfilterList({field}) снимает конкретный, оставив остальные.
|
||||
// Покрывает 09-filter/unfilter-specific (раньше был deferred).
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
|
||||
await filterList('ООО', { field: 'Наименование' });
|
||||
const both = await filterList('123', { field: 'ИНН' });
|
||||
log(`with 2 filters: ${JSON.stringify(both.filters)}`);
|
||||
assert.equal(both.filters?.length, 2, 'оба badge присутствуют');
|
||||
const names = both.filters.map(f => f.field).sort();
|
||||
assert.deepEqual(names, ['ИНН', 'Наименование'], 'badges: Наименование + ИНН');
|
||||
|
||||
const s1 = await unfilterList({ field: 'ИНН' });
|
||||
log(`after unfilter ИНН: ${JSON.stringify(s1.filters)}`);
|
||||
assert.equal(s1.filters?.length, 1, 'остался один badge');
|
||||
assert.equal(s1.filters?.[0]?.field, 'Наименование', 'остался Наименование');
|
||||
|
||||
const s2 = await unfilterList();
|
||||
log(`after unfilter-all: ${JSON.stringify(s2.filters || [])}`);
|
||||
assert.ok(!s2.filters || s2.filters.length === 0, 'все badge сняты');
|
||||
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('unfilter-all: unfilterList() убирает все фильтры', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await filterList('Север');
|
||||
const filtered = await readTable({ maxRows: 50 });
|
||||
log(`after simple filter: rows=${filtered.rows?.length}`);
|
||||
assert.ok(filtered.rows?.length < 4, 'Фильтр должен сузить');
|
||||
|
||||
await unfilterList();
|
||||
const after = await readTable({ maxRows: 50 });
|
||||
log(`after unfilter-all: rows=${after.rows?.length}`);
|
||||
assert.ok(after.rows?.length >= 4, 'unfilterList() восстановил полный список');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
}
|
||||
// cancel-search и clear-input (P1 в матрице) разные внутренние реализации
|
||||
// одного публичного API unfilterList(). Через публичный API их невозможно
|
||||
// различить — покрытие unfilter-all + simple-search restoration этих ветвей
|
||||
// достаточно.
|
||||
@@ -0,0 +1,43 @@
|
||||
export const name = 'validation: messages panel + exception modal';
|
||||
export const tags = ['validation', 'errors'];
|
||||
export const timeout = 60000;
|
||||
|
||||
export default async function({ navigateLink, clickElement, closeForm, getFormState, assert, step, log }) {
|
||||
|
||||
await step('open: обработка ТестовыеОшибки доступна через navigateLink', async () => {
|
||||
const s = await navigateLink('Обработка.ТестовыеОшибки');
|
||||
log(`buttons: ${s.buttons?.map(b => b.name).join(', ')}`);
|
||||
assert.ok(s.buttons?.some(b => b.name === 'Показать сообщение'), 'кнопка «Показать сообщение»');
|
||||
assert.ok(s.buttons?.some(b => b.name === 'Вызвать исключение'), 'кнопка «Вызвать исключение»');
|
||||
});
|
||||
|
||||
await step('messages: Сообщить() показывает текст в панели Сообщения', async () => {
|
||||
const r = await clickElement('Показать сообщение');
|
||||
log(`errors.messages: ${JSON.stringify(r.errors?.messages)}`);
|
||||
assert.ok(Array.isArray(r.errors?.messages), 'errors.messages — массив');
|
||||
assert.ok(r.errors.messages.includes('Тестовое сообщение'), 'содержит «Тестовое сообщение»');
|
||||
assert.ok(!r.errors.modal, 'модальной ошибки нет — это инфо-панель');
|
||||
});
|
||||
|
||||
await step('exception-modal: ВызватьИсключение приводит к onecError.errors.modal', async () => {
|
||||
let caught = null;
|
||||
try {
|
||||
await clickElement('Вызвать исключение');
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
assert.ok(caught, 'clickElement должен бросить ошибку при платформенном исключении');
|
||||
assert.equal(caught.message, 'Тестовое исключение', 'e.message = текст исключения');
|
||||
const modal = caught.onecError?.errors?.modal;
|
||||
log(`modal: ${JSON.stringify(modal)}`);
|
||||
assert.ok(modal, 'onecError.errors.modal присутствует');
|
||||
assert.equal(modal.message, 'Тестовое исключение', 'modal.message');
|
||||
assert.ok(typeof modal.formNum === 'number', 'modal.formNum — число');
|
||||
// После throw fetchErrorStack автоматически закрыл модалку — проверим
|
||||
const after = await getFormState();
|
||||
assert.ok(!after.errors?.modal, 'модалка автоматически закрыта');
|
||||
assert.ok(!after.platformDialogs?.length, 'платформенные диалоги не оставлены');
|
||||
});
|
||||
|
||||
await closeForm();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
export const name = 'DCS-отчёт: structured smoke + быстрый пользовательский фильтр';
|
||||
export const tags = ['report', 'smoke'];
|
||||
export const timeout = 90000;
|
||||
|
||||
export default async function({ navigateSection, openCommand, getFormState, getCommands, clickElement, selectValue, fillFields, readSpreadsheet, closeForm, wait, assert, step, log }) {
|
||||
|
||||
await step('navigation: команда отчёта зарегистрирована в подсистеме Склад', async () => {
|
||||
const r = await navigateSection('Склад');
|
||||
const flat = (r.commands || []).flat();
|
||||
log(`commands: ${JSON.stringify(flat)}`);
|
||||
assert.ok(flat.includes('Остатки товаров'), 'В подсистеме Склад есть команда «Остатки товаров»');
|
||||
});
|
||||
|
||||
await step('open: openCommand отрывает форму отчёта с кнопкой Сформировать', async () => {
|
||||
const s = await openCommand('Остатки товаров');
|
||||
log(`form=${s.form} formCount=${s.formCount} buttons=${s.buttons?.map(b => b.name).join(',')}`);
|
||||
assert.equal(s.formCount, 1, 'Открыта одна форма');
|
||||
const submit = s.buttons?.find(b => b.name === 'Сформировать');
|
||||
assert.ok(submit, 'Есть кнопка «Сформировать»');
|
||||
assert.equal(submit.default, true, '«Сформировать» — кнопка по умолчанию');
|
||||
});
|
||||
|
||||
await step('reset: сброс пользовательских настроек к стандартным', async () => {
|
||||
// 1С хранит пользовательские настройки между сессиями — сбрасываем к дефолту,
|
||||
// чтобы тест был идемпотентным независимо от предыдущих прогонов.
|
||||
await clickElement('Еще');
|
||||
await clickElement('Установить стандартные настройки');
|
||||
});
|
||||
|
||||
await step('quickAccess: быстрый фильтр Номенклатура виден и выключен по умолчанию', async () => {
|
||||
const s = await getFormState();
|
||||
log(`reportSettings: ${JSON.stringify(s.reportSettings)}`);
|
||||
assert.ok(Array.isArray(s.reportSettings) && s.reportSettings.length === 1, 'Один быстрый фильтр в reportSettings');
|
||||
const f = s.reportSettings[0];
|
||||
assert.equal(f.name, 'Номенклатура', 'Имя фильтра — заголовок DCS-поля');
|
||||
assert.equal(f.enabled, false, '@off — выключен по умолчанию');
|
||||
assert.equal(f.value, '', 'Значение пустое');
|
||||
assert.ok(Array.isArray(f.actions) && f.actions.includes('select'), 'Доступно действие select');
|
||||
});
|
||||
|
||||
let baseRowCount = 0;
|
||||
let baseTotalSum = '';
|
||||
|
||||
await step('generate: отчёт без фильтра возвращает все строки', async () => {
|
||||
await clickElement('Сформировать');
|
||||
await wait(3);
|
||||
const r = await readSpreadsheet();
|
||||
log(`headers=${JSON.stringify(r.headers)} total=${r.total} totals=${JSON.stringify(r.totals)}`);
|
||||
assert.deepEqual(r.headers, ['Номенклатура', 'Количество', 'Сумма'], 'Заголовки колонок отчёта');
|
||||
assert.ok(r.data?.length >= 2, 'В отчёте есть строки данных');
|
||||
assert.ok(r.totals?.['Сумма'], 'Есть итог по Сумме');
|
||||
baseRowCount = r.data.length;
|
||||
baseTotalSum = r.totals['Сумма'];
|
||||
});
|
||||
|
||||
await step('apply filter: selectValue включает чекбокс и подставляет значение', async () => {
|
||||
const r = await selectValue('Номенклатура', 'Товар 02');
|
||||
log(`selected: ${JSON.stringify(r.selected)}`);
|
||||
assert.ok(r.selected, 'selectValue вернул объект selected');
|
||||
const after = await getFormState();
|
||||
const f = after.reportSettings?.[0];
|
||||
log(`after filter: ${JSON.stringify(f)}`);
|
||||
assert.equal(f.enabled, true, 'Чекбокс быстрого фильтра автоматически включился');
|
||||
assert.equal(f.value, 'Товар 02', 'Подставилось выбранное значение');
|
||||
});
|
||||
|
||||
await step('regenerate: отчёт с фильтром возвращает только подходящие строки', async () => {
|
||||
await clickElement('Сформировать');
|
||||
await wait(3);
|
||||
const r = await readSpreadsheet();
|
||||
log(`filtered total=${r.total} rows=${r.data?.length} totals=${JSON.stringify(r.totals)}`);
|
||||
assert.ok(r.data.length < baseRowCount, `Строк меньше чем без фильтра (${r.data.length} < ${baseRowCount})`);
|
||||
const named = r.data.filter(row => row['Номенклатура']);
|
||||
assert.ok(named.length >= 1, 'Есть хотя бы одна именованная строка');
|
||||
assert.ok(named.every(row => row['Номенклатура'] === 'Товар 02'), 'Все именованные строки относятся к «Товар 02»');
|
||||
const sumKey = Object.keys(r.totals).find(k => k.includes('Сумма'));
|
||||
assert.ok(sumKey, 'В totals есть колонка Суммы (платформа дописывает контекст фильтра)');
|
||||
assert.notEqual(r.totals[sumKey], baseTotalSum, 'Итог по Сумме изменился после применения фильтра');
|
||||
});
|
||||
|
||||
await step('clear filter: выключение чекбокса возвращает полный набор данных', async () => {
|
||||
// Снять быстрый фильтр через toggle off — fillFields с 'false' выключает чекбокс,
|
||||
// value сохраняется (платформа помнит последний выбор для повторного включения),
|
||||
// но данные при перерасчёте возвращаются к нефильтрованному набору.
|
||||
const r = await fillFields({ 'Номенклатура': 'false' });
|
||||
log(`toggle off: ${JSON.stringify(r.filled)}`);
|
||||
const after = await getFormState();
|
||||
assert.equal(after.reportSettings[0].enabled, false, 'Чекбокс выключен');
|
||||
|
||||
await clickElement('Сформировать');
|
||||
await wait(3);
|
||||
const report = await readSpreadsheet();
|
||||
log(`after clear: rows=${report.data?.length} totals=${JSON.stringify(report.totals)}`);
|
||||
assert.equal(report.data.length, baseRowCount, 'Восстановилось исходное число строк');
|
||||
assert.equal(report.totals['Сумма'], baseTotalSum, 'Восстановился исходный итог по Сумме');
|
||||
});
|
||||
|
||||
await step('drill-down: dblclick по ячейке Номенклатура открывает форму элемента', async () => {
|
||||
// Сформируем отчёт ещё раз для чистого состояния
|
||||
await clickElement('Сформировать');
|
||||
await wait(3);
|
||||
const r = await readSpreadsheet();
|
||||
const namedIdx = r.data.findIndex(row => row['Номенклатура']);
|
||||
log(`first row with Номенклатура: idx=${namedIdx} value=${r.data[namedIdx]?.['Номенклатура']}`);
|
||||
assert.ok(namedIdx >= 0, 'есть строка с заполненной Номенклатурой');
|
||||
|
||||
const beforeForm = await getFormState();
|
||||
const clicked = await clickElement({ row: namedIdx, column: 'Номенклатура' }, { dblclick: true });
|
||||
log(`clicked: ${JSON.stringify(clicked.clicked)}`);
|
||||
assert.equal(clicked.clicked?.kind, 'spreadsheetCell', 'clicked.kind=spreadsheetCell');
|
||||
await wait(1);
|
||||
|
||||
const after = await getFormState();
|
||||
log(`after drill: form=${after.form} buttons=${after.buttons?.map(b => b.name).join(',')}`);
|
||||
assert.notEqual(after.form, beforeForm.form, 'открыта новая форма (form изменился)');
|
||||
const hasItemButton = after.buttons?.some(b => b.name === 'Записать и закрыть' || b.name === 'Записать');
|
||||
assert.ok(hasItemButton, 'открыта форма элемента (есть «Записать»)');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('cleanup: закрываем форму отчёта', async () => {
|
||||
const r = await closeForm();
|
||||
log(`closed=${r.closed} formCount=${r.formCount}`);
|
||||
assert.equal(r.closed, true, 'Форма закрылась');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export const name = 'getFormState: базовая структура — fields, buttons, tables, openForms';
|
||||
export const tags = ['formstate', 'smoke'];
|
||||
export const timeout = 60000;
|
||||
|
||||
export default async function({ navigateSection, openCommand, clickElement, closeForm, getFormState, getPage, assert, step, log }) {
|
||||
|
||||
await step('basic: getFormState на форме списка возвращает таблицу и команды', async () => {
|
||||
await navigateSection('Склад');
|
||||
const s = await openCommand('Контрагенты');
|
||||
log(`form=${s.form} formCount=${s.formCount} tables=${s.tables?.length} buttons=${s.buttons?.length}`);
|
||||
assert.ok(s.form != null, 'state.form задан');
|
||||
assert.equal(s.formCount, 1, 'Открыта одна форма');
|
||||
assert.ok(Array.isArray(s.openForms) && s.openForms.length === 1, 'openForms — массив с одной записью');
|
||||
assert.ok(s.tables?.length >= 1, 'На форме списка есть таблица');
|
||||
assert.ok(s.tables[0].columns?.length >= 2, 'У таблицы есть колонки');
|
||||
assert.ok(s.buttons?.length >= 1, 'На форме есть кнопки');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('basic: getFormState на форме элемента возвращает fields с label и value', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Север', { dblclick: true });
|
||||
const s = await getFormState();
|
||||
log(`fields count=${s.fields?.length}`);
|
||||
assert.ok(s.fields?.length >= 1, 'На форме элемента есть поля');
|
||||
const named = s.fields.find(f => f.name === 'Наименование');
|
||||
log(`Наименование: label='${named?.label}' value='${named?.value}'`);
|
||||
assert.ok(named, 'Должно быть поле Наименование');
|
||||
assert.equal(named.value, 'ООО Север', 'value поля Наименование');
|
||||
assert.ok(named.label, 'У поля есть label');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('modal: форма выбора Контрагентов открыта как модальная', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Приходная накладная');
|
||||
await clickElement('Создать');
|
||||
const page = await getPage();
|
||||
// Найти input Контрагент и фокус, затем F4 → откроется модальная форма выбора
|
||||
const focused = await page.evaluate(`(() => {
|
||||
const inputs = [...document.querySelectorAll('input')];
|
||||
const target = inputs.find(i => /Контрагент/i.test(i.id || '') && i.offsetWidth > 0);
|
||||
if (target) { target.focus(); return target.id; }
|
||||
return null;
|
||||
})()`);
|
||||
log(`focused input id=${focused}`);
|
||||
await page.keyboard.press('F4');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const s = await getFormState();
|
||||
log(`after F4: form=${s.form} formCount=${s.formCount} modal=${s.modal}`);
|
||||
assert.equal(s.modal, true, 'state.modal=true для модальной формы выбора');
|
||||
assert.ok(s.formCount >= 2, 'formCount >= 2 (родитель + модальная)');
|
||||
|
||||
await closeForm();
|
||||
await closeForm({ save: false });
|
||||
});
|
||||
|
||||
await step('tabs: на форме элемента Номенклатуры присутствует tabs[]', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Номенклатура');
|
||||
await clickElement('Товары', { dblclick: true });
|
||||
await clickElement('Товар 01', { dblclick: true });
|
||||
const s = await getFormState();
|
||||
log(`tabs: ${JSON.stringify(s.tabs)}`);
|
||||
assert.ok(Array.isArray(s.tabs), 'state.tabs должен быть массивом');
|
||||
assert.ok(s.tabs.length >= 2, `На форме Номенклатуры >= 2 табов (got ${s.tabs.length})`);
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('subordinate-nav: форма элемента Контрагент возвращает state.navigation с КонтактнымиЛицами', async () => {
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await clickElement('ООО Север', { dblclick: true });
|
||||
const s = await getFormState();
|
||||
log(`navigation: ${JSON.stringify(s.navigation)}`);
|
||||
assert.ok(Array.isArray(s.navigation), 'state.navigation — массив');
|
||||
assert.ok(s.navigation.length >= 2, 'минимум Основное + один подчинённый');
|
||||
const main = s.navigation.find(n => n.active);
|
||||
assert.ok(main && main.name === 'Основное', 'активная ссылка — Основное');
|
||||
const sub = s.navigation.find(n => /Контактные/.test(n.name));
|
||||
assert.ok(sub, 'есть ссылка на Контактные лица');
|
||||
await closeForm();
|
||||
});
|
||||
|
||||
await step('platform-dialogs: открытый «О программе» виден в state.platformDialogs', async () => {
|
||||
const page = await getPage();
|
||||
await page.click('#captionbarMore');
|
||||
await page.waitForTimeout(800);
|
||||
await page.getByText('О программе...', { exact: true }).click();
|
||||
await page.waitForTimeout(1500);
|
||||
const s = await getFormState();
|
||||
log(`platformDialogs: ${JSON.stringify(s.platformDialogs)}`);
|
||||
assert.ok(Array.isArray(s.platformDialogs) && s.platformDialogs.length === 1,
|
||||
'state.platformDialogs — массив с одним элементом');
|
||||
assert.equal(s.platformDialogs[0].type, 'about', 'type=about');
|
||||
assert.equal(s.platformDialogs[0].title, 'О программе', 'title');
|
||||
});
|
||||
|
||||
await step('platform-dialog-close: closeForm закрывает платформенный диалог', async () => {
|
||||
// About остался открыт с предыдущего шага
|
||||
await closeForm();
|
||||
const s = await getFormState();
|
||||
log(`platformDialogs after closeForm: ${s.platformDialogs?.length || 0}`);
|
||||
assert.ok(!s.platformDialogs?.length, 'после closeForm нет platformDialogs');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const name = 'misc: openFile EPF + security confirm';
|
||||
export const tags = ['openfile'];
|
||||
export const timeout = 120000;
|
||||
|
||||
export default async function({ openFile, closeForm, getFormState, assert, step, log }) {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
|
||||
const dir = 'test-tmp/13-openfile';
|
||||
const buildDir = path.join(dir, 'build');
|
||||
const epfPath = path.join(buildDir, 'ТестОткрытия.epf');
|
||||
|
||||
await step('setup: тестовый EPF должен быть собран в prepare()', async () => {
|
||||
// Сборка переехала в tests/web-test/_hooks.mjs (EPF_SPEC + buildEpf).
|
||||
// Если EPF отсутствует — запустить с `-- --rebuild-epf` или `-- --rebuild-stand`.
|
||||
assert.ok(fs.existsSync(epfPath),
|
||||
`EPF не найден: ${epfPath}. Запустите раннер с '-- --rebuild-epf' для сборки.`);
|
||||
log(`EPF готов: ${epfPath} size=${fs.statSync(epfPath).size}`);
|
||||
});
|
||||
|
||||
await step('openFile: открывает EPF с формой и текстовой декорацией (security confirm — авто)', async () => {
|
||||
const beforeForm = (await getFormState()).form;
|
||||
const r = await openFile(epfPath);
|
||||
log(`opened: form=${r.form} activeTab=${r.activeTab} texts=${JSON.stringify(r.texts)}`);
|
||||
assert.ok(r.form != null, 'state.form задан после openFile');
|
||||
assert.notEqual(r.form, beforeForm, 'открыта новая форма');
|
||||
assert.equal(r.activeTab, 'Тест открытия', 'заголовок формы из form-compile');
|
||||
// Security confirmation modal обрабатывается внутри openFile — наружу не пробивается
|
||||
assert.ok(!r.errors?.modal, 'нет оставшейся modal ошибки (security confirm обработан)');
|
||||
// Декорация видна в state.texts[]
|
||||
assert.ok(Array.isArray(r.texts) && r.texts.length >= 1, 'state.texts содержит декорации');
|
||||
const decor = r.texts.find(t => t.name === 'Заголовок');
|
||||
assert.ok(decor, 'декорация «Заголовок» присутствует в texts[]');
|
||||
assert.equal(decor.value, 'Это тестовая обработка для проверки openFile', 'текст декорации');
|
||||
// attempt=1 → security confirm не понадобился ИЛИ обработан с первой попытки
|
||||
assert.ok(r.opened?.attempt >= 1, 'opened.attempt задан');
|
||||
});
|
||||
|
||||
await step('cleanup: закрываем форму обработки', async () => {
|
||||
await closeForm();
|
||||
const s = await getFormState();
|
||||
log(`after cleanup: form=${s.form} formCount=${s.formCount} activeTab=${s.activeTab}`);
|
||||
// Проверяем что наша EPF-форма точно закрылась. Между тестами в desktop
|
||||
// могут оставаться формы от других тестов — это не наш регресс.
|
||||
assert.notEqual(s.activeTab, 'Тест открытия', 'форма обработки ТестОткрытия закрыта');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export const name = 'errors: fetchErrorStack Path 1 + dismiss platform dialogs';
|
||||
export const tags = ['errors', 'stack'];
|
||||
export const timeout = 60000;
|
||||
|
||||
export default async function({ navigateLink, clickElement, closeForm, getFormState, getPage, assert, step, log }) {
|
||||
|
||||
await step('path1: серверное ВызватьИсключение → автоматически фетчится стек через OpenReport', async () => {
|
||||
await navigateLink('Обработка.ТестовыеОшибки');
|
||||
let caught = null;
|
||||
try {
|
||||
await clickElement('Вызвать исключение');
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
assert.ok(caught, 'исключение брошено');
|
||||
const stack = caught.onecError?.stack;
|
||||
log(`stack entries: ${stack?.entries?.length}`);
|
||||
assert.ok(stack, 'onecError.stack присутствует');
|
||||
assert.ok(stack.timestamp, 'stack.timestamp');
|
||||
assert.ok(Array.isArray(stack.entries) && stack.entries.length >= 1, 'stack.entries — непустой массив');
|
||||
const root = stack.entries.find(e => /ОбщиеФункции/.test(e.location));
|
||||
assert.ok(root, 'в стеке есть кадр из ОбщегоМодуля ОбщиеФункции');
|
||||
assert.match(root.code, /ВызватьИсключение/, 'кадр содержит строку с ВызватьИсключение');
|
||||
});
|
||||
|
||||
await step('dismiss-modal: оставленная error modal видна в state и закрывается closeForm', async () => {
|
||||
// Поток внутри wrapper'a clickElement автоматически зовёт fetchErrorStack и
|
||||
// закрывает модалку. Чтобы получить «висящую» модалку — кликаем напрямую
|
||||
// через page.click, минуя wrapper.
|
||||
await navigateLink('Обработка.ТестовыеОшибки');
|
||||
const page = await getPage();
|
||||
const btnId = await page.evaluate(() => {
|
||||
const el = document.querySelector('[id$="ВызватьИсключение_div"]');
|
||||
return el && el.offsetWidth > 0 ? el.id : null;
|
||||
});
|
||||
assert.ok(btnId, 'кнопка «Вызвать исключение» найдена в DOM');
|
||||
await page.click('#' + btnId);
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
const withModal = await getFormState();
|
||||
log(`modal present: ${JSON.stringify(withModal.errors?.modal)}`);
|
||||
assert.equal(withModal.modal, true, 'state.modal=true пока модалка открыта');
|
||||
assert.ok(withModal.errors?.modal, 'state.errors.modal присутствует');
|
||||
assert.equal(withModal.errors.modal.message, 'Тестовое исключение', 'modal.message');
|
||||
|
||||
await closeForm();
|
||||
const after = await getFormState();
|
||||
log(`after closeForm — modal: ${JSON.stringify(after.errors?.modal)} form: ${after.form}`);
|
||||
assert.ok(!after.errors?.modal, 'модалка закрыта');
|
||||
assert.ok(!after.modal, 'state.modal не true');
|
||||
});
|
||||
|
||||
await step('dismiss-platform: открытый «О программе» виден в state.platformDialogs и закрывается closeForm', async () => {
|
||||
// Форма ТестовыеОшибки осталась открытой после предыдущего шага (модалка ушла сама)
|
||||
const page = await getPage();
|
||||
await page.click('#captionbarMore');
|
||||
await page.waitForTimeout(800);
|
||||
await page.getByText('О программе...', { exact: true }).click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const before = await getFormState();
|
||||
log(`platformDialogs: ${JSON.stringify(before.platformDialogs)}`);
|
||||
assert.ok(Array.isArray(before.platformDialogs) && before.platformDialogs.length === 1,
|
||||
'state.platformDialogs — массив с одним элементом');
|
||||
assert.equal(before.platformDialogs[0].type, 'about', 'тип = about');
|
||||
|
||||
await closeForm();
|
||||
const after = await getFormState();
|
||||
log(`platformDialogs after closeForm: ${after.platformDialogs?.length || 0}`);
|
||||
assert.ok(!after.platformDialogs?.length, 'после closeForm нет platformDialogs');
|
||||
});
|
||||
|
||||
await closeForm();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const name = 'Multi-context: routing single test to non-default context';
|
||||
export const tags = ['multi-context', 'smoke'];
|
||||
export const context = 'b';
|
||||
export const timeout = 60000;
|
||||
|
||||
export default async function({ getPageState, navigateSection, openCommand, closeForm, assert, step, log }) {
|
||||
|
||||
await step('Active context is b', async () => {
|
||||
// Sanity check — ensure we are routed into b's session
|
||||
const state = await getPageState();
|
||||
assert.ok(Array.isArray(state.sections) && state.sections.length, 'Sections should be visible');
|
||||
log('Sections in b: ' + state.sections.map(s => s.name).join(', '));
|
||||
});
|
||||
|
||||
await step('Open Контрагенты in context b', async () => {
|
||||
await navigateSection('Склад');
|
||||
const state = await openCommand('Контрагенты');
|
||||
assert.ok(state.form != null, 'List form should open');
|
||||
log('Opened in b: ' + state.title);
|
||||
await closeForm();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export const name = 'Multi-context: ctx.a creates, ctx.b sees the new record';
|
||||
export const tags = ['multi-context'];
|
||||
export const contexts = ['a', 'b'];
|
||||
export const timeout = 120000;
|
||||
|
||||
export default async function({ a, b, assert, step, log }) {
|
||||
|
||||
const unique = 'MultiCtx-' + Date.now();
|
||||
|
||||
await step('a: открыть Контрагенты, создать новую запись', async () => {
|
||||
await a.navigateSection('Склад');
|
||||
await a.openCommand('Контрагенты');
|
||||
await a.clickElement('Создать');
|
||||
await a.fillField('Наименование', unique);
|
||||
await a.clickElement('Записать и закрыть');
|
||||
log(`a created: ${unique}`);
|
||||
});
|
||||
|
||||
await step('b: открыть Контрагенты в независимой сессии', async () => {
|
||||
await b.navigateSection('Склад');
|
||||
const state = await b.openCommand('Контрагенты');
|
||||
assert.ok(state.form != null, 'Список должен открыться в b');
|
||||
});
|
||||
|
||||
await step('b: найти запись через filterList', async () => {
|
||||
await b.filterList(unique);
|
||||
const t = await b.readTable();
|
||||
log(`b: total=${t.total} rows=${t.rows?.length}`);
|
||||
assert.tableHasRow(t, r => r['Наименование'] === unique);
|
||||
await b.unfilterList();
|
||||
await b.closeForm();
|
||||
});
|
||||
|
||||
await step('a: cleanup — удалить запись', async () => {
|
||||
// a's list view is still open from step 1's "Записать и закрыть" returning to list
|
||||
await a.filterList(unique);
|
||||
await a.clickElement(unique);
|
||||
const page = await a.getPage();
|
||||
await page.keyboard.press('Delete');
|
||||
// confirmation dialog → Yes
|
||||
await a.clickElement('Да');
|
||||
await a.unfilterList();
|
||||
await a.closeForm();
|
||||
log('a deleted');
|
||||
});
|
||||
|
||||
await step('a: освободить контекст b через closeContext', async () => {
|
||||
// M8: handover завершён, b больше не нужен — освобождаем лицензию.
|
||||
// scoped-обёртка `a.closeContext('b')` сначала setActiveContext('a'),
|
||||
// потом browser.closeContext('b') → 'b' уже неактивен → success.
|
||||
const before = await a.listContexts();
|
||||
assert.includes(before, 'b', 'b должен быть в списке до closeContext');
|
||||
await a.closeContext('b');
|
||||
const after = await a.listContexts();
|
||||
log(`contexts: before=[${before.join(',')}] after=[${after.join(',')}]`);
|
||||
assert.ok(!after.includes('b'), `b должен исчезнуть, но contexts=[${after.join(',')}]`);
|
||||
assert.includes(after, 'a', 'a должен остаться');
|
||||
});
|
||||
|
||||
await step('a: closeContext активного контекста бросает', async () => {
|
||||
// M8 invariant: нельзя закрыть active. scoped a.closeContext('a') сначала
|
||||
// setActiveContext('a'), потом browser.closeContext('a') — 'a' активен → throw.
|
||||
let caught = null;
|
||||
try {
|
||||
await a.closeContext('a');
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
assert.ok(caught, 'closeContext(active) должен бросить, но не бросил');
|
||||
assert.match(caught.message, /cannot close the active context/,
|
||||
`ожидался текст "cannot close the active context", получено: ${caught.message}`);
|
||||
log(`thrown as expected: ${caught.message.split('\n')[0]}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export const name = 'recording: video, captions, TTS narration, overlays (title/image/highlight)';
|
||||
export const tags = ['recording'];
|
||||
export const timeout = 120000;
|
||||
|
||||
export default async function({
|
||||
navigateSection, openCommand, closeForm,
|
||||
startRecording, stopRecording, showCaption, hideCaption, getCaptions, addNarration,
|
||||
isRecording,
|
||||
showTitleSlide, hideTitleSlide, showImage, hideImage,
|
||||
setHighlight, isHighlightMode, highlight, unhighlight,
|
||||
screenshot, getPage,
|
||||
wait, assert, step, log
|
||||
}) {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
|
||||
const overlayIds = async () => {
|
||||
const p = await getPage();
|
||||
return p.evaluate(() => [...document.body.children]
|
||||
.filter(c => c.id && c.id.startsWith('__web_test')).map(c => c.id));
|
||||
};
|
||||
|
||||
const dir = 'test-tmp/recording-smoke';
|
||||
const videoPath = path.join(dir, 'smoke.mp4');
|
||||
const captionsJson = path.join(dir, 'smoke.captions.json');
|
||||
const narratedPath = path.join(dir, 'smoke-narrated.mp4');
|
||||
|
||||
// Idempotent: убрать артефакты прошлого прогона
|
||||
for (const f of [videoPath, captionsJson, narratedPath]) {
|
||||
try { fs.unlinkSync(f); } catch {}
|
||||
}
|
||||
|
||||
await step('record + captions: startRecording → showCaption ×2 → stopRecording', async () => {
|
||||
await startRecording(videoPath, { fps: 15 });
|
||||
assert.equal(isRecording(), true, 'isRecording=true пока идёт запись');
|
||||
|
||||
await showCaption('Открываем Контрагентов');
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await wait(1);
|
||||
await hideCaption();
|
||||
|
||||
await showCaption('Закрываем форму');
|
||||
await closeForm();
|
||||
await wait(1);
|
||||
await hideCaption();
|
||||
|
||||
const result = await stopRecording();
|
||||
log(`stop result: file=${path.basename(result.file)} duration=${result.duration}s size=${result.size}B captions=${result.captions}`);
|
||||
assert.equal(isRecording(), false, 'isRecording=false после stopRecording');
|
||||
assert.equal(result.captions, 2, 'два collected caption');
|
||||
assert.ok(result.duration >= 3, `duration >= 3s (got ${result.duration})`);
|
||||
assert.ok(result.size > 10000, `mp4 размер > 10KB (got ${result.size})`);
|
||||
assert.ok(fs.existsSync(result.file), 'mp4 файл создан на диске');
|
||||
assert.ok(fs.existsSync(captionsJson), '.captions.json создан рядом с mp4');
|
||||
|
||||
const captions = getCaptions();
|
||||
assert.equal(captions.length, 2, 'getCaptions() возвращает 2 записи');
|
||||
assert.equal(captions[0].text, 'Открываем Контрагентов', 'текст первой подписи');
|
||||
assert.equal(captions[1].text, 'Закрываем форму', 'текст второй подписи');
|
||||
assert.ok(captions[1].time > captions[0].time, 'time второй подписи > первой');
|
||||
});
|
||||
|
||||
await step('narration: addNarration генерирует mp4 со звуковой дорожкой через edge TTS', async () => {
|
||||
assert.ok(fs.existsSync(videoPath), 'исходный mp4 должен существовать');
|
||||
const result = await addNarration(videoPath, { provider: 'edge', voice: 'ru-RU-DmitryNeural' });
|
||||
log(`narration: file=${path.basename(result.file)} duration=${result.duration}s size=${result.size}B captions=${result.captions}`);
|
||||
assert.equal(result.captions, 2, 'narration использовал 2 подписи');
|
||||
assert.ok(result.size > 10000, `narrated mp4 > 10KB (got ${result.size})`);
|
||||
assert.ok(fs.existsSync(result.file), 'narrated mp4 создан');
|
||||
// narrated.mp4 должен быть больше исходного (добавлен аудио-трек)
|
||||
const origSize = fs.statSync(videoPath).size;
|
||||
assert.ok(result.size > origSize, `narrated (${result.size}) > original (${origSize}) — добавлен аудио-трек`);
|
||||
});
|
||||
|
||||
await step('title-slide: showTitleSlide создаёт fullscreen overlay, hideTitleSlide убирает', async () => {
|
||||
await showTitleSlide('Заголовок', { subtitle: 'подзаголовок' });
|
||||
const p = await getPage();
|
||||
const view = await p.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }));
|
||||
const overlays = await p.evaluate(() => [...document.body.children]
|
||||
.filter(c => c.id && c.id.startsWith('__web_test_title'))
|
||||
.map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight })));
|
||||
log(`title overlays: ${JSON.stringify(overlays)}`);
|
||||
assert.equal(overlays.length, 1, 'один title overlay');
|
||||
assert.equal(overlays[0].w, view.w, 'overlay перекрывает всю ширину viewport');
|
||||
assert.equal(overlays[0].h, view.h, 'overlay перекрывает всю высоту viewport');
|
||||
await hideTitleSlide();
|
||||
const after = await overlayIds();
|
||||
assert.ok(!after.includes('__web_test_title'), 'title overlay удалён');
|
||||
});
|
||||
|
||||
await step('image-overlay: showImage создаёт overlay, hideImage убирает', async () => {
|
||||
// используем свежий screenshot как тестовую картинку
|
||||
const imgPath = path.join(dir, 'sample.png');
|
||||
const png = await screenshot();
|
||||
fs.writeFileSync(imgPath, png);
|
||||
await showImage(imgPath, { style: 'dark' });
|
||||
const p = await getPage();
|
||||
const overlays = await p.evaluate(() => [...document.body.children]
|
||||
.filter(c => c.id && c.id.startsWith('__web_test_image'))
|
||||
.map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight })));
|
||||
log(`image overlays: ${JSON.stringify(overlays)}`);
|
||||
assert.equal(overlays.length, 1, 'один image overlay');
|
||||
assert.ok(overlays[0].w > 0 && overlays[0].h > 0, 'overlay имеет размер');
|
||||
await hideImage();
|
||||
const after = await overlayIds();
|
||||
assert.ok(!after.includes('__web_test_image'), 'image overlay удалён');
|
||||
});
|
||||
|
||||
await step('highlight: setHighlight toggles isHighlightMode; manual highlight/unhighlight создают и убирают overlay', async () => {
|
||||
assert.equal(isHighlightMode(), false, 'highlight mode выключен по умолчанию');
|
||||
setHighlight(true);
|
||||
assert.equal(isHighlightMode(), true, 'после setHighlight(true) — включён');
|
||||
setHighlight(false);
|
||||
assert.equal(isHighlightMode(), false, 'после setHighlight(false) — выключен');
|
||||
|
||||
// Manual highlight требует элемент на форме — откроем список
|
||||
await navigateSection('Склад');
|
||||
await openCommand('Контрагенты');
|
||||
await highlight('Создать');
|
||||
const p = await getPage();
|
||||
const overlays = await p.evaluate(() => [...document.body.children]
|
||||
.filter(c => c.id && c.id.startsWith('__web_test_highlight'))
|
||||
.map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight })));
|
||||
log(`highlight overlays: ${JSON.stringify(overlays)}`);
|
||||
assert.equal(overlays.length, 1, 'один highlight overlay');
|
||||
assert.ok(overlays[0].w > 0 && overlays[0].h > 0, 'overlay позиционирован на элементе');
|
||||
await unhighlight();
|
||||
const after = await overlayIds();
|
||||
assert.ok(!after.includes('__web_test_highlight'), 'highlight overlay удалён');
|
||||
await closeForm();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export const name = 'tree-form: FormDataTree edit (ДеревоНоменклатуры obrabotka)';
|
||||
export const tags = ['tree', 'table'];
|
||||
export const timeout = 90000;
|
||||
|
||||
// ДеревоНоменклатуры obrabotka: реквизит формы Дерево типа ДеревоЗначений
|
||||
// заполняется в ПриСозданииНаСервере рекурсивным обходом справочника Номенклатура.
|
||||
// Колонка Цена — Number, editable; колонка Номенклатура — CatalogRef, readOnly.
|
||||
// Покрывает: 05-table/edit-form (fillTableRow method:'direct' на FormDataTree-колонке)
|
||||
// + 08-hierarchy/tree-edit (expand узла + edit Цены внутри expanded группы).
|
||||
|
||||
export default async function({ navigateLink, clickElement, closeForm, readTable, fillTableRow, assert, step, log }) {
|
||||
|
||||
await step('setup: открыть обработку ДеревоНоменклатуры', async () => {
|
||||
const r = await navigateLink('Обработка.ДеревоНоменклатуры');
|
||||
log(`form=${r.form} activeTab=${r.activeTab}`);
|
||||
assert.equal(r.activeTab, 'Дерево номенклатуры', 'форма открыта');
|
||||
assert.ok(r.tables?.some(t => t.name === 'Дерево'), 'таблица Дерево присутствует');
|
||||
});
|
||||
|
||||
await step('read-roots: на верхнем уровне видны 2 группы (Товары, Услуги)', async () => {
|
||||
const t = await readTable('Дерево');
|
||||
log(`columns=${t.columns?.join(',')} rows=${t.rows?.length}`);
|
||||
assert.deepEqual(t.columns, ['Номенклатура', 'Цена'], 'колонки: Номенклатура + Цена');
|
||||
assert.equal(t.rows.length, 2, '2 корневые строки');
|
||||
const names = t.rows.map(r => r['Номенклатура']);
|
||||
assert.includes(names, 'Товары', 'есть Товары');
|
||||
assert.includes(names, 'Услуги', 'есть Услуги');
|
||||
assert.ok(t.rows.every(r => r._kind === 'group'), 'обе корневые — group (есть expand-стрелка)');
|
||||
});
|
||||
|
||||
await step('expand: clickElement({expand}) раскрывает Товары — 15 элементов', async () => {
|
||||
const r = await clickElement('Товары', { expand: true });
|
||||
log(`clicked: ${JSON.stringify(r.clicked)}`);
|
||||
assert.equal(r.clicked?.toggled, true, 'expand toggled');
|
||||
const t = await readTable('Дерево');
|
||||
log(`after expand: total=${t.total}`);
|
||||
assert.ok(t.total >= 16, `Товары + 15 элементов (got ${t.total})`);
|
||||
const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01');
|
||||
assert.ok(tovar01, 'Товар 01 виден внутри Товары');
|
||||
assert.equal(tovar01['Цена'], '100,00', 'исходная Цена 100,00 (из справочника)');
|
||||
});
|
||||
|
||||
await step('tree-edit: fillTableRow меняет Цену в развёрнутой группе', async () => {
|
||||
// row:1 — это Товар 01 (row:0 — Товары после expand). Используем index, т.к.
|
||||
// fillTableRow{row:'Товар 01'} ловит SyntaxError в JS-эвале — TODO в bug list.
|
||||
const r = await fillTableRow({ Цена: 1500 }, { row: 1 });
|
||||
log(`filled: ${JSON.stringify(r.filled)}`);
|
||||
assert.equal(r.filled?.length, 1, '1 поле заполнено');
|
||||
assert.equal(r.filled[0].field, 'Цена', 'поле Цена');
|
||||
assert.equal(r.filled[0].method, 'direct', 'method=direct (in-place edit)');
|
||||
assert.equal(r.filled[0].ok, true, 'ok=true');
|
||||
const t = await readTable('Дерево');
|
||||
const tovar01 = t.rows.find(row => row['Номенклатура'] === 'Товар 01');
|
||||
assert.ok(tovar01, 'Товар 01 виден');
|
||||
// 1С web использует non-breaking space ( ) как разделитель разрядов
|
||||
assert.equal(tovar01['Цена'], '1 500,00', 'Цена обновилась до 1 500,00');
|
||||
});
|
||||
|
||||
await step('cleanup: закрыть форму', async () => {
|
||||
await closeForm();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"name": "License pool exhausted (1C)",
|
||||
"matchedStatuses": ["failed", "broken"],
|
||||
"messageRegex": ".*Не обнаружено свободной лицензии.*"
|
||||
},
|
||||
{
|
||||
"name": "1C application error (modal)",
|
||||
"matchedStatuses": ["failed"],
|
||||
"messageRegex": ".*(ВызватьИсключение|В поле введены некорректные данные|Произошла ошибка|Ошибка при вызове).*"
|
||||
},
|
||||
{
|
||||
"name": "Section panel icon-only (stand state)",
|
||||
"matchedStatuses": ["failed"],
|
||||
"messageRegex": ".*icon-only mode.*"
|
||||
},
|
||||
{
|
||||
"name": "Navigation lookup miss",
|
||||
"matchedStatuses": ["failed"],
|
||||
"messageRegex": ".*(navigateSection|openCommand|navigateLink|switchTab).*not found.*"
|
||||
},
|
||||
{
|
||||
"name": "Element not found",
|
||||
"matchedStatuses": ["failed"],
|
||||
"messageRegex": ".*(clickElement|fillField|fillFields|selectValue|closeForm|fillTableRow|deleteTableRow).*not found.*"
|
||||
},
|
||||
{
|
||||
"name": "Test timeout",
|
||||
"matchedStatuses": ["failed", "broken"],
|
||||
"messageRegex": "Timeout \\(\\d+ms\\)"
|
||||
},
|
||||
{
|
||||
"name": "Assertion failure",
|
||||
"matchedStatuses": ["failed"],
|
||||
"messageRegex": "(Expected|AssertionError|Field \".*\" not found in form|Form title .*does not contain|No row matching predicate|Form has errors).*"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,419 @@
|
||||
// _hooks.mjs v0.5 — автономный стенд + testlevel-хуки + title slides + per-context badge
|
||||
//
|
||||
// `prepare()` поднимает изолированный стенд по smart-логике:
|
||||
// 1) Если нужно пересоздавать БД (config-rebuild или --reload-data) — web-stop
|
||||
// (Apache держит блокировку БД).
|
||||
// 2) [config-hash изменился или --rebuild-config] → пересобрать XML.
|
||||
// 3) [нужна пересборка БД] → drop+create+load+update.
|
||||
// 4) [epf-hash изменился или --rebuild-epf] → пересобрать EPF.
|
||||
// 5) Apache:
|
||||
// - если БД пересоздавалась → web-publish + probe ready.
|
||||
// - иначе probe-first: жив → ничего не делаем; мёртв → publish + probe.
|
||||
//
|
||||
// Идемпотентность — через sha256-локи в `tests/skills/.cache/webtest-stand/`.
|
||||
// На warm-старте (ничего не менялось, Apache жив) prepare() сводится к ~200ms:
|
||||
// чтение локов + probe.
|
||||
//
|
||||
// Поддерживаемые hookArgs (`node run.mjs test ... -- <args>`):
|
||||
// --rebuild-config принудительно пересобрать XML + БД
|
||||
// --reload-data принудительно пересоздать БД из существующего XML
|
||||
// --rebuild-epf принудительно пересобрать EPF
|
||||
// --rebuild-stand эквивалент всех трёх флагов сразу
|
||||
//
|
||||
// Cross-platform: на не-Windows можно задать env WEBTEST_HOOKS_RUNTIME=python,
|
||||
// тогда зеркальные py-порты скиллов будут вызваны вместо ps1.
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, statSync } from 'fs';
|
||||
import { join, resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
getProjectInfo,
|
||||
loadBuildSteps,
|
||||
platformLoadSteps,
|
||||
runSteps,
|
||||
execSkill,
|
||||
resolveScript,
|
||||
} from '../skills/build-webtest-db.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const REPO_ROOT = resolve(dirname(__filename), '../..');
|
||||
const LOCK_DIR = join(REPO_ROOT, 'tests/skills/.cache/webtest-stand');
|
||||
|
||||
// ── Configurable knobs ─────────────────────────────────────────────────────────
|
||||
|
||||
const APACHE_APPNAME = 'webtest-runner';
|
||||
const APACHE_PORT = 9191;
|
||||
const READY_URL = `http://localhost:${APACHE_PORT}/${APACHE_APPNAME}/ru_RU/`;
|
||||
const READY_TIMEOUT = 30_000;
|
||||
const RUNTIME = process.env.WEBTEST_HOOKS_RUNTIME || 'powershell';
|
||||
|
||||
// EPF spec: версионируется через epf.lock (sha256 от JSON.stringify(this)).
|
||||
// Любое изменение → автоматический rebuild.
|
||||
const EPF_SPEC = {
|
||||
v8path: 'C:\\Program Files\\1cv8\\8.3.24.1691\\bin',
|
||||
srcDir: 'test-tmp/13-openfile/src',
|
||||
buildDir: 'test-tmp/13-openfile/build',
|
||||
name: 'ТестОткрытия',
|
||||
synonym: 'Тест открытия из файла',
|
||||
formName: 'Форма',
|
||||
form: {
|
||||
title: 'Тест открытия',
|
||||
elements: [
|
||||
{ label: 'Заголовок', title: 'Это тестовая обработка для проверки openFile' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Args parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
function parseHookArgs(hookArgs) {
|
||||
const out = { rebuildConfig: false, reloadData: false, rebuildEpf: false, rebuildStand: false };
|
||||
for (const a of hookArgs || []) {
|
||||
if (a === '--rebuild-config') out.rebuildConfig = true;
|
||||
else if (a === '--reload-data') out.reloadData = true;
|
||||
else if (a === '--rebuild-epf') out.rebuildEpf = true;
|
||||
else if (a === '--rebuild-stand') out.rebuildStand = true;
|
||||
}
|
||||
if (out.rebuildStand) {
|
||||
out.rebuildConfig = true;
|
||||
out.reloadData = true;
|
||||
out.rebuildEpf = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Hash-lock helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function sha256(s) {
|
||||
return createHash('sha256').update(s, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function readLock(name) {
|
||||
const f = join(LOCK_DIR, `${name}.lock`);
|
||||
return existsSync(f) ? readFileSync(f, 'utf8').trim() : null;
|
||||
}
|
||||
|
||||
function writeLock(name, hash) {
|
||||
mkdirSync(LOCK_DIR, { recursive: true });
|
||||
writeFileSync(join(LOCK_DIR, `${name}.lock`), hash + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ── Apache helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function webStop(log) {
|
||||
try {
|
||||
const script = resolveScript('web-stop/scripts/web-stop', RUNTIME);
|
||||
await execSkill(script, [], RUNTIME);
|
||||
log('apache stopped');
|
||||
} catch (e) {
|
||||
log(`apache stop: ${e.message.split('\n')[0]}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function webPublish(dbPath, v8path, log) {
|
||||
const script = resolveScript('web-publish/scripts/web-publish', RUNTIME);
|
||||
await execSkill(script, [
|
||||
'-InfoBasePath', dbPath,
|
||||
'-V8Path', v8path,
|
||||
'-Port', String(APACHE_PORT),
|
||||
'-AppName', APACHE_APPNAME,
|
||||
], RUNTIME);
|
||||
log(`apache published: ${READY_URL}`);
|
||||
}
|
||||
|
||||
async function probeReady(url, timeoutMs, log) {
|
||||
const t0 = Date.now();
|
||||
let attempt = 0;
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
attempt++;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
||||
if (res.status >= 200 && res.status < 500) {
|
||||
log(`ready after ${((Date.now() - t0) / 1000).toFixed(1)}s (status=${res.status}, attempts=${attempt})`);
|
||||
return;
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Apache not ready at ${url} within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
// Лёгкий probe для одной попытки — для проверки «жив ли Apache уже сейчас».
|
||||
// Возвращает true если в течение `timeoutMs` пришёл ответ 200-499 (т.е. сервер
|
||||
// откликается). Не бросает — fail-quiet.
|
||||
async function probeAlive(url, timeoutMs = 1500) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
return res.status >= 200 && res.status < 500;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── EPF build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function buildEpf(spec, log) {
|
||||
const srcDir = resolve(REPO_ROOT, spec.srcDir);
|
||||
const buildDir = resolve(REPO_ROOT, spec.buildDir);
|
||||
const srcXml = join(srcDir, `${spec.name}.xml`);
|
||||
const epfPath = join(buildDir, `${spec.name}.epf`);
|
||||
const formDir = join(srcDir, `${spec.name}/Forms/${spec.formName}`);
|
||||
const formXml = join(formDir, 'Ext/Form.xml');
|
||||
|
||||
// Полный rebuild: чистим и собираем заново.
|
||||
if (existsSync(srcDir)) rmSync(srcDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
// 1. epf-init
|
||||
await execSkill(
|
||||
resolveScript('epf-init/scripts/init', RUNTIME),
|
||||
['-Name', spec.name, '-Synonym', spec.synonym, '-SrcDir', srcDir],
|
||||
RUNTIME,
|
||||
);
|
||||
log('epf-init OK');
|
||||
|
||||
// 2. form-add
|
||||
await execSkill(
|
||||
resolveScript('form-add/scripts/form-add', RUNTIME),
|
||||
['-ObjectPath', srcXml, '-FormName', spec.formName],
|
||||
RUNTIME,
|
||||
);
|
||||
log('form-add OK');
|
||||
|
||||
// 3. form-compile
|
||||
const formJsonPath = join(buildDir, '__form.json');
|
||||
writeFileSync(formJsonPath, JSON.stringify(spec.form, null, 2), 'utf8');
|
||||
await execSkill(
|
||||
resolveScript('form-compile/scripts/form-compile', RUNTIME),
|
||||
['-JsonPath', formJsonPath, '-OutputPath', formXml],
|
||||
RUNTIME,
|
||||
);
|
||||
rmSync(formJsonPath);
|
||||
log('form-compile OK');
|
||||
|
||||
// 4. epf-build
|
||||
await execSkill(
|
||||
resolveScript('epf-build/scripts/epf-build', RUNTIME),
|
||||
['-SourceFile', srcXml, '-OutputFile', epfPath, '-V8Path', spec.v8path],
|
||||
RUNTIME,
|
||||
);
|
||||
if (!existsSync(epfPath)) throw new Error(`epf-build did not produce ${epfPath}`);
|
||||
log(`epf-build OK (${statSync(epfPath).size} bytes)`);
|
||||
return epfPath;
|
||||
}
|
||||
|
||||
function epfArtifactExists(spec) {
|
||||
const epfPath = resolve(REPO_ROOT, spec.buildDir, `${spec.name}.epf`);
|
||||
return existsSync(epfPath);
|
||||
}
|
||||
|
||||
// ── prepare / cleanup ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function prepare({ hookArgs, log, config }) {
|
||||
const flags = parseHookArgs(hookArgs);
|
||||
const t0 = Date.now();
|
||||
log(`stand prepare: flags=${JSON.stringify(flags)} runtime=${RUNTIME}`);
|
||||
|
||||
// Project info (paths, db registration)
|
||||
const { v8path, v8exe, configSrc, dbPath } = getProjectInfo();
|
||||
if (!existsSync(v8exe)) throw new Error(`1cv8.exe not found at ${v8exe} (check .v8-project.json v8path)`);
|
||||
|
||||
// Hashes
|
||||
const buildSteps = await loadBuildSteps();
|
||||
const configHash = sha256(JSON.stringify(buildSteps));
|
||||
const epfHash = sha256(JSON.stringify(EPF_SPEC));
|
||||
const prevConfig = readLock('config');
|
||||
const prevEpf = readLock('epf');
|
||||
|
||||
const needConfig = flags.rebuildConfig || prevConfig !== configHash;
|
||||
const needData = needConfig || flags.reloadData;
|
||||
const needEpf = flags.rebuildEpf || prevEpf !== epfHash || !epfArtifactExists(EPF_SPEC);
|
||||
|
||||
log(`config-hash=${configHash.slice(0, 12)}... prev=${prevConfig?.slice(0, 12) || 'none'}... ${needConfig ? 'REBUILD' : 'skip'}`);
|
||||
log(`epf-hash=${epfHash.slice(0, 12)}... prev=${prevEpf?.slice(0, 12) || 'none'}... ${needEpf ? 'REBUILD' : 'skip'}`);
|
||||
log(`data-${needData ? 'RELOAD' : 'skip'}`);
|
||||
|
||||
// 1. Apache stop — только если будем пересоздавать БД (Apache держит lock-файл).
|
||||
// На warm-старте (никаких rebuild) — НЕ трогаем Apache, иначе впустую отдадим
|
||||
// 5-8 секунд на restart при каждом прогоне.
|
||||
if (needData) {
|
||||
await webStop(log);
|
||||
}
|
||||
|
||||
// 2. Config rebuild
|
||||
if (needConfig) {
|
||||
log(`rebuild config XML → ${configSrc}`);
|
||||
if (existsSync(configSrc)) rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
mkdirSync(configSrc, { recursive: true });
|
||||
const paths = { workDir: configSrc, v8path, dbPath };
|
||||
const r = await runSteps(buildSteps, paths, RUNTIME, log);
|
||||
if (!r.ok) throw new Error(`config rebuild failed at step #${r.failedAt + 1}`);
|
||||
writeLock('config', configHash);
|
||||
}
|
||||
|
||||
// 3. DB reload
|
||||
if (needData) {
|
||||
log(`reload DB → ${dbPath}`);
|
||||
if (existsSync(dbPath)) rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
const paths = { workDir: configSrc, v8path, dbPath };
|
||||
const r = await runSteps(platformLoadSteps(), paths, RUNTIME, log);
|
||||
if (!r.ok) throw new Error(`DB reload failed at step #${r.failedAt + 1}`);
|
||||
}
|
||||
|
||||
// 4. EPF rebuild
|
||||
if (needEpf) {
|
||||
log('rebuild EPF');
|
||||
await buildEpf(EPF_SPEC, log);
|
||||
writeLock('epf', epfHash);
|
||||
}
|
||||
|
||||
// 5. Apache: publish + probe (smart logic)
|
||||
// - needData=true → Apache был остановлен в #1, нужно публиковать заново
|
||||
// - needData=false → probe сначала: если жив, ничего не делаем (warm-старт);
|
||||
// если мёртв (упал/не поднимали) → publish
|
||||
if (needData) {
|
||||
await webPublish(dbPath, v8path, log);
|
||||
await probeReady(READY_URL, READY_TIMEOUT, log);
|
||||
} else if (await probeAlive(READY_URL)) {
|
||||
log(`apache already live at ${READY_URL} (warm start)`);
|
||||
} else {
|
||||
log(`apache not responding — publishing`);
|
||||
await webPublish(dbPath, v8path, log);
|
||||
await probeReady(READY_URL, READY_TIMEOUT, log);
|
||||
}
|
||||
|
||||
log(`stand ready in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||||
}
|
||||
|
||||
export async function cleanup({ log }) {
|
||||
// MVP: оставляем стенд поднятым для отладки. Для full-shutdown — ручной /web-stop
|
||||
// или следующий запуск с --rebuild-stand.
|
||||
log('cleanup: stand left running (use /web-stop or run with `-- --rebuild-stand` to reset)');
|
||||
}
|
||||
|
||||
// ── Testlevel hooks (M7.4) ────────────────────────────────────────────────────
|
||||
//
|
||||
// Shared mutable state, импортируется индикатором `00-hooks.test.mjs` для
|
||||
// проверки порядка вызовов. Хуки — counter-only, никакой реальной работы:
|
||||
// `prepare()` уже подготовил стенд, дефолтное приземление 1С после входа
|
||||
// уже показывает панель разделов (разведка 2026-05-13: navigateSection
|
||||
// в beforeAll не нужен).
|
||||
//
|
||||
// `events` — последовательность строк, по которой индикатор восстанавливает
|
||||
// порядок (`beforeAll`, `beforeEach:01-navigation.test.mjs`, ...).
|
||||
|
||||
export const _state = {
|
||||
beforeAll: 0,
|
||||
afterAll: 0,
|
||||
beforeEach: 0,
|
||||
afterEach: 0,
|
||||
afterOpenContext: 0,
|
||||
beforeCloseContext: 0,
|
||||
events: [],
|
||||
lastTestResult: null,
|
||||
};
|
||||
|
||||
export async function beforeAll(_ctx) {
|
||||
_state.beforeAll++;
|
||||
_state.events.push('beforeAll');
|
||||
}
|
||||
|
||||
export async function afterAll(_ctx) {
|
||||
_state.afterAll++;
|
||||
_state.events.push('afterAll');
|
||||
}
|
||||
|
||||
// Длительность показа title slide перед телом теста (секунды). Эмпирически
|
||||
// 1.5с хватает чтобы в записанном видео слайд успел зацепиться кадром,
|
||||
// и не слишком долго на тестах вроде 14-routing (~2.5с целиком).
|
||||
const TITLE_SLIDE_SEC = 1.5;
|
||||
|
||||
export async function beforeEach(ctx) {
|
||||
_state.beforeEach++;
|
||||
_state.events.push(`beforeEach:${ctx.testInfo?.file || '?'}`);
|
||||
|
||||
// M7.5: title slide для `--record`-прогонов. Под обычным регрессом
|
||||
// (isRecording === false) пропускаем — лишние ~1.5s × N тестов
|
||||
// не нужны.
|
||||
if (ctx.isRecording?.()) {
|
||||
const info = ctx.testInfo;
|
||||
const primary = info.contexts?.[info.primaryContext];
|
||||
const subtitle = primary?.displayName || '';
|
||||
try {
|
||||
await ctx.showTitleSlide(info.name, { subtitle });
|
||||
await ctx.wait(TITLE_SLIDE_SEC);
|
||||
await ctx.hideTitleSlide();
|
||||
} catch {
|
||||
// Не валим тест из-за оформления — recorder/page-state могут
|
||||
// не сложиться в редких сценариях (race на старте контекста).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function afterEach(ctx) {
|
||||
_state.afterEach++;
|
||||
// Снимок testResult без тяжёлого steps[]: индикатор проверяет только
|
||||
// status/duration/attempts/error.
|
||||
if (ctx.testResult) {
|
||||
const { status, duration, attempts, error } = ctx.testResult;
|
||||
_state.lastTestResult = { status, duration, attempts, error };
|
||||
} else {
|
||||
_state.lastTestResult = null;
|
||||
}
|
||||
_state.events.push(`afterEach:${ctx.testInfo?.file || '?'}:${ctx.testResult?.status || '?'}`);
|
||||
}
|
||||
|
||||
// ── Per-context hooks (M8) ────────────────────────────────────────────────────
|
||||
//
|
||||
// `afterOpenContext` инжектит persistent DOM-badge с displayName в правый
|
||||
// верхний угол страницы контекста — в записанном видео всегда видно, какая
|
||||
// вкладка к какому пользователю относится. Badge переживает любые
|
||||
// перерисовки 1С (это собственный div с z-index, не часть SPA).
|
||||
//
|
||||
// `beforeCloseContext` — counter-only (страница вот-вот закроется, делать
|
||||
// что-либо с DOM бессмысленно).
|
||||
|
||||
async function injectContextBadge(ctx, name, spec) {
|
||||
const label = spec?.displayName || name;
|
||||
// ctx может быть scoped (auto-setActiveContext) или flat — в любом случае
|
||||
// getPage() возвращает активную страницу, которая на момент afterOpenContext
|
||||
// = только что созданный контекст.
|
||||
const page = ctx.getPage?.();
|
||||
if (!page) return;
|
||||
await page.evaluate((text) => {
|
||||
let div = document.getElementById('__web_test_ctx_badge');
|
||||
if (!div) {
|
||||
div = document.createElement('div');
|
||||
div.id = '__web_test_ctx_badge';
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
div.style.cssText = [
|
||||
'position:fixed', 'top:8px', 'right:8px',
|
||||
'padding:4px 10px',
|
||||
'background:rgba(30,30,46,0.85)', 'color:#fff',
|
||||
'font:600 13px Segoe UI,Arial,sans-serif',
|
||||
'border-radius:4px', 'box-shadow:0 2px 6px rgba(0,0,0,0.25)',
|
||||
'z-index:999998', 'pointer-events:none',
|
||||
'letter-spacing:0.3px',
|
||||
].join(';');
|
||||
div.textContent = text;
|
||||
}, label);
|
||||
}
|
||||
|
||||
export async function afterOpenContext(ctx, name, spec) {
|
||||
_state.afterOpenContext++;
|
||||
_state.events.push(`afterOpenContext:${name}`);
|
||||
try {
|
||||
await injectContextBadge(ctx, name, spec);
|
||||
} catch {
|
||||
// Не валим прогон если badge не сел — это чисто визуальный bonus.
|
||||
}
|
||||
}
|
||||
|
||||
export async function beforeCloseContext(_ctx, name, _spec) {
|
||||
_state.beforeCloseContext++;
|
||||
_state.events.push(`beforeCloseContext:${name}`);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Default config for tests/web-test. CLI URL still overrides defaultContext URL.
|
||||
// Two contexts pointing at the same webtest publication — represent two independent
|
||||
// 1C sessions (different cookies), used by multi-context tests to simulate two users.
|
||||
//
|
||||
// AppName `webtest-runner` отличается от интерактивной публикации `webtest` на :8081 —
|
||||
// автономный стенд (см. tests/web-test/_hooks.mjs) использует свой URL, чтобы не
|
||||
// конфликтовать с ручной разведкой и работать поверх отдельного Apache на :9191.
|
||||
export default {
|
||||
contexts: {
|
||||
// `displayName` — человекочитаемое имя контекста, видно хукам через
|
||||
// testInfo.contexts[name].displayName (например для showTitleSlide).
|
||||
// Custom-поля любого типа пробрасываются как есть.
|
||||
a: { url: 'http://localhost:9191/webtest-runner/ru_RU', displayName: 'Пользователь A' },
|
||||
b: { url: 'http://localhost:9191/webtest-runner/ru_RU', displayName: 'Пользователь B' },
|
||||
},
|
||||
defaultContext: 'a',
|
||||
// isolation: 'tab' (default) — persistent context, tabs in one window, 1С extension loads.
|
||||
// Cookies are shared between tabs but scope by URL path, so different vrd-publications
|
||||
// give independent auth without extra isolation.
|
||||
// isolation: 'window' — separate BrowserContext per slot, full cookie isolation,
|
||||
// extension may not load (Playwright limitation). Use only when really needed.
|
||||
timeout: 60000,
|
||||
|
||||
// Allure severity policy: inverted map "уровень → теги, попадающие в этот уровень".
|
||||
// Резолв (run.mjs:resolveSeverity):
|
||||
// 1. explicit `export const severity` в тесте — выигрывает всегда;
|
||||
// 2. иначе max-rank среди тегов теста (стандартное имя severity или маппинг ниже);
|
||||
// 3. иначе `defaultSeverity`.
|
||||
// Тег не может быть в двух bucket'ах одновременно — валидация при загрузке конфига.
|
||||
severity: {
|
||||
critical: ['smoke', 'multi-context'],
|
||||
minor: ['recording'],
|
||||
// blocker / trivial — пустые, не используем
|
||||
},
|
||||
defaultSeverity: 'normal',
|
||||
};
|
||||
Reference in New Issue
Block a user