selfies-js 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/LICENSE +21 -0
- package/README.md +274 -0
- package/package.json +65 -0
- package/src/alphabet.js +150 -0
- package/src/alphabet.test.js +82 -0
- package/src/chemistryValidator.js +236 -0
- package/src/cli.js +206 -0
- package/src/constraints.js +186 -0
- package/src/constraints.test.js +126 -0
- package/src/decoder.js +636 -0
- package/src/decoder.test.js +560 -0
- package/src/dsl/analyzer.js +170 -0
- package/src/dsl/analyzer.test.js +139 -0
- package/src/dsl/dsl.test.js +146 -0
- package/src/dsl/importer.js +238 -0
- package/src/dsl/index.js +32 -0
- package/src/dsl/lexer.js +264 -0
- package/src/dsl/lexer.test.js +115 -0
- package/src/dsl/parser.js +201 -0
- package/src/dsl/parser.test.js +148 -0
- package/src/dsl/resolver.js +136 -0
- package/src/dsl/resolver.test.js +99 -0
- package/src/dsl/symbolTable.js +56 -0
- package/src/dsl/symbolTable.test.js +68 -0
- package/src/dsl/valenceValidator.js +147 -0
- package/src/encoder.js +467 -0
- package/src/encoder.test.js +61 -0
- package/src/errors.js +79 -0
- package/src/errors.test.js +91 -0
- package/src/grammar_rules.js +146 -0
- package/src/index.js +70 -0
- package/src/parser.js +96 -0
- package/src/parser.test.js +96 -0
- package/src/properties/atoms.js +69 -0
- package/src/properties/atoms.test.js +116 -0
- package/src/properties/formula.js +111 -0
- package/src/properties/formula.test.js +95 -0
- package/src/properties/molecularWeight.js +80 -0
- package/src/properties/molecularWeight.test.js +84 -0
- package/src/properties/properties.test.js +77 -0
- package/src/renderers/README.md +127 -0
- package/src/renderers/svg.js +113 -0
- package/src/renderers/svg.test.js +42 -0
- package/src/syntax.js +641 -0
- package/src/syntax.test.js +363 -0
- package/src/tokenizer.js +99 -0
- package/src/tokenizer.test.js +55 -0
- package/src/validator.js +70 -0
- package/src/validator.test.js +44 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SELFIES decoding
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from 'bun:test'
|
|
6
|
+
import {
|
|
7
|
+
decode,
|
|
8
|
+
decodeToAST,
|
|
9
|
+
parseAtomSymbol,
|
|
10
|
+
readIndexFromTokens,
|
|
11
|
+
deriveBranch,
|
|
12
|
+
processBranchToken,
|
|
13
|
+
processRingToken,
|
|
14
|
+
processAtomToken,
|
|
15
|
+
handleRingClosure,
|
|
16
|
+
assignRingNumbers,
|
|
17
|
+
buildAdjacencyList,
|
|
18
|
+
writeBondSymbol,
|
|
19
|
+
writeRingClosures,
|
|
20
|
+
writeAtomSymbol
|
|
21
|
+
} from './decoder.js'
|
|
22
|
+
|
|
23
|
+
describe('decode', () => {
|
|
24
|
+
test('decodes methane', () => {
|
|
25
|
+
expect(decode('[C]')).toBe('C')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('decodes ethane', () => {
|
|
29
|
+
expect(decode('[C][C]')).toBe('CC')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('decodes ethanol', () => {
|
|
33
|
+
expect(decode('[C][C][O]')).toBe('CCO')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('decodes ethene (double bond)', () => {
|
|
37
|
+
expect(decode('[C][=C]')).toBe('C=C')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('decodes acetylene (triple bond)', () => {
|
|
41
|
+
expect(decode('[C][#C]')).toBe('C#C')
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('parseAtomSymbol', () => {
|
|
46
|
+
test('parses simple carbon atom', () => {
|
|
47
|
+
const result = parseAtomSymbol('C')
|
|
48
|
+
expect(result).toEqual({ element: 'C', bondOrder: 1, stereo: null })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('parses nitrogen atom', () => {
|
|
52
|
+
const result = parseAtomSymbol('N')
|
|
53
|
+
expect(result).toEqual({ element: 'N', bondOrder: 1, stereo: null })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('parses oxygen atom', () => {
|
|
57
|
+
const result = parseAtomSymbol('O')
|
|
58
|
+
expect(result).toEqual({ element: 'O', bondOrder: 1, stereo: null })
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('parses double bonded carbon', () => {
|
|
62
|
+
const result = parseAtomSymbol('=C')
|
|
63
|
+
expect(result).toEqual({ element: 'C', bondOrder: 2, stereo: null })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('parses triple bonded carbon', () => {
|
|
67
|
+
const result = parseAtomSymbol('#C')
|
|
68
|
+
expect(result).toEqual({ element: 'C', bondOrder: 3, stereo: null })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('parses chlorine', () => {
|
|
72
|
+
const result = parseAtomSymbol('Cl')
|
|
73
|
+
expect(result).toEqual({ element: 'Cl', bondOrder: 1, stereo: null })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('parses bromine', () => {
|
|
77
|
+
const result = parseAtomSymbol('Br')
|
|
78
|
+
expect(result).toEqual({ element: 'Br', bondOrder: 1, stereo: null })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('parses carbon with @ stereo', () => {
|
|
82
|
+
const result = parseAtomSymbol('C@')
|
|
83
|
+
expect(result?.element).toBe('C')
|
|
84
|
+
expect(result?.stereo).toBe('C@')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('parses carbon with @@ stereo', () => {
|
|
88
|
+
const result = parseAtomSymbol('C@@')
|
|
89
|
+
expect(result?.element).toBe('C')
|
|
90
|
+
expect(result?.stereo).toBe('C@@')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('parses carbon with @H stereo', () => {
|
|
94
|
+
const result = parseAtomSymbol('C@H')
|
|
95
|
+
expect(result?.element).toBe('C')
|
|
96
|
+
expect(result?.stereo).toBe('C@H')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('returns null for invalid element', () => {
|
|
100
|
+
const result = parseAtomSymbol('Xyz')
|
|
101
|
+
expect(result).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('returns null for empty string', () => {
|
|
105
|
+
const result = parseAtomSymbol('')
|
|
106
|
+
expect(result).toBeNull()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('handles forward slash prefix', () => {
|
|
110
|
+
const result = parseAtomSymbol('/C')
|
|
111
|
+
expect(result).toEqual({ element: 'C', bondOrder: 1, stereo: null })
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('handles backslash prefix', () => {
|
|
115
|
+
const result = parseAtomSymbol('\\C')
|
|
116
|
+
expect(result).toEqual({ element: 'C', bondOrder: 1, stereo: null })
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('readIndexFromTokens', () => {
|
|
121
|
+
test('reads single index token', () => {
|
|
122
|
+
const result = readIndexFromTokens(['[C]', '[N]'], 0, 1)
|
|
123
|
+
expect(result.consumed).toBe(1)
|
|
124
|
+
expect(typeof result.value).toBe('number')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('returns zero for empty token array', () => {
|
|
128
|
+
const result = readIndexFromTokens([], 0, 1)
|
|
129
|
+
expect(result).toEqual({ value: 0, consumed: 0 })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('returns zero when start index is out of bounds', () => {
|
|
133
|
+
const result = readIndexFromTokens(['[C]'], 5, 1)
|
|
134
|
+
expect(result).toEqual({ value: 0, consumed: 0 })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('reads multiple tokens', () => {
|
|
138
|
+
const result = readIndexFromTokens(['[C]', '[N]', '[O]'], 0, 2)
|
|
139
|
+
expect(result.consumed).toBe(2)
|
|
140
|
+
expect(typeof result.value).toBe('number')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('handles non-index tokens as None', () => {
|
|
144
|
+
const result = readIndexFromTokens(['[C]', '[Branch1]'], 0, 2)
|
|
145
|
+
expect(result.consumed).toBe(2)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('deriveBranch', () => {
|
|
150
|
+
test('derives single atom branch', () => {
|
|
151
|
+
const atoms = []
|
|
152
|
+
const bonds = []
|
|
153
|
+
const rings = []
|
|
154
|
+
const tokens = ['[C]']
|
|
155
|
+
|
|
156
|
+
const result = deriveBranch(tokens, 0, 1, 3, 0, atoms, bonds, rings)
|
|
157
|
+
|
|
158
|
+
expect(result.consumed).toBe(1)
|
|
159
|
+
expect(result.derived).toBe(1)
|
|
160
|
+
expect(atoms.length).toBe(1)
|
|
161
|
+
expect(bonds.length).toBe(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('derives multi-atom branch', () => {
|
|
165
|
+
const atoms = [{ element: 'C', capacity: 4, stereo: null }]
|
|
166
|
+
const bonds = []
|
|
167
|
+
const rings = []
|
|
168
|
+
const tokens = ['[C]', '[C]', '[C]']
|
|
169
|
+
|
|
170
|
+
const result = deriveBranch(tokens, 0, 3, 3, 0, atoms, bonds, rings)
|
|
171
|
+
|
|
172
|
+
expect(result.consumed).toBe(3)
|
|
173
|
+
expect(result.derived).toBe(3)
|
|
174
|
+
expect(atoms.length).toBe(4)
|
|
175
|
+
expect(bonds.length).toBe(3)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('stops when state is zero', () => {
|
|
179
|
+
const atoms = []
|
|
180
|
+
const bonds = []
|
|
181
|
+
const rings = []
|
|
182
|
+
const tokens = ['[C]', '[C]']
|
|
183
|
+
|
|
184
|
+
const result = deriveBranch(tokens, 0, 2, 0, null, atoms, bonds, rings)
|
|
185
|
+
|
|
186
|
+
expect(result.consumed).toBe(0)
|
|
187
|
+
expect(result.derived).toBe(0)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('respects maxDerive limit', () => {
|
|
191
|
+
const atoms = [{ element: 'C', capacity: 4, stereo: null }]
|
|
192
|
+
const bonds = []
|
|
193
|
+
const rings = []
|
|
194
|
+
const tokens = ['[C]', '[C]', '[C]', '[C]']
|
|
195
|
+
|
|
196
|
+
const result = deriveBranch(tokens, 0, 2, 3, 0, atoms, bonds, rings)
|
|
197
|
+
|
|
198
|
+
expect(result.derived).toBeLessThanOrEqual(2)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('skips Branch and Ring tokens', () => {
|
|
202
|
+
const atoms = [{ element: 'C', capacity: 4, stereo: null }]
|
|
203
|
+
const bonds = []
|
|
204
|
+
const rings = []
|
|
205
|
+
const tokens = ['[C]', '[Branch1]', '[Ring1]', '[C]']
|
|
206
|
+
|
|
207
|
+
const result = deriveBranch(tokens, 0, 2, 3, 0, atoms, bonds, rings)
|
|
208
|
+
|
|
209
|
+
expect(result.consumed).toBeGreaterThan(0)
|
|
210
|
+
expect(atoms.length).toBeGreaterThan(1)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('processAtomToken', () => {
|
|
215
|
+
test('processes carbon atom', () => {
|
|
216
|
+
const atoms = []
|
|
217
|
+
const bonds = []
|
|
218
|
+
|
|
219
|
+
const result = processAtomToken('C', 0, null, atoms, bonds)
|
|
220
|
+
|
|
221
|
+
expect(result.consumed).toBe(1)
|
|
222
|
+
expect(result.state).toBeGreaterThan(0)
|
|
223
|
+
expect(result.prevAtomIndex).toBe(0)
|
|
224
|
+
expect(atoms.length).toBe(1)
|
|
225
|
+
expect(atoms[0].element).toBe('C')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test('processes double bonded carbon', () => {
|
|
229
|
+
const atoms = [{ element: 'C', capacity: 4, stereo: null }]
|
|
230
|
+
const bonds = []
|
|
231
|
+
|
|
232
|
+
const result = processAtomToken('=C', 3, 0, atoms, bonds)
|
|
233
|
+
|
|
234
|
+
expect(result.consumed).toBe(1)
|
|
235
|
+
expect(atoms.length).toBe(2)
|
|
236
|
+
expect(bonds.length).toBe(1)
|
|
237
|
+
expect(bonds[0].order).toBe(2)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('processes triple bonded carbon', () => {
|
|
241
|
+
const atoms = [{ element: 'C', capacity: 4, stereo: null }]
|
|
242
|
+
const bonds = []
|
|
243
|
+
|
|
244
|
+
const result = processAtomToken('#C', 3, 0, atoms, bonds)
|
|
245
|
+
|
|
246
|
+
expect(result.consumed).toBe(1)
|
|
247
|
+
expect(atoms.length).toBe(2)
|
|
248
|
+
expect(bonds.length).toBe(1)
|
|
249
|
+
expect(bonds[0].order).toBe(3)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('returns unchanged state for invalid atom', () => {
|
|
253
|
+
const atoms = []
|
|
254
|
+
const bonds = []
|
|
255
|
+
|
|
256
|
+
const result = processAtomToken('Xyz', 3, null, atoms, bonds)
|
|
257
|
+
|
|
258
|
+
expect(result.consumed).toBe(1)
|
|
259
|
+
expect(result.state).toBe(3)
|
|
260
|
+
expect(result.prevAtomIndex).toBeNull()
|
|
261
|
+
expect(atoms.length).toBe(0)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('does not add bond for first atom', () => {
|
|
265
|
+
const atoms = []
|
|
266
|
+
const bonds = []
|
|
267
|
+
|
|
268
|
+
const result = processAtomToken('C', 0, null, atoms, bonds)
|
|
269
|
+
|
|
270
|
+
expect(bonds.length).toBe(0)
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('handleRingClosure', () => {
|
|
275
|
+
test('adds new ring closure', () => {
|
|
276
|
+
const bonds = []
|
|
277
|
+
const rings = []
|
|
278
|
+
|
|
279
|
+
handleRingClosure(0, 2, 1, bonds, rings)
|
|
280
|
+
|
|
281
|
+
expect(rings.length).toBe(1)
|
|
282
|
+
expect(rings[0]).toEqual({ from: 0, to: 2, order: 1 })
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('increases existing bond order', () => {
|
|
286
|
+
const bonds = [{ from: 0, to: 2, order: 1 }]
|
|
287
|
+
const rings = []
|
|
288
|
+
|
|
289
|
+
handleRingClosure(0, 2, 1, bonds, rings)
|
|
290
|
+
|
|
291
|
+
expect(bonds[0].order).toBe(2)
|
|
292
|
+
expect(rings.length).toBe(0)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('increases existing ring order', () => {
|
|
296
|
+
const bonds = []
|
|
297
|
+
const rings = [{ from: 0, to: 2, order: 1 }]
|
|
298
|
+
|
|
299
|
+
handleRingClosure(0, 2, 1, bonds, rings)
|
|
300
|
+
|
|
301
|
+
expect(rings[0].order).toBe(2)
|
|
302
|
+
expect(rings.length).toBe(1)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('caps bond order at 3', () => {
|
|
306
|
+
const bonds = [{ from: 0, to: 2, order: 3 }]
|
|
307
|
+
const rings = []
|
|
308
|
+
|
|
309
|
+
handleRingClosure(0, 2, 2, bonds, rings)
|
|
310
|
+
|
|
311
|
+
expect(bonds[0].order).toBe(3)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('handles reversed bond direction', () => {
|
|
315
|
+
const bonds = [{ from: 2, to: 0, order: 1 }]
|
|
316
|
+
const rings = []
|
|
317
|
+
|
|
318
|
+
handleRingClosure(0, 2, 1, bonds, rings)
|
|
319
|
+
|
|
320
|
+
expect(bonds[0].order).toBe(2)
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
describe('assignRingNumbers', () => {
|
|
325
|
+
test('assigns numbers to rings', () => {
|
|
326
|
+
const rings = [
|
|
327
|
+
{ from: 0, to: 2, order: 1 },
|
|
328
|
+
{ from: 1, to: 3, order: 1 }
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
const ringNumbers = assignRingNumbers(rings)
|
|
332
|
+
|
|
333
|
+
expect(ringNumbers.get('0-2')).toBe(1)
|
|
334
|
+
expect(ringNumbers.get('2-0')).toBe(1)
|
|
335
|
+
expect(ringNumbers.get('1-3')).toBe(2)
|
|
336
|
+
expect(ringNumbers.get('3-1')).toBe(2)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('handles empty rings array', () => {
|
|
340
|
+
const ringNumbers = assignRingNumbers([])
|
|
341
|
+
expect(ringNumbers.size).toBe(0)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('assigns bidirectional mappings', () => {
|
|
345
|
+
const rings = [{ from: 0, to: 5, order: 1 }]
|
|
346
|
+
|
|
347
|
+
const ringNumbers = assignRingNumbers(rings)
|
|
348
|
+
|
|
349
|
+
expect(ringNumbers.get('0-5')).toBe(ringNumbers.get('5-0'))
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('buildAdjacencyList', () => {
|
|
354
|
+
test('builds adjacency list from bonds', () => {
|
|
355
|
+
const atoms = [
|
|
356
|
+
{ element: 'C', capacity: 4, stereo: null },
|
|
357
|
+
{ element: 'C', capacity: 4, stereo: null },
|
|
358
|
+
{ element: 'C', capacity: 4, stereo: null }
|
|
359
|
+
]
|
|
360
|
+
const bonds = [
|
|
361
|
+
{ from: 0, to: 1, order: 1 },
|
|
362
|
+
{ from: 1, to: 2, order: 2 }
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
const adj = buildAdjacencyList(atoms, bonds)
|
|
366
|
+
|
|
367
|
+
expect(adj.get(0).length).toBe(1)
|
|
368
|
+
expect(adj.get(0)[0]).toEqual({ to: 1, order: 1 })
|
|
369
|
+
expect(adj.get(1).length).toBe(2)
|
|
370
|
+
expect(adj.get(2).length).toBe(1)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('creates entries for all atoms', () => {
|
|
374
|
+
const atoms = [
|
|
375
|
+
{ element: 'C', capacity: 4, stereo: null },
|
|
376
|
+
{ element: 'C', capacity: 4, stereo: null }
|
|
377
|
+
]
|
|
378
|
+
const bonds = []
|
|
379
|
+
|
|
380
|
+
const adj = buildAdjacencyList(atoms, bonds)
|
|
381
|
+
|
|
382
|
+
expect(adj.size).toBe(2)
|
|
383
|
+
expect(adj.get(0)).toEqual([])
|
|
384
|
+
expect(adj.get(1)).toEqual([])
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
describe('writeBondSymbol', () => {
|
|
389
|
+
test('writes single bond (nothing)', () => {
|
|
390
|
+
const smiles = []
|
|
391
|
+
writeBondSymbol(1, smiles)
|
|
392
|
+
expect(smiles).toEqual([])
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
test('writes double bond', () => {
|
|
396
|
+
const smiles = []
|
|
397
|
+
writeBondSymbol(2, smiles)
|
|
398
|
+
expect(smiles).toEqual(['='])
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
test('writes triple bond', () => {
|
|
402
|
+
const smiles = []
|
|
403
|
+
writeBondSymbol(3, smiles)
|
|
404
|
+
expect(smiles).toEqual(['#'])
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
describe('writeAtomSymbol', () => {
|
|
409
|
+
test('writes simple atom', () => {
|
|
410
|
+
const smiles = []
|
|
411
|
+
const atom = { element: 'C', capacity: 4, stereo: null }
|
|
412
|
+
writeAtomSymbol(atom, smiles)
|
|
413
|
+
expect(smiles).toEqual(['C'])
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('writes atom with stereo', () => {
|
|
417
|
+
const smiles = []
|
|
418
|
+
const atom = { element: 'C', capacity: 4, stereo: 'C@H' }
|
|
419
|
+
writeAtomSymbol(atom, smiles)
|
|
420
|
+
expect(smiles).toEqual(['[C@H]'])
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
describe('writeRingClosures', () => {
|
|
425
|
+
test('writes opening ring closure', () => {
|
|
426
|
+
const smiles = []
|
|
427
|
+
const rings = [{ from: 0, to: 2, order: 1 }]
|
|
428
|
+
const ringNumbers = new Map([['0-2', 1], ['2-0', 1]])
|
|
429
|
+
const visited = new Set([0])
|
|
430
|
+
|
|
431
|
+
writeRingClosures(0, rings, ringNumbers, visited, smiles)
|
|
432
|
+
|
|
433
|
+
expect(smiles).toEqual(['1'])
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('writes closing ring closure', () => {
|
|
437
|
+
const smiles = []
|
|
438
|
+
const rings = [{ from: 0, to: 2, order: 1 }]
|
|
439
|
+
const ringNumbers = new Map([['0-2', 1], ['2-0', 1]])
|
|
440
|
+
const visited = new Set([0, 1, 2])
|
|
441
|
+
|
|
442
|
+
writeRingClosures(2, rings, ringNumbers, visited, smiles)
|
|
443
|
+
|
|
444
|
+
expect(smiles).toEqual(['1'])
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('writes double bond ring closure', () => {
|
|
448
|
+
const smiles = []
|
|
449
|
+
const rings = [{ from: 0, to: 2, order: 2 }]
|
|
450
|
+
const ringNumbers = new Map([['0-2', 1], ['2-0', 1]])
|
|
451
|
+
const visited = new Set([0])
|
|
452
|
+
|
|
453
|
+
writeRingClosures(0, rings, ringNumbers, visited, smiles)
|
|
454
|
+
|
|
455
|
+
expect(smiles).toEqual(['=', '1'])
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test('writes triple bond ring closure', () => {
|
|
459
|
+
const smiles = []
|
|
460
|
+
const rings = [{ from: 0, to: 2, order: 3 }]
|
|
461
|
+
const ringNumbers = new Map([['0-2', 1], ['2-0', 1]])
|
|
462
|
+
const visited = new Set([0])
|
|
463
|
+
|
|
464
|
+
writeRingClosures(0, rings, ringNumbers, visited, smiles)
|
|
465
|
+
|
|
466
|
+
expect(smiles).toEqual(['#', '1'])
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
describe('Bug reproductions', () => {
|
|
471
|
+
test('BUG: Triple bond becomes double bond with low state', () => {
|
|
472
|
+
// When state=2 and we request bond order 3, we should get bond order 2
|
|
473
|
+
// This is the root cause of [C][=C][#C] having wrong bond order
|
|
474
|
+
const atoms = [
|
|
475
|
+
{ element: 'C', capacity: 4, stereo: null },
|
|
476
|
+
{ element: 'C', capacity: 4, stereo: null }
|
|
477
|
+
]
|
|
478
|
+
const bonds = []
|
|
479
|
+
|
|
480
|
+
// Simulate: prevAtom has state=2 remaining, next atom requests order 3
|
|
481
|
+
const result = processAtomToken('#C', 2, 1, atoms, bonds)
|
|
482
|
+
|
|
483
|
+
expect(result.state).toBe(2) // Carbon has capacity 4, bond uses 2, so 4-2=2 remaining
|
|
484
|
+
expect(atoms.length).toBe(3)
|
|
485
|
+
expect(bonds.length).toBe(1)
|
|
486
|
+
expect(bonds[0].order).toBe(2) // Should be min(3, 2, 4) = 2
|
|
487
|
+
expect(bonds[0].from).toBe(1)
|
|
488
|
+
expect(bonds[0].to).toBe(2)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
test('DOCUMENTED BEHAVIOR: Triple bond limited by state', () => {
|
|
492
|
+
// [C][=C][#C] produces bonds [order 2, order 2] NOT [order 2, order 3]
|
|
493
|
+
// Because after [=C], state is 2, so [#C] can only get bond order min(3,2,4)=2
|
|
494
|
+
const ast = decodeToAST('[C][=C][#C]')
|
|
495
|
+
|
|
496
|
+
expect(ast).toEqual({
|
|
497
|
+
atoms: [
|
|
498
|
+
{ element: 'C', capacity: 4, stereo: null },
|
|
499
|
+
{ element: 'C', capacity: 4, stereo: null },
|
|
500
|
+
{ element: 'C', capacity: 4, stereo: null }
|
|
501
|
+
],
|
|
502
|
+
bonds: [
|
|
503
|
+
{ from: 0, to: 1, order: 2 }, // =C gets full double bond
|
|
504
|
+
{ from: 1, to: 2, order: 2 } // #C limited to 2 by state!
|
|
505
|
+
],
|
|
506
|
+
rings: []
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
describe('SELFIES State Machine Documentation', () => {
|
|
512
|
+
/**
|
|
513
|
+
* CRITICAL UNDERSTANDING: SELFIES uses a state machine where "state" represents
|
|
514
|
+
* the remaining bonding capacity at the current position in the derivation.
|
|
515
|
+
*
|
|
516
|
+
* State transitions:
|
|
517
|
+
* - Start: state = 0
|
|
518
|
+
* - After adding atom with capacity N and forming bond of order B:
|
|
519
|
+
* newState = N - B (remaining capacity of that atom)
|
|
520
|
+
* - When state = 0: cannot form more bonds in this chain
|
|
521
|
+
* - When state = null: chain is terminated
|
|
522
|
+
*
|
|
523
|
+
* Bond order resolution:
|
|
524
|
+
* - Requested bond (from = or # prefix) is limited by THREE factors:
|
|
525
|
+
* 1. The request itself (1, 2, or 3)
|
|
526
|
+
* 2. Current state (remaining capacity from previous atom)
|
|
527
|
+
* 3. New atom's total capacity
|
|
528
|
+
* - Actual bond = min(requested, state, capacity)
|
|
529
|
+
*
|
|
530
|
+
* Example: [C][=C][#C]
|
|
531
|
+
* - [C]: state 0 → atom(C, cap=4) → state 4
|
|
532
|
+
* - [=C]: request=2, state=4, cap=4 → bond=min(2,4,4)=2, newState=4-2=2
|
|
533
|
+
* - [#C]: request=3, state=2, cap=4 → bond=min(3,2,4)=2, newState=4-2=2
|
|
534
|
+
* ^^^^^^^ LIMITED BY STATE!
|
|
535
|
+
*/
|
|
536
|
+
test('State machine documentation - bond order limits', () => {
|
|
537
|
+
const atoms = []
|
|
538
|
+
const bonds = []
|
|
539
|
+
let state = 0
|
|
540
|
+
let prevAtom = null
|
|
541
|
+
|
|
542
|
+
// First C
|
|
543
|
+
let result = processAtomToken('C', state, prevAtom, atoms, bonds)
|
|
544
|
+
expect(result.state).toBe(4)
|
|
545
|
+
state = result.state
|
|
546
|
+
prevAtom = result.prevAtomIndex
|
|
547
|
+
|
|
548
|
+
// =C (double bond requested)
|
|
549
|
+
result = processAtomToken('=C', state, prevAtom, atoms, bonds)
|
|
550
|
+
expect(bonds[bonds.length - 1].order).toBe(2) // Gets full double bond
|
|
551
|
+
expect(result.state).toBe(2) // 4 - 2 = 2 remaining
|
|
552
|
+
state = result.state
|
|
553
|
+
prevAtom = result.prevAtomIndex
|
|
554
|
+
|
|
555
|
+
// #C (triple bond requested, but state only allows 2)
|
|
556
|
+
result = processAtomToken('#C', state, prevAtom, atoms, bonds)
|
|
557
|
+
expect(bonds[bonds.length - 1].order).toBe(2) // LIMITED to 2 by state!
|
|
558
|
+
expect(result.state).toBe(2) // 4 - 2 = 2
|
|
559
|
+
})
|
|
560
|
+
})
|