@application-kit/acceptance 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/cjs/bin/application-scenarios.d.ts +2 -0
- package/dist/cjs/bin/application-scenarios.js +15 -0
- package/dist/cjs/bin/index.d.ts +1 -0
- package/dist/cjs/bin/index.js +17 -0
- package/dist/cjs/cadence/index.d.ts +1 -0
- package/dist/cjs/cadence/index.js +17 -0
- package/dist/cjs/cadence/resolve-cadence.d.ts +8 -0
- package/dist/cjs/cadence/resolve-cadence.js +18 -0
- package/dist/cjs/environments/environment.interface.d.ts +5 -0
- package/dist/cjs/environments/environment.interface.js +2 -0
- package/dist/cjs/environments/index.d.ts +3 -0
- package/dist/cjs/environments/index.js +19 -0
- package/dist/cjs/environments/resolve-environment.d.ts +10 -0
- package/dist/cjs/environments/resolve-environment.js +20 -0
- package/dist/cjs/environments/secrets.d.ts +4 -0
- package/dist/cjs/environments/secrets.js +14 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +18 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/scenarios/acceptance-class.d.ts +26 -0
- package/dist/cjs/scenarios/acceptance-class.js +100 -0
- package/dist/cjs/scenarios/board.d.ts +6 -0
- package/dist/cjs/scenarios/board.js +98 -0
- package/dist/cjs/scenarios/classes.d.ts +16 -0
- package/dist/cjs/scenarios/classes.js +48 -0
- package/dist/cjs/scenarios/classify.d.ts +35 -0
- package/dist/cjs/scenarios/classify.js +108 -0
- package/dist/cjs/scenarios/config.d.ts +17 -0
- package/dist/cjs/scenarios/config.js +67 -0
- package/dist/cjs/scenarios/coverage-data.d.ts +53 -0
- package/dist/cjs/scenarios/coverage-data.js +118 -0
- package/dist/cjs/scenarios/coverage-json.d.ts +2 -0
- package/dist/cjs/scenarios/coverage-json.js +94 -0
- package/dist/cjs/scenarios/coverage-open.d.ts +16 -0
- package/dist/cjs/scenarios/coverage-open.js +76 -0
- package/dist/cjs/scenarios/coverage-page.d.ts +2 -0
- package/dist/cjs/scenarios/coverage-page.js +108 -0
- package/dist/cjs/scenarios/coverage-removed.d.ts +13 -0
- package/dist/cjs/scenarios/coverage-removed.js +30 -0
- package/dist/cjs/scenarios/coverage.d.ts +26 -0
- package/dist/cjs/scenarios/coverage.js +91 -0
- package/dist/cjs/scenarios/dates.d.ts +8 -0
- package/dist/cjs/scenarios/dates.js +93 -0
- package/dist/cjs/scenarios/declared.d.ts +18 -0
- package/dist/cjs/scenarios/declared.js +69 -0
- package/dist/cjs/scenarios/features.d.ts +13 -0
- package/dist/cjs/scenarios/features.js +112 -0
- package/dist/cjs/scenarios/gherkin.d.ts +10 -0
- package/dist/cjs/scenarios/gherkin.js +75 -0
- package/dist/cjs/scenarios/git.d.ts +15 -0
- package/dist/cjs/scenarios/git.js +83 -0
- package/dist/cjs/scenarios/index.d.ts +27 -0
- package/dist/cjs/scenarios/index.js +43 -0
- package/dist/cjs/scenarios/inventory.d.ts +8 -0
- package/dist/cjs/scenarios/inventory.js +55 -0
- package/dist/cjs/scenarios/links.d.ts +4 -0
- package/dist/cjs/scenarios/links.js +99 -0
- package/dist/cjs/scenarios/lint-inputs.d.ts +12 -0
- package/dist/cjs/scenarios/lint-inputs.js +118 -0
- package/dist/cjs/scenarios/lint.d.ts +8 -0
- package/dist/cjs/scenarios/lint.js +94 -0
- package/dist/cjs/scenarios/projects.d.ts +21 -0
- package/dist/cjs/scenarios/projects.js +104 -0
- package/dist/cjs/scenarios/python-text.d.ts +11 -0
- package/dist/cjs/scenarios/python-text.js +95 -0
- package/dist/cjs/scenarios/remote.d.ts +6 -0
- package/dist/cjs/scenarios/remote.js +29 -0
- package/dist/cjs/scenarios/removed-list.d.ts +15 -0
- package/dist/cjs/scenarios/removed-list.js +62 -0
- package/dist/cjs/scenarios/report.d.ts +6 -0
- package/dist/cjs/scenarios/report.js +10 -0
- package/dist/cjs/scenarios/run.d.ts +10 -0
- package/dist/cjs/scenarios/run.js +145 -0
- package/dist/cjs/scenarios/task-links.d.ts +15 -0
- package/dist/cjs/scenarios/task-links.js +54 -0
- package/dist/esm/bin/application-scenarios.d.ts +2 -0
- package/dist/esm/bin/application-scenarios.js +10 -0
- package/dist/esm/bin/index.d.ts +1 -0
- package/dist/esm/bin/index.js +1 -0
- package/dist/esm/cadence/index.d.ts +1 -0
- package/dist/esm/cadence/index.js +1 -0
- package/dist/esm/cadence/resolve-cadence.d.ts +8 -0
- package/dist/esm/cadence/resolve-cadence.js +14 -0
- package/dist/esm/environments/environment.interface.d.ts +5 -0
- package/dist/esm/environments/environment.interface.js +1 -0
- package/dist/esm/environments/index.d.ts +3 -0
- package/dist/esm/environments/index.js +3 -0
- package/dist/esm/environments/resolve-environment.d.ts +10 -0
- package/dist/esm/environments/resolve-environment.js +16 -0
- package/dist/esm/environments/secrets.d.ts +4 -0
- package/dist/esm/environments/secrets.js +9 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/scenarios/acceptance-class.d.ts +26 -0
- package/dist/esm/scenarios/acceptance-class.js +93 -0
- package/dist/esm/scenarios/board.d.ts +6 -0
- package/dist/esm/scenarios/board.js +92 -0
- package/dist/esm/scenarios/classes.d.ts +16 -0
- package/dist/esm/scenarios/classes.js +42 -0
- package/dist/esm/scenarios/classify.d.ts +35 -0
- package/dist/esm/scenarios/classify.js +104 -0
- package/dist/esm/scenarios/config.d.ts +17 -0
- package/dist/esm/scenarios/config.js +58 -0
- package/dist/esm/scenarios/coverage-data.d.ts +53 -0
- package/dist/esm/scenarios/coverage-data.js +109 -0
- package/dist/esm/scenarios/coverage-json.d.ts +2 -0
- package/dist/esm/scenarios/coverage-json.js +90 -0
- package/dist/esm/scenarios/coverage-open.d.ts +16 -0
- package/dist/esm/scenarios/coverage-open.js +71 -0
- package/dist/esm/scenarios/coverage-page.d.ts +2 -0
- package/dist/esm/scenarios/coverage-page.js +104 -0
- package/dist/esm/scenarios/coverage-removed.d.ts +13 -0
- package/dist/esm/scenarios/coverage-removed.js +26 -0
- package/dist/esm/scenarios/coverage.d.ts +26 -0
- package/dist/esm/scenarios/coverage.js +84 -0
- package/dist/esm/scenarios/dates.d.ts +8 -0
- package/dist/esm/scenarios/dates.js +87 -0
- package/dist/esm/scenarios/declared.d.ts +18 -0
- package/dist/esm/scenarios/declared.js +62 -0
- package/dist/esm/scenarios/features.d.ts +13 -0
- package/dist/esm/scenarios/features.js +96 -0
- package/dist/esm/scenarios/gherkin.d.ts +10 -0
- package/dist/esm/scenarios/gherkin.js +68 -0
- package/dist/esm/scenarios/git.d.ts +15 -0
- package/dist/esm/scenarios/git.js +72 -0
- package/dist/esm/scenarios/index.d.ts +27 -0
- package/dist/esm/scenarios/index.js +27 -0
- package/dist/esm/scenarios/inventory.d.ts +8 -0
- package/dist/esm/scenarios/inventory.js +48 -0
- package/dist/esm/scenarios/links.d.ts +4 -0
- package/dist/esm/scenarios/links.js +95 -0
- package/dist/esm/scenarios/lint-inputs.d.ts +12 -0
- package/dist/esm/scenarios/lint-inputs.js +110 -0
- package/dist/esm/scenarios/lint.d.ts +8 -0
- package/dist/esm/scenarios/lint.js +89 -0
- package/dist/esm/scenarios/projects.d.ts +21 -0
- package/dist/esm/scenarios/projects.js +93 -0
- package/dist/esm/scenarios/python-text.d.ts +11 -0
- package/dist/esm/scenarios/python-text.js +81 -0
- package/dist/esm/scenarios/remote.d.ts +6 -0
- package/dist/esm/scenarios/remote.js +24 -0
- package/dist/esm/scenarios/removed-list.d.ts +15 -0
- package/dist/esm/scenarios/removed-list.js +57 -0
- package/dist/esm/scenarios/report.d.ts +6 -0
- package/dist/esm/scenarios/report.js +6 -0
- package/dist/esm/scenarios/run.d.ts +10 -0
- package/dist/esm/scenarios/run.js +141 -0
- package/dist/esm/scenarios/task-links.d.ts +15 -0
- package/dist/esm/scenarios/task-links.js +50 -0
- package/package.json +65 -3
- package/README.md +0 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { markPending, marks, markTodo } from './config.js';
|
|
3
|
+
import { parseFeature } from './gherkin.js';
|
|
4
|
+
import { git } from './git.js';
|
|
5
|
+
import { compareText, roundTenth } from './python-text.js';
|
|
6
|
+
import { blobUrl, webUrl } from './remote.js';
|
|
7
|
+
import { linkToken } from './task-links.js';
|
|
8
|
+
export const uncoveredOrder = ['off-run', 'todo-only', 'no-scenario'];
|
|
9
|
+
export const uncoveredLabels = {
|
|
10
|
+
'no-scenario': 'без сценария',
|
|
11
|
+
'off-run': 'вне прогона',
|
|
12
|
+
'todo-only': `только долг @${markTodo}`,
|
|
13
|
+
};
|
|
14
|
+
const uncoveredOf = {
|
|
15
|
+
'без сценария': 'no-scenario',
|
|
16
|
+
'все сняты': 'no-scenario',
|
|
17
|
+
'с тестом': 'off-run',
|
|
18
|
+
'только долг': 'todo-only',
|
|
19
|
+
};
|
|
20
|
+
const repositoryRoot = '.';
|
|
21
|
+
export const liveScenarios = (ids, scenarios) => ids
|
|
22
|
+
.filter((id) => !scenarios.removed.has(id))
|
|
23
|
+
.flatMap((id) => scenarios.ids.get(id)?.slice(0, 1) ?? []);
|
|
24
|
+
const isInRun = (story, scenarios) => liveScenarios(story.ids, scenarios).some((scenario) => scenario.tags.every((tag) => !marks.includes(tag)));
|
|
25
|
+
const uncoveredStory = (item, location, story) => ({
|
|
26
|
+
file: story.file,
|
|
27
|
+
project: item.project.name,
|
|
28
|
+
scenarios: story.ids,
|
|
29
|
+
state: uncoveredOf[story.state],
|
|
30
|
+
story: story.key,
|
|
31
|
+
url: blobUrl(location, story.file),
|
|
32
|
+
});
|
|
33
|
+
const uncoveredStories = async (item, scenarios) => {
|
|
34
|
+
const location = { commit: item.project.commit, web: await webUrl(scenarios.cwd, item.project.repo) };
|
|
35
|
+
return item.stories
|
|
36
|
+
.filter((story) => story.state !== 'все сняты' && !isInRun(story, scenarios))
|
|
37
|
+
.map((story) => uncoveredStory(item, location, story));
|
|
38
|
+
};
|
|
39
|
+
const stateCounts = (uncovered) => {
|
|
40
|
+
const count = (state) => uncovered.filter((story) => story.state === state).length;
|
|
41
|
+
return { 'no-scenario': count('no-scenario'), 'off-run': count('off-run'), 'todo-only': count('todo-only') };
|
|
42
|
+
};
|
|
43
|
+
const shareOf = (covered, denominator) => (denominator === 0 ? undefined : roundTenth((100 * covered) / denominator));
|
|
44
|
+
const projectOf = (item, uncovered) => {
|
|
45
|
+
const { counts } = item;
|
|
46
|
+
const denominator = counts.e2e - counts['все сняты'];
|
|
47
|
+
const covered = denominator - uncovered.length;
|
|
48
|
+
return {
|
|
49
|
+
allRemoved: counts['все сняты'],
|
|
50
|
+
closed: item.closed,
|
|
51
|
+
codeTests: counts['code-tests'],
|
|
52
|
+
commit: item.project.commit ?? '',
|
|
53
|
+
covered,
|
|
54
|
+
denominator,
|
|
55
|
+
e2e: counts.e2e,
|
|
56
|
+
none: counts.none,
|
|
57
|
+
project: item.project.name,
|
|
58
|
+
revision: item.project.revision ?? '',
|
|
59
|
+
share: shareOf(covered, denominator),
|
|
60
|
+
unclassified: counts['без класса'],
|
|
61
|
+
uncovered: stateCounts(uncovered),
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
export const coverageOf = async (classified, scenarios) => {
|
|
65
|
+
const rows = await Promise.all(classified.map(async (item) => ({
|
|
66
|
+
item,
|
|
67
|
+
uncovered: await uncoveredStories(item, scenarios),
|
|
68
|
+
})));
|
|
69
|
+
return {
|
|
70
|
+
projects: rows.map(({ item, uncovered }) => projectOf(item, uncovered)),
|
|
71
|
+
uncovered: rows.flatMap(({ uncovered }) => uncovered),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const referrerOf = (row) => ({
|
|
75
|
+
file: row.file,
|
|
76
|
+
line: row.line,
|
|
77
|
+
project: row.project,
|
|
78
|
+
status: row.status,
|
|
79
|
+
});
|
|
80
|
+
const referrersOf = (rows) => {
|
|
81
|
+
const found = rows.flatMap((row) => [...row.text.matchAll(linkToken)]
|
|
82
|
+
.map((match) => ({ row, tag: match[0].slice(1) })));
|
|
83
|
+
const grouped = Map.groupBy(found, ({ tag }) => tag);
|
|
84
|
+
return new Map([...grouped].map(([tag, items]) => [tag, items.map(({ row }) => referrerOf(row))]));
|
|
85
|
+
};
|
|
86
|
+
const pendingIds = (context, scenario) => scenario.tags
|
|
87
|
+
.filter((tag) => context.scenarios.config.id.test(tag))
|
|
88
|
+
.slice(0, 1)
|
|
89
|
+
.filter((id) => scenario.tags.includes(markPending) && !context.scenarios.removed.has(id));
|
|
90
|
+
const pendingOf = (context, scenario) => {
|
|
91
|
+
const { file, location, referrers } = context;
|
|
92
|
+
const { line, title } = scenario;
|
|
93
|
+
return pendingIds(context, scenario)
|
|
94
|
+
.map((id) => ({ file, id, line, stories: referrers.get(id) ?? [], title, url: blobUrl(location, file, line) }));
|
|
95
|
+
};
|
|
96
|
+
const headLocation = async (scenarios) => {
|
|
97
|
+
const head = await git(scenarios.cwd, repositoryRoot, ['rev-parse', 'HEAD']);
|
|
98
|
+
return { commit: (head ?? '').trim(), web: await webUrl(scenarios.cwd, repositoryRoot) };
|
|
99
|
+
};
|
|
100
|
+
export const pendingScenarios = async (scenarios, files, rows) => {
|
|
101
|
+
const referrers = referrersOf(rows);
|
|
102
|
+
const location = await headLocation(scenarios);
|
|
103
|
+
const pending = [...files].flatMap(([raw, text]) => {
|
|
104
|
+
const file = path.posix.normalize(raw);
|
|
105
|
+
const context = { file, location, referrers, scenarios };
|
|
106
|
+
return parseFeature(text, file).flatMap((scenario) => pendingOf(context, scenario));
|
|
107
|
+
});
|
|
108
|
+
return pending.toSorted((left, right) => compareText(left.id, right.id));
|
|
109
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { uncoveredOrder } from './coverage-data.js';
|
|
2
|
+
class PyFloat {
|
|
3
|
+
value;
|
|
4
|
+
constructor(value) {
|
|
5
|
+
this.value = value;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const ordered = (fields) => Object.fromEntries(fields);
|
|
9
|
+
const projectJson = (item) => ordered([
|
|
10
|
+
['project', item.project],
|
|
11
|
+
['ref', item.revision],
|
|
12
|
+
['commit', item.commit],
|
|
13
|
+
['closed', item.closed],
|
|
14
|
+
['e2e', item.e2e],
|
|
15
|
+
['all_removed', item.allRemoved],
|
|
16
|
+
['code_tests', item.codeTests],
|
|
17
|
+
['none', item.none],
|
|
18
|
+
['unclassified', item.unclassified],
|
|
19
|
+
['denominator', item.denominator],
|
|
20
|
+
['covered', item.covered],
|
|
21
|
+
['share', item.share === undefined ? null : new PyFloat(item.share)],
|
|
22
|
+
['uncovered', ordered(uncoveredOrder.map((state) => [state, item.uncovered[state]]))],
|
|
23
|
+
]);
|
|
24
|
+
const uncoveredJson = (story) => ordered([
|
|
25
|
+
['project', story.project],
|
|
26
|
+
['story', story.story],
|
|
27
|
+
['path', story.file],
|
|
28
|
+
['url', story.url ?? null],
|
|
29
|
+
['state', story.state],
|
|
30
|
+
['scenarios', story.scenarios],
|
|
31
|
+
]);
|
|
32
|
+
const openJson = (story) => ordered([
|
|
33
|
+
['project', story.project],
|
|
34
|
+
['story', story.story],
|
|
35
|
+
['status', story.status],
|
|
36
|
+
['group', story.group],
|
|
37
|
+
['path', story.file ?? null],
|
|
38
|
+
['url', story.url ?? null],
|
|
39
|
+
['scenarios', story.scenarios],
|
|
40
|
+
]);
|
|
41
|
+
const referrerJson = (story) => ordered([
|
|
42
|
+
['project', story.project],
|
|
43
|
+
['path', story.file],
|
|
44
|
+
['line', story.line],
|
|
45
|
+
['status', story.status],
|
|
46
|
+
]);
|
|
47
|
+
const pendingJson = (scenario) => ordered([
|
|
48
|
+
['id', scenario.id],
|
|
49
|
+
['title', scenario.title],
|
|
50
|
+
['file', scenario.file],
|
|
51
|
+
['line', scenario.line],
|
|
52
|
+
['url', scenario.url ?? null],
|
|
53
|
+
['stories', scenario.stories.map((story) => referrerJson(story))],
|
|
54
|
+
]);
|
|
55
|
+
const removedJson = (row) => ordered([
|
|
56
|
+
['id', row.id],
|
|
57
|
+
['date', row.date],
|
|
58
|
+
['reason', row.reason],
|
|
59
|
+
['story', row.story],
|
|
60
|
+
['replaced_by', row.replacedBy],
|
|
61
|
+
['commit', row.commit],
|
|
62
|
+
]);
|
|
63
|
+
const pythonShape = (data) => ordered([
|
|
64
|
+
['date', data.date],
|
|
65
|
+
['period', ordered([['since', data.period.since], ['until', data.period.until]])],
|
|
66
|
+
['projects', data.projects.map((item) => projectJson(item))],
|
|
67
|
+
['uncovered', data.uncovered.map((story) => uncoveredJson(story))],
|
|
68
|
+
['open', data.open.map((story) => openJson(story))],
|
|
69
|
+
['pending', data.pending.map((scenario) => pendingJson(scenario))],
|
|
70
|
+
['removed', data.removed.map((row) => removedJson(row))],
|
|
71
|
+
['notes', data.notes],
|
|
72
|
+
['defects', data.defects],
|
|
73
|
+
]);
|
|
74
|
+
const isPlainObject = (value) => {
|
|
75
|
+
const isObject = typeof value === 'object' && value !== null;
|
|
76
|
+
return isObject && !Array.isArray(value) && !(value instanceof PyFloat);
|
|
77
|
+
};
|
|
78
|
+
const floatText = (value) => (Number.isSafeInteger(value) ? `${value}.0` : String(value));
|
|
79
|
+
const block = (open, close, entries, indent) => (entries.length === 0
|
|
80
|
+
? `${open}${close}`
|
|
81
|
+
: `${open}\n${entries.join(',\n')}\n${indent}${close}`);
|
|
82
|
+
const scalarJson = (value) => (value instanceof PyFloat ? floatText(value.value) : JSON.stringify(value));
|
|
83
|
+
const dump = (value, indent) => {
|
|
84
|
+
const inner = `${indent} `;
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return block('[', ']', value.map((item) => `${inner}${dump(item, inner)}`), indent);
|
|
87
|
+
}
|
|
88
|
+
return isPlainObject(value) ? block('{', '}', Object.entries(value).map(([key, item]) => `${inner}${JSON.stringify(key)}: ${dump(item, inner)}`), indent) : scalarJson(value);
|
|
89
|
+
};
|
|
90
|
+
export const coverageJson = (data) => `${dump(pythonShape(data), '')}\n`;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ClassifiedInterface } from './classes.js';
|
|
2
|
+
import type { ScenariosInterface } from './classify.js';
|
|
3
|
+
export type OpenGroupType = (typeof openOrder)[number];
|
|
4
|
+
export interface OpenRowInterface {
|
|
5
|
+
readonly file: string | undefined;
|
|
6
|
+
readonly group: OpenGroupType;
|
|
7
|
+
readonly project: string;
|
|
8
|
+
readonly scenarios: readonly string[];
|
|
9
|
+
readonly status: string;
|
|
10
|
+
readonly story: string;
|
|
11
|
+
readonly url: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
export declare const openOrder: readonly ["tested", "pending", "todo-only", "no-scenario", "code-tests", "none", "unclassified"];
|
|
14
|
+
export declare const openLabels: Readonly<Record<OpenGroupType, string>>;
|
|
15
|
+
export declare const openRows: (classified: readonly ClassifiedInterface[], scenarios: ScenariosInterface) => Promise<OpenRowInterface[]>;
|
|
16
|
+
export declare const openCounts: (rows: readonly OpenRowInterface[]) => Record<OpenGroupType, number>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { scenarioState } from './acceptance-class.js';
|
|
2
|
+
import { markPending, markTodo } from './config.js';
|
|
3
|
+
import { liveScenarios } from './coverage-data.js';
|
|
4
|
+
import { blobUrl, webUrl } from './remote.js';
|
|
5
|
+
export const openOrder = [
|
|
6
|
+
'tested',
|
|
7
|
+
'pending',
|
|
8
|
+
'todo-only',
|
|
9
|
+
'no-scenario',
|
|
10
|
+
'code-tests',
|
|
11
|
+
'none',
|
|
12
|
+
'unclassified',
|
|
13
|
+
];
|
|
14
|
+
export const openLabels = {
|
|
15
|
+
'code-tests': 'проверка — тестами кода',
|
|
16
|
+
'no-scenario': 'сценария ещё нет',
|
|
17
|
+
none: 'проверять нечего',
|
|
18
|
+
pending: 'тест готов раньше реализации',
|
|
19
|
+
tested: 'тест есть',
|
|
20
|
+
'todo-only': `критерий перенесён, шагов нет (@${markTodo})`,
|
|
21
|
+
unclassified: 'класс не задан',
|
|
22
|
+
};
|
|
23
|
+
const e2eGroup = (story, scenarios) => {
|
|
24
|
+
const state = scenarioState(story.ids, scenarios.ids, scenarios.removed);
|
|
25
|
+
if (state === 'только долг') {
|
|
26
|
+
return 'todo-only';
|
|
27
|
+
}
|
|
28
|
+
if (state !== 'с тестом') {
|
|
29
|
+
return 'no-scenario';
|
|
30
|
+
}
|
|
31
|
+
const tested = liveScenarios(story.ids, scenarios).filter((scenario) => !scenario.tags.includes(markTodo));
|
|
32
|
+
return tested.every((scenario) => scenario.tags.includes(markPending)) ? 'pending' : 'tested';
|
|
33
|
+
};
|
|
34
|
+
const groupOf = (story, scenarios) => {
|
|
35
|
+
const { kinds } = story;
|
|
36
|
+
if (kinds === undefined) {
|
|
37
|
+
return 'unclassified';
|
|
38
|
+
}
|
|
39
|
+
if (kinds.includes('e2e')) {
|
|
40
|
+
return e2eGroup(story, scenarios);
|
|
41
|
+
}
|
|
42
|
+
return kinds.includes('code-tests') ? 'code-tests' : 'none';
|
|
43
|
+
};
|
|
44
|
+
const projectRows = async (item, scenarios) => {
|
|
45
|
+
const location = { commit: item.project.commit, web: await webUrl(scenarios.cwd, item.project.repo) };
|
|
46
|
+
return item.open.map((story) => ({
|
|
47
|
+
file: story.file,
|
|
48
|
+
group: groupOf(story, scenarios),
|
|
49
|
+
project: item.project.name,
|
|
50
|
+
scenarios: story.ids,
|
|
51
|
+
status: story.status,
|
|
52
|
+
story: story.key,
|
|
53
|
+
url: story.file === undefined ? undefined : blobUrl(location, story.file),
|
|
54
|
+
}));
|
|
55
|
+
};
|
|
56
|
+
export const openRows = async (classified, scenarios) => {
|
|
57
|
+
const rows = await Promise.all(classified.map(async (item) => projectRows(item, scenarios)));
|
|
58
|
+
return rows.flat().toSorted((left, right) => openOrder.indexOf(left.group) - openOrder.indexOf(right.group));
|
|
59
|
+
};
|
|
60
|
+
export const openCounts = (rows) => {
|
|
61
|
+
const count = (group) => rows.filter((row) => row.group === group).length;
|
|
62
|
+
return {
|
|
63
|
+
'code-tests': count('code-tests'),
|
|
64
|
+
'no-scenario': count('no-scenario'),
|
|
65
|
+
none: count('none'),
|
|
66
|
+
pending: count('pending'),
|
|
67
|
+
tested: count('tested'),
|
|
68
|
+
'todo-only': count('todo-only'),
|
|
69
|
+
unclassified: count('unclassified'),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { uncoveredLabels, uncoveredOrder } from './coverage-data.js';
|
|
2
|
+
import { openCounts, openLabels, openOrder } from './coverage-open.js';
|
|
3
|
+
import { capitalize, escapeHtml } from './python-text.js';
|
|
4
|
+
const pageStyle = 'body{font:14px/1.5 system-ui,sans-serif;margin:24px;color:#1f2328;background:#fff}'
|
|
5
|
+
+ 'h1{font-size:22px}h2{font-size:17px;margin-top:28px}p.note{color:#59636e;max-width:880px}'
|
|
6
|
+
+ 'table{border-collapse:collapse;margin:8px 0}'
|
|
7
|
+
+ 'th,td{border:1px solid #d0d7de;padding:4px 8px;text-align:left;vertical-align:top}th{background:#f6f8fa}'
|
|
8
|
+
+ '@media (prefers-color-scheme:dark){body{color:#e6edf3;background:#0d1117}th{background:#161b22}'
|
|
9
|
+
+ 'th,td{border-color:#30363d}p.note{color:#9198a1}a{color:#4493f8}}';
|
|
10
|
+
const note = 'Классы приёмки story: e2e — сценарий этого репозитория против стенда снаружи, code-tests — тесты кода в '
|
|
11
|
+
+ 'репозитории проекта, none — проверять нечего; story с набором классов учтена в каждом. Покрыта — закрытая story '
|
|
12
|
+
+ 'с e2e, у которой хоть один сценарий идёт в прогон: не снят и без отметок @todo, @not-implemented, @quarantine. '
|
|
13
|
+
+ 'Знаменатель — закрытые story с e2e без тех, все сценарии которых сняты.';
|
|
14
|
+
const projectHead = [
|
|
15
|
+
'Проект',
|
|
16
|
+
'Задачи на',
|
|
17
|
+
'Закрытых',
|
|
18
|
+
'Знаменатель',
|
|
19
|
+
'Покрыто',
|
|
20
|
+
'Доля',
|
|
21
|
+
...uncoveredOrder.map((state) => capitalize(uncoveredLabels[state])),
|
|
22
|
+
'Все сняты',
|
|
23
|
+
'code-tests',
|
|
24
|
+
'none',
|
|
25
|
+
'Без класса',
|
|
26
|
+
];
|
|
27
|
+
const linkTo = (text, url) => (url === undefined ? escapeHtml(text) : `<a href="${escapeHtml(url)}">${escapeHtml(text)}</a>`);
|
|
28
|
+
const table = (head, rows) => {
|
|
29
|
+
if (rows.length === 0) {
|
|
30
|
+
return '<p>нет</p>';
|
|
31
|
+
}
|
|
32
|
+
const cells = head.map((cell) => `<th>${escapeHtml(cell)}</th>`).join('');
|
|
33
|
+
const body = rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`).join('');
|
|
34
|
+
return `<table><tr>${cells}</tr>${body}</table>`;
|
|
35
|
+
};
|
|
36
|
+
const items = (title, values) => {
|
|
37
|
+
const list = values.map((value) => `<li>${escapeHtml(value)}</li>`).join('');
|
|
38
|
+
const body = values.length > 0 ? `<ul>${list}</ul>` : '<p>нет</p>';
|
|
39
|
+
return `<h2>${escapeHtml(title)} (${values.length})</h2>${body}`;
|
|
40
|
+
};
|
|
41
|
+
const numbers = (values) => values.map((value) => escapeHtml(String(value)));
|
|
42
|
+
const shareCell = (share) => (share === undefined ? '—' : escapeHtml(`${share}%`));
|
|
43
|
+
const projectRows = (data) => data.projects.map((item) => [
|
|
44
|
+
escapeHtml(item.project),
|
|
45
|
+
escapeHtml(`${item.revision} ${item.commit.slice(0, 9)}`),
|
|
46
|
+
...numbers([item.closed, item.denominator, item.covered]),
|
|
47
|
+
shareCell(item.share),
|
|
48
|
+
...numbers(uncoveredOrder.map((state) => item.uncovered[state])),
|
|
49
|
+
...numbers([item.allRemoved, item.codeTests, item.none, item.unclassified]),
|
|
50
|
+
]);
|
|
51
|
+
const uncoveredRows = (data) => data.uncovered.map((story) => [
|
|
52
|
+
linkTo(`${story.project}:${story.story}`, story.url),
|
|
53
|
+
escapeHtml(uncoveredLabels[story.state]),
|
|
54
|
+
escapeHtml(story.scenarios.join(', ') || '—'),
|
|
55
|
+
]);
|
|
56
|
+
const openNote = 'Незакрытые story (статус board — не done и не rejected) по готовности теста; в долю покрытия они не '
|
|
57
|
+
+ 'входят. Тест есть — хоть один сценарий story с шагами не снят и не только ждёт реализации (в том числе в '
|
|
58
|
+
+ 'карантине); класс не задан — поля нет, заготовка TODO или находка поля.';
|
|
59
|
+
const openGroupRows = (data) => {
|
|
60
|
+
const counts = openCounts(data.open);
|
|
61
|
+
return openOrder.map((group) => [escapeHtml(capitalize(openLabels[group])), escapeHtml(String(counts[group]))]);
|
|
62
|
+
};
|
|
63
|
+
const openRows = (data) => data.open.map((story) => [
|
|
64
|
+
linkTo(`${story.project}:${story.story}`, story.url),
|
|
65
|
+
escapeHtml(story.status),
|
|
66
|
+
escapeHtml(openLabels[story.group]),
|
|
67
|
+
escapeHtml(story.scenarios.join(', ') || '—'),
|
|
68
|
+
]);
|
|
69
|
+
const referrersCell = (scenario) => {
|
|
70
|
+
const lines = scenario.stories
|
|
71
|
+
.map((story) => escapeHtml(`${story.project}/${story.file}:${story.line} — ${story.status || 'вне board'}`));
|
|
72
|
+
return lines.join('<br>') || 'ссылок нет';
|
|
73
|
+
};
|
|
74
|
+
const pendingRows = (data) => data.pending.map((scenario) => [
|
|
75
|
+
linkTo(scenario.id, scenario.url),
|
|
76
|
+
escapeHtml(scenario.title),
|
|
77
|
+
escapeHtml(`${scenario.file}:${scenario.line}`),
|
|
78
|
+
referrersCell(scenario),
|
|
79
|
+
]);
|
|
80
|
+
const removedRows = (data) => data.removed
|
|
81
|
+
.map((row) => [row.id, row.date, row.reason, row.story, row.replacedBy, row.commit].map((value) => escapeHtml(value)));
|
|
82
|
+
export const renderPage = (data) => [
|
|
83
|
+
'<!doctype html><html lang="ru"><head><meta charset="utf-8">',
|
|
84
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
|
85
|
+
`<title>Покрытие задач</title><style>${pageStyle}</style></head><body>`,
|
|
86
|
+
'<h1>Покрытие задач приёмочными тестами</h1>',
|
|
87
|
+
`<p class="note">Дата: ${escapeHtml(data.date)}. ${note}</p>`,
|
|
88
|
+
'<h2>Покрытие</h2>',
|
|
89
|
+
table(projectHead, projectRows(data)),
|
|
90
|
+
`<h2>Закрытые без включённого теста (${data.uncovered.length})</h2>`,
|
|
91
|
+
table(['Story', 'Состояние', 'Сценарии story'], uncoveredRows(data)),
|
|
92
|
+
`<h2>Незакрытые по готовности теста (${data.open.length})</h2>`,
|
|
93
|
+
`<p class="note">${openNote}</p>`,
|
|
94
|
+
table(['Группа', 'Story'], openGroupRows(data)),
|
|
95
|
+
table(['Story', 'Статус', 'Группа', 'Сценарии story'], openRows(data)),
|
|
96
|
+
`<h2>Ждут реализации (${data.pending.length})</h2>`,
|
|
97
|
+
'<p class="note">Тест написан раньше реализации: сценарий с отметкой @not-implemented.</p>',
|
|
98
|
+
table(['Сценарий', 'Заголовок', 'Файл', 'Story со ссылкой'], pendingRows(data)),
|
|
99
|
+
`<h2>Снято за ${escapeHtml(data.period.since)} — ${escapeHtml(data.period.until)} (${data.removed.length})</h2>`,
|
|
100
|
+
table(['id', 'Дата', 'Причина', 'Story', 'Чем заменён', 'Коммит'], removedRows(data)),
|
|
101
|
+
items('Дефекты разметки', data.defects),
|
|
102
|
+
items('Примечания', data.notes),
|
|
103
|
+
'</body></html>\n',
|
|
104
|
+
].join('');
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface RemovedInInterface {
|
|
2
|
+
readonly notes: readonly string[];
|
|
3
|
+
readonly rows: readonly RemovedInPeriodInterface[];
|
|
4
|
+
}
|
|
5
|
+
export interface RemovedInPeriodInterface {
|
|
6
|
+
readonly commit: string;
|
|
7
|
+
readonly date: string;
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly reason: string;
|
|
10
|
+
readonly replacedBy: string;
|
|
11
|
+
readonly story: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const removedIn: (cwd: string, file: string, period: readonly [string, string]) => Promise<RemovedInInterface>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { parseDate } from './dates.js';
|
|
2
|
+
import { pyRepr } from './python-text.js';
|
|
3
|
+
import { removedRows } from './removed-list.js';
|
|
4
|
+
const datePattern = /^\d{4}-\d{2}-\d{2}$/u;
|
|
5
|
+
const parsedDay = (text) => {
|
|
6
|
+
try {
|
|
7
|
+
return [parseDate(text)];
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const dayOf = (text) => (datePattern.test(text) ? parsedDay(text)[0] : undefined);
|
|
14
|
+
const inPeriod = (row, period, day) => {
|
|
15
|
+
const { commit, date, id, reason, replacedBy, story } = row;
|
|
16
|
+
return period[0] <= day && day <= period[1] ? [{ commit, date, id, reason, replacedBy, story }] : [];
|
|
17
|
+
};
|
|
18
|
+
const dateNote = (row, file) => `запись ${row.id} в ${file}:${row.line}: дата ${pyRepr(row.date)} не по формату ГГГГ-ММ-ДД — в «снято за период» не входит`;
|
|
19
|
+
export const removedIn = async (cwd, file, period) => {
|
|
20
|
+
const rows = await removedRows(cwd, file);
|
|
21
|
+
const dated = rows.map((row) => [row, dayOf(row.date)]);
|
|
22
|
+
return {
|
|
23
|
+
notes: dated.filter(([, day]) => day === undefined).map(([row]) => dateNote(row, file)),
|
|
24
|
+
rows: dated.flatMap(([row, day]) => (day === undefined ? [] : inPeriod(row, period, day))),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CoverageProjectInterface, PendingInterface, UncoveredStoryInterface } from './coverage-data.js';
|
|
2
|
+
import type { OpenRowInterface } from './coverage-open.js';
|
|
3
|
+
import type { RemovedInPeriodInterface } from './coverage-removed.js';
|
|
4
|
+
import type { DeclaredInputInterface } from './declared.js';
|
|
5
|
+
import type { ReportInterface } from './report.js';
|
|
6
|
+
export interface CoverageDataInterface {
|
|
7
|
+
readonly date: string;
|
|
8
|
+
readonly defects: readonly string[];
|
|
9
|
+
readonly notes: readonly string[];
|
|
10
|
+
readonly open: readonly OpenRowInterface[];
|
|
11
|
+
readonly pending: readonly PendingInterface[];
|
|
12
|
+
readonly period: Readonly<{
|
|
13
|
+
since: string;
|
|
14
|
+
until: string;
|
|
15
|
+
}>;
|
|
16
|
+
readonly projects: readonly CoverageProjectInterface[];
|
|
17
|
+
readonly removed: readonly RemovedInPeriodInterface[];
|
|
18
|
+
readonly uncovered: readonly UncoveredStoryInterface[];
|
|
19
|
+
}
|
|
20
|
+
export interface CoverageOptionsInterface {
|
|
21
|
+
readonly json: string | undefined;
|
|
22
|
+
readonly out: string | undefined;
|
|
23
|
+
readonly period: readonly [string, string];
|
|
24
|
+
readonly today: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const coverageCommand: (input: DeclaredInputInterface, options: CoverageOptionsInterface) => Promise<ReportInterface>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { classifyDeclared, noProjects, sourcesLine } from './classes.js';
|
|
4
|
+
import { ConfigError } from './config.js';
|
|
5
|
+
import { coverageOf, pendingScenarios, uncoveredLabels, uncoveredOrder } from './coverage-data.js';
|
|
6
|
+
import { coverageJson } from './coverage-json.js';
|
|
7
|
+
import { openCounts, openLabels, openOrder, openRows } from './coverage-open.js';
|
|
8
|
+
import { renderPage } from './coverage-page.js';
|
|
9
|
+
import { removedIn } from './coverage-removed.js';
|
|
10
|
+
import { scenarioIds, worktreeFeatures } from './features.js';
|
|
11
|
+
import { messageOf } from './python-text.js';
|
|
12
|
+
import { parseRemoved } from './removed-list.js';
|
|
13
|
+
import { readProjects } from './task-links.js';
|
|
14
|
+
const projectPart = (item) => {
|
|
15
|
+
const share = item.share === undefined ? '—' : `${item.share}%`;
|
|
16
|
+
const states = uncoveredOrder.map((state) => `${uncoveredLabels[state]} ${item.uncovered[state]}`).join(', ');
|
|
17
|
+
const total = uncoveredOrder.reduce((sum, state) => sum + item.uncovered[state], 0);
|
|
18
|
+
return `${item.project} — покрыто ${item.covered} из ${item.denominator} (${share}), без включённого теста ${total} (${states})`;
|
|
19
|
+
};
|
|
20
|
+
const coverageLine = (projects) => {
|
|
21
|
+
const parts = projects.map((item) => projectPart(item)).join(' · ');
|
|
22
|
+
return `покрытие закрытых story: ${parts || noProjects}`;
|
|
23
|
+
};
|
|
24
|
+
const openPart = (item, rows) => {
|
|
25
|
+
const own = rows.filter((row) => row.project === item.project.name);
|
|
26
|
+
const counts = openCounts(own);
|
|
27
|
+
const groups = openOrder.map((group) => `${openLabels[group]} ${counts[group]}`).join(', ');
|
|
28
|
+
return `${item.project.name} — по группам ${own.length}, на board ${item.open.length} (${groups})`;
|
|
29
|
+
};
|
|
30
|
+
const openLine = (classified, rows) => {
|
|
31
|
+
const parts = classified.map((item) => openPart(item, rows)).join(' · ');
|
|
32
|
+
return `незакрытые story: ${parts || noProjects}`;
|
|
33
|
+
};
|
|
34
|
+
const writeOut = async (cwd, file, text) => {
|
|
35
|
+
try {
|
|
36
|
+
await mkdir(path.dirname(path.resolve(cwd, file)), { recursive: true });
|
|
37
|
+
await writeFile(path.resolve(cwd, file), text);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new ConfigError(`${file} не записывается: ${messageOf(error)}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const scenariosOf = async (input, files) => {
|
|
44
|
+
const removed = await parseRemoved(input.cwd, input.config.removed);
|
|
45
|
+
return { config: input.config, cwd: input.cwd, ids: scenarioIds(input.config, files), removed: removed.entries };
|
|
46
|
+
};
|
|
47
|
+
const collect = async (input, options) => {
|
|
48
|
+
const files = await worktreeFeatures(input.cwd, input.config);
|
|
49
|
+
const scenarios = await scenariosOf(input, files);
|
|
50
|
+
const { classified, defects, notes } = await classifyDeclared(scenarios, input);
|
|
51
|
+
const { projects, uncovered } = await coverageOf(classified, scenarios);
|
|
52
|
+
const read = await readProjects(input);
|
|
53
|
+
const pending = await pendingScenarios(scenarios, files, read.rows);
|
|
54
|
+
const open = await openRows(classified, scenarios);
|
|
55
|
+
const inPeriod = await removedIn(input.cwd, input.config.removed, options.period);
|
|
56
|
+
const data = {
|
|
57
|
+
date: options.today,
|
|
58
|
+
defects,
|
|
59
|
+
notes: [...notes, ...inPeriod.notes],
|
|
60
|
+
open,
|
|
61
|
+
pending,
|
|
62
|
+
period: { since: options.period[0], until: options.period[1] },
|
|
63
|
+
projects,
|
|
64
|
+
removed: inPeriod.rows,
|
|
65
|
+
uncovered,
|
|
66
|
+
};
|
|
67
|
+
return { data, open: openLine(classified, open), sources: sourcesLine(classified.map((item) => item.project)) };
|
|
68
|
+
};
|
|
69
|
+
const isGiven = (file) => file !== undefined && file !== '';
|
|
70
|
+
const writeIfGiven = async (cwd, target) => {
|
|
71
|
+
if (!isGiven(target.file)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
await writeOut(cwd, target.file, target.text);
|
|
75
|
+
return [`${target.label}: ${target.file}`];
|
|
76
|
+
};
|
|
77
|
+
export const coverageCommand = async (input, options) => {
|
|
78
|
+
const { data, open, sources } = await collect(input, options);
|
|
79
|
+
const counts = `ждут реализации ${data.pending.length}, снято за ${data.period.since} — ${data.period.until}: ${data.removed.length}`;
|
|
80
|
+
const json = await writeIfGiven(input.cwd, { file: options.json, label: 'данные', text: coverageJson(data) });
|
|
81
|
+
const page = await writeIfGiven(input.cwd, { file: options.out, label: 'страница', text: renderPage(data) });
|
|
82
|
+
const summary = [coverageLine(data.projects), open, sources, counts, ...json, ...page].join('\n');
|
|
83
|
+
return { defects: data.defects, notes: data.notes, summary };
|
|
84
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface PeriodOptionsInterface {
|
|
2
|
+
readonly since: string | undefined;
|
|
3
|
+
readonly today: string;
|
|
4
|
+
readonly until: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
export declare const parseDate: (text: string) => string;
|
|
7
|
+
export declare const localToday: (now: Date) => string;
|
|
8
|
+
export declare const periodOf: (options: PeriodOptionsInterface) => readonly [string, string];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { ConfigError } from './config.js';
|
|
2
|
+
import { messageOf, pyRepr } from './python-text.js';
|
|
3
|
+
const isoForm = /^(?<year>\d{4})(?<dash>-?)(?<month>\d{2})\k<dash>(?<day>\d{2})$/u;
|
|
4
|
+
const periodDays = 30;
|
|
5
|
+
const monthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
6
|
+
const eraDays = 146_097;
|
|
7
|
+
const epochShift = 719_468;
|
|
8
|
+
const isLeap = (year) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
9
|
+
const daysIn = (year, month) => {
|
|
10
|
+
const isLeapFebruary = month === 2 && isLeap(year);
|
|
11
|
+
return isLeapFebruary ? 29 : monthDays[month - 1] ?? 0;
|
|
12
|
+
};
|
|
13
|
+
const dayError = ({ day, month, year }) => {
|
|
14
|
+
const last = daysIn(year, month);
|
|
15
|
+
return day < 1 || day > last ? `day ${day} must be in range 1..${last} for month ${month} in year ${year}` : undefined;
|
|
16
|
+
};
|
|
17
|
+
const rangeError = (civil) => {
|
|
18
|
+
if (civil.year < 1) {
|
|
19
|
+
return `year must be in 1..9999, not ${civil.year}`;
|
|
20
|
+
}
|
|
21
|
+
return civil.month < 1 || civil.month > 12 ? `month must be in 1..12, not ${civil.month}` : dayError(civil);
|
|
22
|
+
};
|
|
23
|
+
const pad = (value, width) => String(value).padStart(width, '0');
|
|
24
|
+
const format = ({ day, month, year }) => `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
|
|
25
|
+
const marchMonth = (month) => (month > 2 ? month - 3 : month + 9);
|
|
26
|
+
const toDays = ({ day, month, year }) => {
|
|
27
|
+
const shifted = month <= 2 ? year - 1 : year;
|
|
28
|
+
const era = Math.floor(shifted / 400);
|
|
29
|
+
const yearOfEra = shifted - (era * 400);
|
|
30
|
+
const dayOfYear = Math.floor(((153 * marchMonth(month)) + 2) / 5) + day - 1;
|
|
31
|
+
const leapDays = Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100);
|
|
32
|
+
return (era * eraDays) + (yearOfEra * 365) + leapDays + dayOfYear - epochShift;
|
|
33
|
+
};
|
|
34
|
+
const yearOfEraOf = (dayOfEra) => {
|
|
35
|
+
const leapDays = Math.floor(dayOfEra / 1460) - Math.floor(dayOfEra / 36_524) + Math.floor(dayOfEra / (eraDays - 1));
|
|
36
|
+
return Math.floor((dayOfEra - leapDays) / 365);
|
|
37
|
+
};
|
|
38
|
+
const fromDays = (days) => {
|
|
39
|
+
const shifted = days + epochShift;
|
|
40
|
+
const era = Math.floor(shifted / eraDays);
|
|
41
|
+
const dayOfEra = shifted - (era * eraDays);
|
|
42
|
+
const yearOfEra = yearOfEraOf(dayOfEra);
|
|
43
|
+
const dayOfYear = dayOfEra - ((365 * yearOfEra) + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
|
44
|
+
const monthIndex = Math.floor(((5 * dayOfYear) + 2) / 153);
|
|
45
|
+
const month = monthIndex < 10 ? monthIndex + 3 : monthIndex - 9;
|
|
46
|
+
const day = dayOfYear - Math.floor(((153 * monthIndex) + 2) / 5) + 1;
|
|
47
|
+
return { day, month, year: yearOfEra + (era * 400) + (month <= 2 ? 1 : 0) };
|
|
48
|
+
};
|
|
49
|
+
export const parseDate = (text) => {
|
|
50
|
+
const groups = isoForm.exec(text)?.groups;
|
|
51
|
+
if (groups === undefined) {
|
|
52
|
+
throw new Error(`Invalid isoformat string: ${pyRepr(text)}`);
|
|
53
|
+
}
|
|
54
|
+
const civil = { day: Number(groups.day), month: Number(groups.month), year: Number(groups.year) };
|
|
55
|
+
const problem = rangeError(civil);
|
|
56
|
+
if (problem !== undefined) {
|
|
57
|
+
throw new Error(problem);
|
|
58
|
+
}
|
|
59
|
+
return format(civil);
|
|
60
|
+
};
|
|
61
|
+
export const localToday = (now) => format({
|
|
62
|
+
day: now.getDate(),
|
|
63
|
+
month: now.getMonth() + 1,
|
|
64
|
+
year: now.getFullYear(),
|
|
65
|
+
});
|
|
66
|
+
const minusDays = (date, days) => {
|
|
67
|
+
const [year = 0, month = 0, day = 0] = date.split('-').map(Number);
|
|
68
|
+
return format(fromDays(toDays({ day, month, year }) - days));
|
|
69
|
+
};
|
|
70
|
+
const isUnset = (text) => text === undefined || text === '';
|
|
71
|
+
const dateOr = (text, fallback) => (isUnset(text) ? fallback() : parseDate(text));
|
|
72
|
+
const parsePeriod = (options) => {
|
|
73
|
+
try {
|
|
74
|
+
const until = dateOr(options.until, () => options.today);
|
|
75
|
+
return [dateOr(options.since, () => minusDays(until, periodDays)), until];
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new ConfigError(`дата периода не по формату ГГГГ-ММ-ДД: ${messageOf(error)}`);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
export const periodOf = (options) => {
|
|
82
|
+
const [since, until] = parsePeriod(options);
|
|
83
|
+
if (since > until) {
|
|
84
|
+
throw new ConfigError(`--since ${since} позже --until ${until}`);
|
|
85
|
+
}
|
|
86
|
+
return [since, until];
|
|
87
|
+
};
|