@rsconcept/rstool 0.5.1 → 0.6.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.
@@ -0,0 +1,106 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { CstType, RSToolWrapperClient, type AddOrUpdateConstituentaInput } from '../src';
5
+
6
+ /** Tuple marker in structured values. */
7
+ const TUPLE_ID = -111;
8
+
9
+ async function run() {
10
+ const client = new RSToolWrapperClient({
11
+ cwd: resolve(process.cwd())
12
+ });
13
+
14
+ try {
15
+ await client.waitUntilReady();
16
+ const session = await client.call<{ sessionId: string; contractVersion: string }>('createSession');
17
+
18
+ const drafts: AddOrUpdateConstituentaInput[] = [
19
+ {
20
+ draft: { id: 1, alias: 'X1', cstType: CstType.BASE, definitionFormal: '' }
21
+ },
22
+ {
23
+ draft: { id: 2, alias: 'C1', cstType: CstType.CONSTANT, definitionFormal: '' }
24
+ },
25
+ {
26
+ draft: {
27
+ id: 3,
28
+ alias: 'S1',
29
+ cstType: CstType.STRUCTURED,
30
+ definitionFormal: 'ℬ(X1×X1)',
31
+ convention: 'Pairs (parent, child) over X1.'
32
+ }
33
+ },
34
+ {
35
+ draft: { id: 4, alias: 'D1', cstType: CstType.TERM, definitionFormal: 'Pr1(S1)' }
36
+ },
37
+ {
38
+ draft: { id: 5, alias: 'A1', cstType: CstType.AXIOM, definitionFormal: '1=1' }
39
+ }
40
+ ];
41
+
42
+ for (const input of drafts) {
43
+ const result = await client.call('addOrUpdateConstituenta', {
44
+ sessionId: session.sessionId,
45
+ input
46
+ });
47
+ console.log(
48
+ `Added ${input.draft.alias}:`,
49
+ (result as { diagnostics?: unknown[] }).diagnostics?.length ?? 0,
50
+ 'diagnostics'
51
+ );
52
+ }
53
+
54
+ const model = await client.call('setConstituentaValues', {
55
+ sessionId: session.sessionId,
56
+ input: {
57
+ items: [
58
+ { target: 1, value: { 0: 'alice', 1: 'bob' } },
59
+ { target: 2, value: { 0: 'zero', 1: 'one', 2: 'two' } },
60
+ { target: 3, value: [[TUPLE_ID, 0, 1]] }
61
+ ]
62
+ }
63
+ });
64
+ console.log('Model values set:', model);
65
+
66
+ const d1Eval = await client.call('evaluateConstituenta', {
67
+ sessionId: session.sessionId,
68
+ input: { constituentId: 4 }
69
+ });
70
+ console.log('D1 (Pr1(S1)) evaluation:', d1Eval);
71
+
72
+ const a1Eval = await client.call('evaluateConstituenta', {
73
+ sessionId: session.sessionId,
74
+ input: { constituentId: 5 }
75
+ });
76
+ console.log('A1 (1=1) evaluation:', a1Eval);
77
+
78
+ const recalculated = await client.call('recalculateModel', {
79
+ sessionId: session.sessionId
80
+ });
81
+ const recalculatedItems = (recalculated as { items: { alias: string; status: number }[] }).items;
82
+ console.log(
83
+ 'Recalculated model:',
84
+ recalculatedItems.map(item => ({ alias: item.alias, status: item.status }))
85
+ );
86
+
87
+ await client.call('commitStep', {
88
+ sessionId: session.sessionId,
89
+ message: 'Built sample RSModel'
90
+ });
91
+
92
+ const exported = await client.call<string>('exportSession', {
93
+ sessionId: session.sessionId
94
+ });
95
+ const outputPath = resolve(process.cwd(), 'examples', 'sample-rsmodel-session.json');
96
+ await writeFile(outputPath, exported, 'utf8');
97
+ console.log(`Sample RSModel exported: ${outputPath}`);
98
+ } finally {
99
+ await client.close();
100
+ }
101
+ }
102
+
103
+ run().catch(error => {
104
+ console.error(error);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,247 @@
1
+ import readline from 'node:readline/promises';
2
+ import { resolve } from 'node:path';
3
+ import { stdin as input, stdout as output } from 'node:process';
4
+
5
+ import { RSToolWrapperClient } from '../../src';
6
+
7
+ import { DEFAULT_SESSION_PATH } from './constants';
8
+ import { KinshipModelSession } from './session';
9
+ import {
10
+ formatS1,
11
+ formatX1,
12
+ formatA1Status,
13
+ genderLabel,
14
+ parseAddPersonArgs,
15
+ parseSetPersonArgs
16
+ } from './x1-actions';
17
+
18
+ const HELP = `
19
+ Команды изменения X1 (люди):
20
+
21
+ list показать X1 и связи S1
22
+ add <м|ж> <имя> добавить человека (пол: м, ж, m, f)
23
+ remove <имя> удалить человека (с пересчётом индексов и очисткой S1)
24
+ rename <старое> <новое> переименовать
25
+ set <м|ж> <имя> ... заменить X1 (пары пол+имя; S1 по именам)
26
+ set <имя1> <имя2> ... то же, если пол уже известен из сессии / примера
27
+ clear очистить X1 и S1
28
+ save [путь] сохранить сессию (по умолчанию — исходный файл)
29
+ help эта справка
30
+ exit выход без сохранения
31
+
32
+ Флаги запуска:
33
+ --session <path> файл сессии (по умолчанию: ${DEFAULT_SESSION_PATH})
34
+ --no-save не сохранять после команды
35
+ `.trim();
36
+
37
+ const READONLY_COMMANDS = new Set(['help', '?', 'list', 'show']);
38
+
39
+ function shouldAutoSave(command: string, autoSave: boolean): boolean {
40
+ return autoSave && command !== 'save' && !READONLY_COMMANDS.has(command);
41
+ }
42
+
43
+ interface CliOptions {
44
+ sessionPath: string;
45
+ autoSave: boolean;
46
+ command?: string;
47
+ args: string[];
48
+ }
49
+
50
+ function failCli(message: string): never {
51
+ console.error(message);
52
+ console.error('');
53
+ console.error(HELP);
54
+ process.exit(1);
55
+ }
56
+
57
+ function parseArgs(argv: string[]): CliOptions {
58
+ const options: CliOptions = {
59
+ sessionPath: DEFAULT_SESSION_PATH,
60
+ autoSave: true,
61
+ args: []
62
+ };
63
+
64
+ for (let index = 0; index < argv.length; index += 1) {
65
+ const token = argv[index];
66
+ if (token === '--session') {
67
+ const value = argv[index + 1];
68
+ if (value === undefined) {
69
+ failCli('Ошибка: после --session требуется путь к файлу сессии.');
70
+ }
71
+ if (value.startsWith('-')) {
72
+ failCli(`Ошибка: недопустимое значение для --session: «${value}».`);
73
+ }
74
+ options.sessionPath = value;
75
+ index += 1;
76
+ continue;
77
+ }
78
+ if (token === '--no-save') {
79
+ options.autoSave = false;
80
+ continue;
81
+ }
82
+ if (!options.command) {
83
+ options.command = token;
84
+ continue;
85
+ }
86
+ options.args.push(token);
87
+ }
88
+
89
+ return options;
90
+ }
91
+
92
+ async function printModel(session: KinshipModelSession): Promise<void> {
93
+ const binding = await session.getX1Binding();
94
+ const s1 = await session.getS1Value();
95
+ const genderByName = session.getGenderByName();
96
+ console.log('X1 (люди):');
97
+ console.log(formatX1(binding, genderByName));
98
+ console.log('');
99
+ console.log(formatA1Status(binding));
100
+ console.log('');
101
+ console.log('S1 (родитель → ребёнок):');
102
+ console.log(formatS1(binding, s1));
103
+ }
104
+
105
+ async function runCommand(session: KinshipModelSession, command: string, args: string[]): Promise<boolean> {
106
+ switch (command) {
107
+ case 'help':
108
+ case '?':
109
+ console.log(HELP);
110
+ return true;
111
+
112
+ case 'list':
113
+ case 'show':
114
+ await printModel(session);
115
+ return true;
116
+
117
+ case 'add': {
118
+ const { gender, name } = parseAddPersonArgs(args);
119
+ await session.addPerson(name, gender);
120
+ await session.commitStep(`X1: добавлен «${name}» (${genderLabel(gender)})`);
121
+ console.log(`Добавлен: ${name} (${genderLabel(gender)})`);
122
+ await printModel(session);
123
+ return true;
124
+ }
125
+
126
+ case 'remove':
127
+ case 'rm': {
128
+ const name = args.join(' ').trim();
129
+ if (!name) {
130
+ throw new Error('Укажите имя: remove <имя>');
131
+ }
132
+ await session.removePerson(name);
133
+ await session.commitStep(`X1: удалён «${name}»`);
134
+ console.log(`Удалён: ${name}`);
135
+ await printModel(session);
136
+ return true;
137
+ }
138
+
139
+ case 'rename': {
140
+ const [oldName, ...rest] = args;
141
+ const newName = rest.join(' ').trim();
142
+ if (!oldName || !newName) {
143
+ throw new Error('Укажите имена: rename <старое> <новое>');
144
+ }
145
+ await session.renamePerson(oldName, newName);
146
+ await session.commitStep(`X1: «${oldName}» → «${newName}»`);
147
+ console.log(`Переименовано: ${oldName} → ${newName}`);
148
+ await printModel(session);
149
+ return true;
150
+ }
151
+
152
+ case 'set': {
153
+ const { specs } = parseSetPersonArgs(args, session.getGenderByName());
154
+ await session.setX1People(specs);
155
+ await session.commitStep(`X1: задан список (${specs.length})`);
156
+ const summary = specs.map(spec => `${spec.name} (${genderLabel(spec.gender)})`).join(', ');
157
+ console.log(`Задан X1: ${summary}`);
158
+ await printModel(session);
159
+ return true;
160
+ }
161
+
162
+ case 'clear': {
163
+ await session.clearX1();
164
+ await session.commitStep('X1 и S1 очищены');
165
+ console.log('X1 и S1 очищены');
166
+ await printModel(session);
167
+ return true;
168
+ }
169
+
170
+ case 'save': {
171
+ const savedPath = await session.save(args[0]);
172
+ console.log(`Сохранено: ${savedPath}`);
173
+ return true;
174
+ }
175
+
176
+ case 'exit':
177
+ case 'quit':
178
+ return false;
179
+
180
+ default:
181
+ throw new Error(`Неизвестная команда: ${command}\n\n${HELP}`);
182
+ }
183
+ }
184
+
185
+ async function runInteractive(session: KinshipModelSession, autoSave: boolean): Promise<void> {
186
+ const rl = readline.createInterface({ input, output });
187
+ console.log('Kinship model CLI — изменение X1');
188
+ console.log(`Сессия: ${resolve(process.cwd(), DEFAULT_SESSION_PATH)}`);
189
+ console.log('Введите help для списка команд.\n');
190
+
191
+ try {
192
+ for (;;) {
193
+ const line = (await rl.question('kinship> ')).trim();
194
+ if (!line) {
195
+ continue;
196
+ }
197
+ const [command, ...args] = line.split(/\s+/);
198
+ if (command === 'exit' || command === 'quit') {
199
+ break;
200
+ }
201
+ try {
202
+ await runCommand(session, command, args);
203
+ if (shouldAutoSave(command, autoSave)) {
204
+ const savedPath = await session.save();
205
+ console.log(`(автосохранение: ${savedPath})`);
206
+ }
207
+ } catch (error) {
208
+ console.error(error instanceof Error ? error.message : error);
209
+ }
210
+ console.log('');
211
+ }
212
+ } finally {
213
+ rl.close();
214
+ }
215
+ }
216
+
217
+ async function main(): Promise<void> {
218
+ const options = parseArgs(process.argv.slice(2));
219
+ const client = new RSToolWrapperClient({ cwd: resolve(process.cwd()) });
220
+
221
+ try {
222
+ await client.waitUntilReady();
223
+ const session = await KinshipModelSession.open(client, options.sessionPath);
224
+
225
+ if (!options.command) {
226
+ await runInteractive(session, options.autoSave);
227
+ return;
228
+ }
229
+
230
+ const shouldContinue = await runCommand(session, options.command, options.args);
231
+ if (!shouldContinue) {
232
+ return;
233
+ }
234
+
235
+ if (shouldAutoSave(options.command, options.autoSave)) {
236
+ const savedPath = await session.save();
237
+ console.log(`Сохранено: ${savedPath}`);
238
+ }
239
+ } finally {
240
+ await client.close();
241
+ }
242
+ }
243
+
244
+ main().catch(error => {
245
+ console.error(error instanceof Error ? error.message : error);
246
+ process.exit(1);
247
+ });
@@ -0,0 +1,12 @@
1
+ /** Constituent ids in kinship RSForm / RSModel sessions. */
2
+ export const X1_ID = 1;
3
+ export const S1_ID = 2;
4
+ export const S2_ID = 13;
5
+ export const S3_ID = 14;
6
+ export const D3_ID = 11;
7
+ export const A1_ID = 12;
8
+
9
+ /** Tuple marker in structured S1 values (frontend `TUPLE_ID`). */
10
+ export const TUPLE_ID = -111;
11
+
12
+ export const DEFAULT_SESSION_PATH = 'examples/kinship-rsmodel-session.json';
@@ -0,0 +1,190 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { type BasicBinding, type RSToolWrapperClient, type SessionModelState } from '../../src';
5
+
6
+ import { S1_ID, S2_ID, S3_ID, X1_ID } from './constants';
7
+ import {
8
+ addPerson,
9
+ type Gender,
10
+ type GenderRegistry,
11
+ genderByNameFromSets,
12
+ type S1Value,
13
+ removePerson,
14
+ renamePerson,
15
+ remapS1ByNames,
16
+ deriveGenderSets,
17
+ setX1WithGender
18
+ } from './x1-actions';
19
+
20
+ function asBinding(value: unknown): BasicBinding {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
22
+ return {};
23
+ }
24
+ const binding: BasicBinding = {};
25
+ for (const [key, name] of Object.entries(value)) {
26
+ if (typeof name === 'string') {
27
+ binding[Number(key)] = name;
28
+ }
29
+ }
30
+ return binding;
31
+ }
32
+
33
+ function asS1Value(value: unknown): S1Value {
34
+ if (!Array.isArray(value)) {
35
+ return [];
36
+ }
37
+ return value.filter(item => Array.isArray(item)) as S1Value;
38
+ }
39
+
40
+ function asIndexList(value: unknown): number[] {
41
+ if (!Array.isArray(value)) {
42
+ return [];
43
+ }
44
+ return value.filter((item): item is number => typeof item === 'number');
45
+ }
46
+
47
+ function getModelItem(model: SessionModelState, id: number): unknown {
48
+ return model.items.find(item => item.id === id)?.value ?? null;
49
+ }
50
+
51
+ export class KinshipModelSession {
52
+ private genderByName: GenderRegistry = {};
53
+
54
+ public constructor(
55
+ private readonly client: RSToolWrapperClient,
56
+ public readonly sessionId: string,
57
+ public readonly sessionPath: string
58
+ ) {}
59
+
60
+ public static async open(
61
+ client: RSToolWrapperClient,
62
+ sessionPath: string
63
+ ): Promise<KinshipModelSession> {
64
+ const absolutePath = resolve(process.cwd(), sessionPath);
65
+ const payload = await readFile(absolutePath, 'utf8');
66
+ const imported = await client.call<{ sessionId: string }>('importSession', { payload });
67
+ const session = new KinshipModelSession(client, imported.sessionId, absolutePath);
68
+ await session.loadGenderFromModel();
69
+ return session;
70
+ }
71
+
72
+ private async loadGenderFromModel(): Promise<void> {
73
+ const model = await this.getModelState();
74
+ const binding = asBinding(getModelItem(model, X1_ID));
75
+ const s2 = asIndexList(getModelItem(model, S2_ID));
76
+ const s3 = asIndexList(getModelItem(model, S3_ID));
77
+ this.genderByName = genderByNameFromSets(binding, s2, s3);
78
+ }
79
+
80
+ public getGenderByName(): Readonly<GenderRegistry> {
81
+ return this.genderByName;
82
+ }
83
+
84
+ public async getModelState(): Promise<SessionModelState> {
85
+ return this.client.call<SessionModelState>('getModelState', { sessionId: this.sessionId });
86
+ }
87
+
88
+ public async getX1Binding(): Promise<BasicBinding> {
89
+ const model = await this.getModelState();
90
+ return asBinding(getModelItem(model, X1_ID));
91
+ }
92
+
93
+ public async getS1Value(): Promise<S1Value> {
94
+ const model = await this.getModelState();
95
+ return asS1Value(getModelItem(model, S1_ID));
96
+ }
97
+
98
+ private async applyModelValues(binding: BasicBinding, s1: S1Value): Promise<SessionModelState> {
99
+ const { s2, s3 } = deriveGenderSets(binding, this.genderByName);
100
+ return this.client.call<SessionModelState>('setConstituentaValues', {
101
+ sessionId: this.sessionId,
102
+ input: {
103
+ items: [
104
+ { target: X1_ID, value: binding },
105
+ { target: S1_ID, value: s1 },
106
+ { target: S2_ID, value: s2 },
107
+ { target: S3_ID, value: s3 }
108
+ ]
109
+ }
110
+ });
111
+ }
112
+
113
+ private async clearModelValues(): Promise<SessionModelState> {
114
+ return this.client.call<SessionModelState>('clearConstituentaValues', {
115
+ sessionId: this.sessionId,
116
+ input: { items: [X1_ID, S1_ID, S2_ID, S3_ID] }
117
+ });
118
+ }
119
+
120
+ public async recalculateModel(): Promise<void> {
121
+ await this.client.call('recalculateModel', { sessionId: this.sessionId });
122
+ }
123
+
124
+ public async commitStep(message: string): Promise<void> {
125
+ await this.client.call('commitStep', { sessionId: this.sessionId, message });
126
+ }
127
+
128
+ public async save(outputPath = this.sessionPath): Promise<string> {
129
+ const exported = await this.client.call<string>('exportSession', { sessionId: this.sessionId });
130
+ const absolutePath = resolve(process.cwd(), outputPath);
131
+ await writeFile(absolutePath, exported, 'utf8');
132
+ return absolutePath;
133
+ }
134
+
135
+ public async addPerson(name: string, gender: Gender): Promise<{ binding: BasicBinding; s1: S1Value }> {
136
+ const binding = await this.getX1Binding();
137
+ const s1 = await this.getS1Value();
138
+ const nextBinding = addPerson(binding, name, gender, this.genderByName);
139
+ await this.applyModelValues(nextBinding, s1);
140
+ await this.recalculateModel();
141
+ return { binding: nextBinding, s1 };
142
+ }
143
+
144
+ public async removePerson(name: string): Promise<{ binding: BasicBinding; s1: S1Value }> {
145
+ const binding = await this.getX1Binding();
146
+ const s1 = await this.getS1Value();
147
+ const next = removePerson(binding, s1, name, this.genderByName);
148
+ await this.applyModelValues(next.binding, next.s1);
149
+ await this.recalculateModel();
150
+ return next;
151
+ }
152
+
153
+ public async renamePerson(oldName: string, newName: string): Promise<BasicBinding> {
154
+ const binding = await this.getX1Binding();
155
+ const nextBinding = renamePerson(binding, oldName, newName, this.genderByName);
156
+ const s1 = await this.getS1Value();
157
+ await this.applyModelValues(nextBinding, s1);
158
+ await this.recalculateModel();
159
+ return nextBinding;
160
+ }
161
+
162
+ public async setX1People(specs: { gender: Gender; name: string }[]): Promise<{
163
+ binding: BasicBinding;
164
+ s1: S1Value;
165
+ }> {
166
+ const oldBinding = await this.getX1Binding();
167
+ const s1 = await this.getS1Value();
168
+ const { binding: nextBinding, genderByName } = setX1WithGender(specs);
169
+ this.genderByName = genderByName;
170
+ const nextS1 = remapS1ByNames(oldBinding, nextBinding, s1);
171
+ await this.applyModelValues(nextBinding, nextS1);
172
+ await this.recalculateModel();
173
+ return { binding: nextBinding, s1: nextS1 };
174
+ }
175
+
176
+ public async clearX1(): Promise<void> {
177
+ this.genderByName = {};
178
+ await this.clearModelValues();
179
+ await this.recalculateModel();
180
+ }
181
+ }
182
+
183
+ export async function withKinshipSession<T>(
184
+ client: RSToolWrapperClient,
185
+ sessionPath: string,
186
+ action: (session: KinshipModelSession) => Promise<T>
187
+ ): Promise<T> {
188
+ const session = await KinshipModelSession.open(client, sessionPath);
189
+ return action(session);
190
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { TUPLE_ID } from './constants';
4
+ import {
5
+ addPerson,
6
+ A1_MAX_PEOPLE,
7
+ deriveGenderSets,
8
+ formatX1,
9
+ parseAddPersonArgs,
10
+ parseGenderToken,
11
+ removePerson,
12
+ renamePerson,
13
+ remapS1ByNames,
14
+ setX1List,
15
+ setX1WithGender,
16
+ satisfiesA1MaxPeople,
17
+ x1Cardinality
18
+ } from './x1-actions';
19
+
20
+ const SAMPLE_BINDING = {
21
+ 0: 'Иван',
22
+ 1: 'Мария',
23
+ 2: 'Пётр',
24
+ 3: 'Анна'
25
+ };
26
+
27
+ const SAMPLE_S1 = [
28
+ [TUPLE_ID, 0, 2],
29
+ [TUPLE_ID, 1, 2],
30
+ [TUPLE_ID, 2, 3]
31
+ ];
32
+
33
+ describe('kinship x1-actions', () => {
34
+ it('parses gender tokens', () => {
35
+ expect(parseGenderToken('м')).toBe('m');
36
+ expect(parseGenderToken('F')).toBe('f');
37
+ expect(parseGenderToken('unknown')).toBeNull();
38
+ });
39
+
40
+ it('parses add args as gender then name', () => {
41
+ expect(parseAddPersonArgs(['м', 'Олег'])).toEqual({ gender: 'm', name: 'Олег' });
42
+ expect(parseAddPersonArgs(['ж', 'Anna', 'Maria'])).toEqual({ gender: 'f', name: 'Anna Maria' });
43
+ });
44
+
45
+ it('adds person with next index and records gender', () => {
46
+ const genderByName: Record<string, 'm' | 'f'> = { Иван: 'm', Мария: 'f', Пётр: 'm', Анна: 'f' };
47
+ const next = addPerson(SAMPLE_BINDING, 'Олег', 'm', genderByName);
48
+ expect(next[4]).toBe('Олег');
49
+ expect(genderByName.Олег).toBe('m');
50
+ expect(formatX1(next, genderByName)).toContain('4: Олег (м)');
51
+ const { s2, s3 } = deriveGenderSets(next, genderByName);
52
+ expect(s2).toContain(4);
53
+ expect(s3).not.toContain(4);
54
+ });
55
+
56
+ it('renames person in place', () => {
57
+ const genderByName: Record<string, 'm' | 'f'> = { Иван: 'm', Мария: 'f', Пётр: 'm', Анна: 'f' };
58
+ const next = renamePerson(SAMPLE_BINDING, 'Пётр', 'Петр', genderByName);
59
+ expect(next[2]).toBe('Петр');
60
+ expect(genderByName.Петр).toBe('m');
61
+ expect(genderByName.Пётр).toBeUndefined();
62
+ });
63
+
64
+ it('rejects blank names on add and rename', () => {
65
+ const genderByName: Record<string, 'm' | 'f'> = {};
66
+ expect(() => addPerson(SAMPLE_BINDING, ' ', 'm', genderByName)).toThrow(/пустым/);
67
+ expect(() => parseAddPersonArgs(['м'])).toThrow(/пол и имя/);
68
+ expect(() => renamePerson(SAMPLE_BINDING, ' ', 'Петр', genderByName)).toThrow(/пустым/);
69
+ expect(() => renamePerson(SAMPLE_BINDING, 'Пётр', ' ', genderByName)).toThrow(/пустым/);
70
+ });
71
+
72
+ it('removes person and reindexes binding and S1', () => {
73
+ const genderByName: Record<string, 'm' | 'f'> = {
74
+ Иван: 'm',
75
+ Мария: 'f',
76
+ Пётр: 'm',
77
+ Анна: 'f'
78
+ };
79
+ const { binding, s1 } = removePerson(SAMPLE_BINDING, SAMPLE_S1, 'Мария', genderByName);
80
+ expect(binding).toEqual({ 0: 'Иван', 1: 'Пётр', 2: 'Анна' });
81
+ expect(s1).toEqual([
82
+ [TUPLE_ID, 0, 1],
83
+ [TUPLE_ID, 1, 2]
84
+ ]);
85
+ expect(genderByName.Мария).toBeUndefined();
86
+ });
87
+
88
+ it('sets X1 with explicit gender pairs', () => {
89
+ const { binding, genderByName } = setX1WithGender([
90
+ { gender: 'f', name: 'Анна' },
91
+ { gender: 'm', name: 'Иван' }
92
+ ]);
93
+ expect(binding).toEqual({ 0: 'Анна', 1: 'Иван' });
94
+ expect(deriveGenderSets(binding, genderByName)).toEqual({ s2: [1], s3: [0] });
95
+ });
96
+
97
+ it('replaces X1 list and remaps S1 by names', () => {
98
+ const nextBinding = setX1List(['Анна', 'Иван', 'Пётр']);
99
+ const nextS1 = remapS1ByNames(SAMPLE_BINDING, nextBinding, SAMPLE_S1);
100
+ expect(nextBinding).toEqual({ 0: 'Анна', 1: 'Иван', 2: 'Пётр' });
101
+ expect(nextS1).toEqual([
102
+ [TUPLE_ID, 1, 2],
103
+ [TUPLE_ID, 2, 0]
104
+ ]);
105
+ });
106
+
107
+ it('remaps S1 using actual binding indices, not list position', () => {
108
+ const sparseBinding = { 0: 'Иван', 5: 'Пётр', 9: 'Анна' };
109
+ const nextS1 = remapS1ByNames(SAMPLE_BINDING, sparseBinding, SAMPLE_S1);
110
+ expect(nextS1).toEqual([
111
+ [TUPLE_ID, 0, 5],
112
+ [TUPLE_ID, 5, 9]
113
+ ]);
114
+ });
115
+
116
+ it('rejects add when A1 would be violated', () => {
117
+ let binding: Record<number, string> = {};
118
+ const genderByName: Record<string, 'm' | 'f'> = {};
119
+ for (let index = 0; index < A1_MAX_PEOPLE; index += 1) {
120
+ binding = addPerson(binding, `P${index}`, index % 2 === 0 ? 'm' : 'f', genderByName);
121
+ }
122
+ expect(x1Cardinality(binding)).toBe(A1_MAX_PEOPLE);
123
+ expect(() => addPerson(binding, 'Лишний', 'm', genderByName)).toThrow(/A1/);
124
+ });
125
+
126
+ it('rejects set when A1 would be violated', () => {
127
+ const names = Array.from({ length: A1_MAX_PEOPLE + 1 }, (_, index) => `P${index}`);
128
+ expect(() => setX1List(names)).toThrow(/A1/);
129
+ });
130
+
131
+ it('allows binding at the A1 limit', () => {
132
+ const names = Array.from({ length: A1_MAX_PEOPLE }, (_, index) => `P${index}`);
133
+ const binding = setX1List(names);
134
+ expect(satisfiesA1MaxPeople(binding)).toBe(true);
135
+ });
136
+ });