@lokascript/framework 4.7.0 → 4.8.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 +35 -1
- package/dist/api/create-dsl.d.ts +5 -2
- package/dist/api/create-dsl.d.ts.map +1 -1
- package/dist/api/index.js +45 -5
- package/dist/api/index.js.map +1 -1
- package/dist/core/index.js +10 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/matching/stock-reader.d.ts +8 -1
- package/dist/core/matching/stock-reader.d.ts.map +1 -1
- package/dist/core/pattern-matching/index.js.map +1 -1
- package/dist/core/types.d.ts +5 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/generation/index.js +12 -2
- package/dist/generation/index.js.map +1 -1
- package/dist/index.cjs +45 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +45 -5
- package/dist/index.js.map +1 -1
- package/dist/ir/index.js.map +1 -1
- package/dist/parsing/index.js.map +1 -1
- package/dist/testing/gate/index.js +44 -12
- package/dist/testing/gate/index.js.map +1 -1
- package/dist/testing/gate/round-trip.d.ts +24 -3
- package/dist/testing/gate/round-trip.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/api/create-dsl-engine.test.ts +65 -1
- package/src/api/create-dsl.test.ts +7 -0
- package/src/api/create-dsl.ts +32 -4
- package/src/core/matching/stock-reader.test.ts +87 -3
- package/src/core/matching/stock-reader.ts +20 -1
- package/src/core/types.ts +5 -0
- package/src/generation/schema-patterns.test.ts +41 -1
- package/src/generation/schema-patterns.ts +12 -0
- package/src/testing/gate/round-trip.test.ts +99 -9
- package/src/testing/gate/round-trip.ts +76 -11
package/src/api/create-dsl.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
SemanticNode,
|
|
7
|
+
LanguageToken,
|
|
7
8
|
LanguageTokenizer,
|
|
8
9
|
LanguagePattern,
|
|
9
10
|
PatternMatchResult,
|
|
@@ -229,7 +230,9 @@ export interface CompileResult {
|
|
|
229
230
|
/**
|
|
230
231
|
* Thrown by `parse` (and so `compile`, `validate`, `translate`) when the input reads as a command
|
|
231
232
|
* only by leaving some of it unread: `select name from users banana` is not `select name from users`.
|
|
232
|
-
* `reading` is what the longest such match read, `unread` the text after it.
|
|
233
|
+
* `reading` is what the longest such match read, `unread` the text after it. `hint`, when there is
|
|
234
|
+
* one, says how the input might read: where a bare word read as a value is followed by another, the
|
|
235
|
+
* two may be one value written without quotes (todo `add buy milk to groceries`).
|
|
233
236
|
*/
|
|
234
237
|
export class UnreadInputError extends Error {
|
|
235
238
|
readonly code = 'unread-input';
|
|
@@ -238,9 +241,13 @@ export class UnreadInputError extends Error {
|
|
|
238
241
|
readonly input: string,
|
|
239
242
|
readonly language: string,
|
|
240
243
|
readonly reading: SemanticNode,
|
|
241
|
-
readonly unread: string
|
|
244
|
+
readonly unread: string,
|
|
245
|
+
readonly hint?: string
|
|
242
246
|
) {
|
|
243
|
-
super(
|
|
247
|
+
super(
|
|
248
|
+
`"${input}" (${language}) reads as ${reading.action} only by leaving "${unread}" unread` +
|
|
249
|
+
(hint ? `; ${hint}` : '')
|
|
250
|
+
);
|
|
244
251
|
this.name = 'UnreadInputError';
|
|
245
252
|
}
|
|
246
253
|
}
|
|
@@ -491,16 +498,37 @@ class MultilingualDSLImpl implements MultilingualDSL {
|
|
|
491
498
|
.slice(longestPartial.consumedTokens)
|
|
492
499
|
.map(t => t.value)
|
|
493
500
|
.join(' ');
|
|
501
|
+
const hint = this.quoteHint(longestPartial, tokens.tokens);
|
|
494
502
|
throw new UnreadInputError(
|
|
495
503
|
input,
|
|
496
504
|
language,
|
|
497
505
|
this.nodeOf(longestPartial, input, language),
|
|
498
|
-
unread
|
|
506
|
+
unread,
|
|
507
|
+
...(hint ? [hint] : [])
|
|
499
508
|
);
|
|
500
509
|
}
|
|
501
510
|
throw new Error(`No pattern matched for input: ${input}`);
|
|
502
511
|
}
|
|
503
512
|
|
|
513
|
+
/**
|
|
514
|
+
* `if "buy milk" is one value, quote it`, where the last token a match read is a bare word it took
|
|
515
|
+
* as a role's value (a role with no word list) and the first it left is a bare word too.
|
|
516
|
+
*/
|
|
517
|
+
private quoteHint(
|
|
518
|
+
match: PatternMatchResult,
|
|
519
|
+
tokens: readonly LanguageToken[]
|
|
520
|
+
): string | undefined {
|
|
521
|
+
const last = tokens[match.consumedTokens - 1];
|
|
522
|
+
const next = tokens[match.consumedTokens];
|
|
523
|
+
if (last?.kind !== 'identifier' || next?.kind !== 'identifier') return undefined;
|
|
524
|
+
const schema = this.schemaLookup.getSchema(match.pattern.command);
|
|
525
|
+
const role = [...match.captured].find(
|
|
526
|
+
([, v]) => (v as { raw?: unknown }).raw === last.value
|
|
527
|
+
)?.[0];
|
|
528
|
+
if (!role || schema?.roles.find(r => r.role === role)?.words) return undefined;
|
|
529
|
+
return `if "${last.value} ${next.value}" is one value, quote it`;
|
|
530
|
+
}
|
|
531
|
+
|
|
504
532
|
private nodeOf(match: PatternMatchResult, input: string, language: string): SemanticNode {
|
|
505
533
|
return {
|
|
506
534
|
kind: 'command',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The stock value reader on the matcher's skeleton (domains rethink Phase 4.7):
|
|
3
|
-
* each kind of value it reads, a greedy slot's stop markers,
|
|
4
|
-
* marker read as a value, and the skip of an optional slot.
|
|
3
|
+
* each kind of value it reads, a greedy slot's stop markers, a slot's word list,
|
|
4
|
+
* the guard against a marker read as a value, and the skip of an optional slot.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
7
|
import { MatchSkeleton } from './match-skeleton';
|
|
@@ -29,7 +29,7 @@ const lit = (value: string, ...alternatives: string[]): PatternToken =>
|
|
|
29
29
|
alternatives.length ? { type: 'literal', value, alternatives } : { type: 'literal', value };
|
|
30
30
|
const slot = (
|
|
31
31
|
role: string,
|
|
32
|
-
extra: { optional?: boolean; greedy?: boolean; expectedTypes?: string[] } = {}
|
|
32
|
+
extra: { optional?: boolean; greedy?: boolean; expectedTypes?: string[]; words?: string[] } = {}
|
|
33
33
|
): PatternToken => ({ type: 'role', role, ...extra }) as PatternToken;
|
|
34
34
|
const group = (...tokens: PatternToken[]): PatternToken => ({
|
|
35
35
|
type: 'group',
|
|
@@ -129,6 +129,53 @@ describe('a greedy slot', () => {
|
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
describe('a slot with a word list', () => {
|
|
133
|
+
const click = pattern([lit('when'), slot('action', { words: ['clicks', 'クリック'] })]);
|
|
134
|
+
|
|
135
|
+
it('reads one of its words, in any case, by value or normalized form', () => {
|
|
136
|
+
expect(read(click, ['when', 'keyword'], ['Clicks', 'identifier'])).toEqual({
|
|
137
|
+
action: { type: 'expression', raw: 'Clicks' },
|
|
138
|
+
});
|
|
139
|
+
expect(read(click, ['when', 'keyword'], ['クリックする', 'identifier', 'クリック'])).toEqual({
|
|
140
|
+
action: { type: 'expression', raw: 'クリックする' },
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('leaves any other word unread', () => {
|
|
145
|
+
expect(read(click, ['when', 'keyword'], ['zzqx', 'identifier'])).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('gives the word to the required slot when the optional slot before it took it (behaviorspec `when clicks zzqx`)', () => {
|
|
149
|
+
const when = pattern([
|
|
150
|
+
lit('when'),
|
|
151
|
+
group(slot('actor', { optional: true })),
|
|
152
|
+
slot('action', { words: ['clicks'] }),
|
|
153
|
+
]);
|
|
154
|
+
const result = matcher().matchPattern(
|
|
155
|
+
stream(['when', 'keyword'], ['clicks', 'identifier'], ['zzqx', 'identifier']),
|
|
156
|
+
when
|
|
157
|
+
);
|
|
158
|
+
expect(result && Object.fromEntries(result.captured)).toEqual({
|
|
159
|
+
action: { type: 'expression', raw: 'clicks' },
|
|
160
|
+
});
|
|
161
|
+
// `zzqx` is left for the caller to refuse as unread.
|
|
162
|
+
expect(result?.consumedTokens).toBe(2);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('an optional one is skipped for a word it does not list (voice `close zzqx`)', () => {
|
|
166
|
+
const close = pattern([lit('close'), slot('patient', { optional: true, words: ['tab'] })]);
|
|
167
|
+
expect(read(close, ['close', 'keyword'], ['tab', 'identifier'])).toEqual({
|
|
168
|
+
patient: { type: 'expression', raw: 'tab' },
|
|
169
|
+
});
|
|
170
|
+
const result = matcher().matchPattern(
|
|
171
|
+
stream(['close', 'keyword'], ['zzqx', 'identifier']),
|
|
172
|
+
close
|
|
173
|
+
);
|
|
174
|
+
expect(result && Object.fromEntries(result.captured)).toEqual({});
|
|
175
|
+
expect(result?.consumedTokens).toBe(1);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
132
179
|
describe('the marker guard and the optional skip', () => {
|
|
133
180
|
it('a required slot does not read the marker the pattern wants next', () => {
|
|
134
181
|
// SOV `{patient} を 実行` given `を 実行`: the patient is missing, not `を`.
|
|
@@ -136,6 +183,43 @@ describe('the marker guard and the optional skip', () => {
|
|
|
136
183
|
expect(read(sov, ['を', 'particle'], ['実行', 'keyword'])).toBeNull();
|
|
137
184
|
});
|
|
138
185
|
|
|
186
|
+
it('an optional slot does not read a marker the rest of the pattern owes, through its optional groups', () => {
|
|
187
|
+
// behaviorspec `when user types into #search`: the typed text is optional and has no marker.
|
|
188
|
+
const when = pattern([
|
|
189
|
+
lit('when'),
|
|
190
|
+
slot('action'),
|
|
191
|
+
group(slot('value', { optional: true })),
|
|
192
|
+
group(lit('on'), slot('target')),
|
|
193
|
+
group(lit('into'), slot('destination')),
|
|
194
|
+
]);
|
|
195
|
+
expect(
|
|
196
|
+
read(
|
|
197
|
+
when,
|
|
198
|
+
['when', 'keyword'],
|
|
199
|
+
['types', 'identifier'],
|
|
200
|
+
['into', 'keyword'],
|
|
201
|
+
['#q', 'selector']
|
|
202
|
+
)
|
|
203
|
+
).toEqual({
|
|
204
|
+
action: { type: 'expression', raw: 'types' },
|
|
205
|
+
destination: { type: 'selector', value: '#q' },
|
|
206
|
+
});
|
|
207
|
+
expect(
|
|
208
|
+
read(
|
|
209
|
+
when,
|
|
210
|
+
['when', 'keyword'],
|
|
211
|
+
['types', 'identifier'],
|
|
212
|
+
["'hi'", 'literal'],
|
|
213
|
+
['into', 'keyword'],
|
|
214
|
+
['#q', 'selector']
|
|
215
|
+
)
|
|
216
|
+
).toEqual({
|
|
217
|
+
action: { type: 'expression', raw: 'types' },
|
|
218
|
+
value: { type: 'literal', value: 'hi', dataType: 'string' },
|
|
219
|
+
destination: { type: 'selector', value: '#q' },
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
139
223
|
it('an optional slot is skipped for the literal the pattern wants next, when the rest then reads to the end', () => {
|
|
140
224
|
const p = pattern([lit('go'), group(slot('x', { optional: true })), lit('now')]);
|
|
141
225
|
expect(read(p, ['go', 'keyword'], ['now', 'keyword'])).toEqual({});
|
|
@@ -7,13 +7,18 @@
|
|
|
7
7
|
* - A **greedy** slot (`RoleSpec.greedy`: a SQL condition, a column list) reads
|
|
8
8
|
* every token up to the next marker the pattern still owes, or the end, as
|
|
9
9
|
* one expression (`readGreedy`).
|
|
10
|
+
* - A slot with a **word list** (`RoleSpec.words`: behaviorspec's interaction,
|
|
11
|
+
* voice's thing to close) reads only one of its words, compared lower-cased
|
|
12
|
+
* by value or normalized form; any other word leaves the slot unread.
|
|
10
13
|
* - Any other slot reads one value (`readRoleValue`, the old matcher's reading):
|
|
11
14
|
* a selector, a quoted string, a number, a boolean, a duration, a URL, an
|
|
12
15
|
* identifier, or one of the multi-token forms it knows (`user.name`,
|
|
13
16
|
* `#dialog.showModal()`), checked against the slot's expected types.
|
|
14
17
|
* - A **required** slot reads nothing where the stream is at the marker the
|
|
15
18
|
* pattern wants next (the old matcher's guard against an SOV marker taken as
|
|
16
|
-
* a value).
|
|
19
|
+
* a value). An **optional** one reads nothing at any marker the rest of the
|
|
20
|
+
* pattern owes, through its optional groups (behaviorspec `when user types
|
|
21
|
+
* into #search`, whose typed text is optional and has no marker). Where a required slot finds nothing to read, and the optional,
|
|
17
22
|
* marker-less slot just before it read a value with nothing read since, that
|
|
18
23
|
* value is given back and the required slot reads again (the old matcher's
|
|
19
24
|
* backtrack: behaviorspec `when clicks`, where the optional actor took
|
|
@@ -89,6 +94,13 @@ export function readGreedy(
|
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/** The stock reader, for a matcher whose profiles it may consult (possessive words). */
|
|
97
|
+
/** Whether `token` is one of a slot's `words`, by value or normalized form, lower-cased. */
|
|
98
|
+
export function isListedWord(token: LanguageToken | null, words: readonly string[]): boolean {
|
|
99
|
+
if (!token) return false;
|
|
100
|
+
const forms = [token.value, token.normalized].filter((f): f is string => typeof f === 'string');
|
|
101
|
+
return forms.some(form => words.some(word => word.toLowerCase() === form.toLowerCase()));
|
|
102
|
+
}
|
|
103
|
+
|
|
92
104
|
export class StockValueReader implements ValueReader {
|
|
93
105
|
private profile: ReadingProfile | undefined;
|
|
94
106
|
/** The last optional, marker-less slot that read a value: what a failing required slot may take back. */
|
|
@@ -147,6 +159,13 @@ export class StockValueReader implements ValueReader {
|
|
|
147
159
|
const token = tokens.peek();
|
|
148
160
|
if (token && this.view.wouldMatch(next, token)) return false;
|
|
149
161
|
}
|
|
162
|
+
if (slot.words && !isListedWord(tokens.peek(), slot.words)) return slot.optional ?? false;
|
|
163
|
+
// Nor does an optional slot read a marker the rest of the pattern owes, through its optional
|
|
164
|
+
// groups (`when user types into #search`: `into` is the destination's, not the typed text).
|
|
165
|
+
if (slot.optional && !slot.greedy) {
|
|
166
|
+
const token = tokens.peek();
|
|
167
|
+
if (token && isStopMarker(token, greedyStopMarkers(this.view.patternRest()))) return true;
|
|
168
|
+
}
|
|
150
169
|
if (slot.greedy) {
|
|
151
170
|
const value = readGreedy(tokens, greedyStopMarkers(this.view.patternRest()));
|
|
152
171
|
if (value) {
|
package/src/core/types.ts
CHANGED
|
@@ -174,6 +174,11 @@ export interface RolePatternToken {
|
|
|
174
174
|
readonly optional?: boolean;
|
|
175
175
|
readonly expectedTypes?: Array<ExpectedType>;
|
|
176
176
|
readonly greedy?: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* The words the slot reads, from the schema's `RoleSpec.words` for the pattern's language (its
|
|
179
|
+
* `'*'` list and the language's own): the stock reader reads the slot only from one of them.
|
|
180
|
+
*/
|
|
181
|
+
readonly words?: readonly string[];
|
|
177
182
|
/**
|
|
178
183
|
* Mirrors the schema's `RoleSpec.valueShape`: the slot's surface form is
|
|
179
184
|
* unambiguous on its own. Carried onto the pattern token because the value
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The schema-pattern generator semantic's hyperscript parser runs (domains
|
|
3
3
|
* rethink Phase 4.4), on a schema of its own: the grammar's position order,
|
|
4
|
-
* the variants,
|
|
4
|
+
* the variants, a profile's optional markers, and a role's word list.
|
|
5
5
|
*/
|
|
6
6
|
import { describe, expect, it } from 'vitest';
|
|
7
7
|
import { defineCommand, defineRole } from '../schema';
|
|
@@ -80,6 +80,46 @@ describe('createSchemaPatternGenerator', () => {
|
|
|
80
80
|
]);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
it("puts a role's word list for the pattern's language on its token, in every variant", () => {
|
|
84
|
+
const close = defineCommand({
|
|
85
|
+
action: 'send',
|
|
86
|
+
roles: [
|
|
87
|
+
defineRole({
|
|
88
|
+
role: 'patient',
|
|
89
|
+
required: true,
|
|
90
|
+
expectedTypes: ['expression'],
|
|
91
|
+
words: { '*': ['tab'], tr: ['sekme'] },
|
|
92
|
+
}),
|
|
93
|
+
defineRole({ role: 'destination', required: false, expectedTypes: ['selector'] }),
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
const generator = createSchemaPatternGenerator({ positionOrder: 'ascending' });
|
|
97
|
+
const wordsOf = (tokens: readonly PatternToken[]): unknown[] =>
|
|
98
|
+
tokens.flatMap(t =>
|
|
99
|
+
t.type === 'role' ? [[t.role, t.words]] : t.type === 'group' ? wordsOf(t.tokens) : []
|
|
100
|
+
);
|
|
101
|
+
const at = (profile: SchemaPatternProfile) => {
|
|
102
|
+
const variants = generator.generatePatternVariants(close, profile, {
|
|
103
|
+
basePriority: 100,
|
|
104
|
+
generateSimpleVariants: true,
|
|
105
|
+
});
|
|
106
|
+
const roles = variants.flatMap(p => wordsOf(p.template.tokens)) as [string, unknown][];
|
|
107
|
+
return {
|
|
108
|
+
variants: variants.length,
|
|
109
|
+
patient: [
|
|
110
|
+
...new Set(roles.filter(([r]) => r === 'patient').map(([, w]) => JSON.stringify(w))),
|
|
111
|
+
],
|
|
112
|
+
destination: [
|
|
113
|
+
...new Set(roles.filter(([r]) => r === 'destination').map(([, w]) => JSON.stringify(w))),
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
expect(at(en)).toEqual({ variants: 2, patient: ['["tab"]'], destination: [undefined] });
|
|
118
|
+
expect(at(tr)).toEqual({ variants: 3, patient: ['["tab","sekme"]'], destination: [undefined] });
|
|
119
|
+
// No list, no field: the patterns of a grammar that lists no words are unchanged.
|
|
120
|
+
expect(JSON.stringify(generator.generatePattern(send, en))).not.toContain('words');
|
|
121
|
+
});
|
|
122
|
+
|
|
83
123
|
it('drops variant-only roles', () => {
|
|
84
124
|
const scroll = defineCommand({
|
|
85
125
|
action: 'scroll',
|
|
@@ -382,11 +382,13 @@ function generateVerbFirstPattern(
|
|
|
382
382
|
// verb-first, which is exactly this shape (trailing particle tolerated).
|
|
383
383
|
const roleTokens: PatternToken[] = requiredRoles.flatMap(r => {
|
|
384
384
|
const prefix = r.valuePrefixLiteral?.[profile.code];
|
|
385
|
+
const words = wordsFor(r, profile.code);
|
|
385
386
|
const roleToken: PatternToken = {
|
|
386
387
|
type: 'role',
|
|
387
388
|
role: r.role,
|
|
388
389
|
optional: false,
|
|
389
390
|
expectedTypes: r.expectedTypes,
|
|
391
|
+
...(words ? { words } : {}),
|
|
390
392
|
};
|
|
391
393
|
return prefix ? [{ type: 'literal', value: prefix } as PatternToken, roleToken] : [roleToken];
|
|
392
394
|
});
|
|
@@ -673,6 +675,13 @@ function buildRoleTokens(
|
|
|
673
675
|
* use — a hand-rolled copy there is what let the two drift apart in the first
|
|
674
676
|
* place (see `appendRemainingOptionalRoles` in event-handlers-sov.ts).
|
|
675
677
|
*/
|
|
678
|
+
/** The words a role reads in `language` (`RoleSpec.words`: its `'*'` list and the language's own). */
|
|
679
|
+
function wordsFor(roleSpec: RoleSpec, language: string): readonly string[] | undefined {
|
|
680
|
+
if (!roleSpec.words) return undefined;
|
|
681
|
+
const words = [...(roleSpec.words['*'] ?? []), ...(roleSpec.words[language] ?? [])];
|
|
682
|
+
return words.length > 0 ? words : undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
676
685
|
function buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): PatternToken[] {
|
|
677
686
|
const tokens: PatternToken[] = [];
|
|
678
687
|
|
|
@@ -686,6 +695,7 @@ function buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): Patt
|
|
|
686
695
|
// `fetch "/api"`). Tag the emitted marker literal so the renderer skips it;
|
|
687
696
|
// the pattern matcher ignores the flag, so parsing is unchanged.
|
|
688
697
|
const suppressMarker = roleSpec.renderOverride?.[profile.code] === '';
|
|
698
|
+
const words = wordsFor(roleSpec, profile.code);
|
|
689
699
|
|
|
690
700
|
// Role value token
|
|
691
701
|
const roleValueToken: PatternToken = {
|
|
@@ -700,6 +710,8 @@ function buildRoleToken(roleSpec: RoleSpec, profile: SchemaPatternProfile): Patt
|
|
|
700
710
|
// A greedy role reads up to the next marker the pattern owes (a domain's
|
|
701
711
|
// SQL condition); its value reader does the reading.
|
|
702
712
|
...(roleSpec.greedy ? { greedy: true } : {}),
|
|
713
|
+
// A role with a word list reads only one of its words.
|
|
714
|
+
...(words ? { words } : {}),
|
|
703
715
|
};
|
|
704
716
|
|
|
705
717
|
// Required literal immediately before the role VALUE (rolePrefixLiteralVariants
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The round-trip gate kit on framework's SQL fixture DSL: the commands it writes,
|
|
3
|
-
* the outcomes it reads
|
|
3
|
+
* the outcomes it reads (an unknown word read into a role among them), and the
|
|
4
|
+
* baseline and snapshot it compares with.
|
|
4
5
|
*/
|
|
5
6
|
import { describe, expect, it } from 'vitest';
|
|
6
|
-
import { createSQLDSL, selectCommand } from '../../__test__/fixtures/sql-dsl';
|
|
7
|
+
import { createSQLDSL, selectCommand, sqlDSLConfig } from '../../__test__/fixtures/sql-dsl';
|
|
8
|
+
import { createMultilingualDSL } from '../../api/create-dsl';
|
|
7
9
|
import {
|
|
8
10
|
commandsFor,
|
|
9
11
|
compare,
|
|
10
12
|
countsOf,
|
|
11
13
|
listingOf,
|
|
14
|
+
readsWord,
|
|
12
15
|
runRoundTrip,
|
|
13
16
|
snapshotChanges,
|
|
14
17
|
snapshotOf,
|
|
@@ -42,11 +45,19 @@ describe('runRoundTrip', () => {
|
|
|
42
45
|
]);
|
|
43
46
|
});
|
|
44
47
|
|
|
48
|
+
it("writes a fixture's own commands after the generated ones, once each", () => {
|
|
49
|
+
const own = '[select columns:email source:users condition:age]';
|
|
50
|
+
expect(
|
|
51
|
+
commandsFor([selectCommand], { ...fixture, commands: [own, '[select columns:id source:t]'] })
|
|
52
|
+
).toEqual([...commandsFor([selectCommand], fixture), '[select columns:id source:t]']);
|
|
53
|
+
expect(commandsFor([selectCommand], fixture)).toContain(own);
|
|
54
|
+
});
|
|
55
|
+
|
|
45
56
|
it('reads every render back, in every language, and tries each with an unknown word after it', () => {
|
|
46
57
|
expect(countsOf(results)).toEqual({
|
|
47
58
|
roundTrip: { cases: 8, pass: 8, refused: 0, silent: 0 },
|
|
48
|
-
// The `where` condition is greedy: it takes the unknown word as text
|
|
49
|
-
leftover: { cases: 8, pass:
|
|
59
|
+
// The `where` condition is greedy: it takes the unknown word as text, into the SQL (`pass`).
|
|
60
|
+
leftover: { cases: 8, pass: 4, refused: 4, silent: 0 },
|
|
50
61
|
});
|
|
51
62
|
expect(results.find(r => r.kind === 'roundTrip' && r.lang === 'es')).toMatchObject({
|
|
52
63
|
text: 'seleccionar name de users',
|
|
@@ -65,12 +76,14 @@ describe('compare', () => {
|
|
|
65
76
|
});
|
|
66
77
|
|
|
67
78
|
it('reports a case worse than listed, one better, and a listed case that is gone', () => {
|
|
79
|
+
// Two leftover cases accepted that the baseline lists as refused.
|
|
80
|
+
const accepted = '[select columns:name source:users]';
|
|
81
|
+
const now = results.map(r =>
|
|
82
|
+
r.kind === 'leftover' && r.source === accepted ? { ...r, outcome: 'silent' as const } : r
|
|
83
|
+
);
|
|
68
84
|
const listing = listingOf(results);
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
delete better[accepted];
|
|
72
|
-
const drift = compare('sql', results, {
|
|
73
|
-
...baselineOf({ roundTrip: listing.roundTrip, leftover: better }),
|
|
85
|
+
const drift = compare('sql', now, {
|
|
86
|
+
...baselineOf(listing),
|
|
74
87
|
roundTrip: {
|
|
75
88
|
sql: {
|
|
76
89
|
'[select columns:name source:users]': {
|
|
@@ -97,6 +110,83 @@ describe('compare', () => {
|
|
|
97
110
|
});
|
|
98
111
|
});
|
|
99
112
|
|
|
113
|
+
describe('an unknown word read into a role', () => {
|
|
114
|
+
const leftover = (rs: typeof results) =>
|
|
115
|
+
rs
|
|
116
|
+
.filter(r => r.kind === 'leftover' && r.source.includes('condition'))
|
|
117
|
+
.map(r => [r.lang, r.outcome, r.why]);
|
|
118
|
+
|
|
119
|
+
it('is read (`pass`) where it extends an open-text role and reaches the code', () => {
|
|
120
|
+
expect(leftover(results)).toEqual([
|
|
121
|
+
['en', 'pass', undefined],
|
|
122
|
+
['es', 'pass', undefined],
|
|
123
|
+
['en', 'pass', undefined],
|
|
124
|
+
['es', 'pass', undefined],
|
|
125
|
+
]);
|
|
126
|
+
expect(results.find(r => r.kind === 'leftover' && r.source.includes('condition'))?.read).toBe(
|
|
127
|
+
'select columns:expression=name condition:expression="age zzqx" source:expression=users'
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('is accepted (`silent`) where it does not reach the code', () => {
|
|
132
|
+
const dropsCondition = createMultilingualDSL({
|
|
133
|
+
...sqlDSLConfig(),
|
|
134
|
+
codeGenerator: { generate: () => 'SELECT name FROM users' },
|
|
135
|
+
});
|
|
136
|
+
const rs = runRoundTrip('sql', dropsCondition, [selectCommand], fixture);
|
|
137
|
+
expect(leftover(rs)[0]).toEqual([
|
|
138
|
+
'en',
|
|
139
|
+
'silent',
|
|
140
|
+
'accepted as select; the word does not reach the code',
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('is refused where the input stops compiling, unless the command never compiles on its own', () => {
|
|
145
|
+
const refusesTheWord = createMultilingualDSL({
|
|
146
|
+
...sqlDSLConfig(),
|
|
147
|
+
codeGenerator: {
|
|
148
|
+
generate: n => {
|
|
149
|
+
const c = n.roles.get('condition') as { raw?: string } | undefined;
|
|
150
|
+
if (c?.raw?.includes('zzqx')) throw new Error('not a condition');
|
|
151
|
+
return 'SELECT';
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
const rs = runRoundTrip('sql', refusesTheWord, [selectCommand], fixture);
|
|
156
|
+
expect(leftover(rs)[0]).toEqual(['en', 'refused', 'does not compile']);
|
|
157
|
+
const alone = runRoundTrip('sql', refusesTheWord, [selectCommand], {
|
|
158
|
+
...fixture,
|
|
159
|
+
englishDoesNotCompile: ['select'],
|
|
160
|
+
});
|
|
161
|
+
expect(leftover(alone)[0]).toEqual(['en', 'pass', undefined]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const node = (action: string, roles: Record<string, string>) => ({
|
|
165
|
+
action,
|
|
166
|
+
roles: new Map(Object.entries(roles).map(([k, v]) => [k, { type: 'expression', raw: v }])),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it.each([
|
|
170
|
+
['extends one role', { condition: 'age' }, { condition: 'age zzqx' }, true],
|
|
171
|
+
['fills a role the text left empty', {}, { list: 'zzqx' }, true],
|
|
172
|
+
[
|
|
173
|
+
'moves a value to make room for it (`when clicks zzqx`)',
|
|
174
|
+
{ action: 'clicks' },
|
|
175
|
+
{ actor: 'clicks', action: 'zzqx' },
|
|
176
|
+
false,
|
|
177
|
+
],
|
|
178
|
+
['replaces a value', { quantity: '2' }, { quantity: 'zzqx' }, false],
|
|
179
|
+
['is in two roles', {}, { a: 'zzqx', b: 'zzqx' }, false],
|
|
180
|
+
['is not read at all', { a: 'x' }, { a: 'x' }, false],
|
|
181
|
+
] as const)('a parse that %s is the text read with the word: %s', (_, was, now, want) => {
|
|
182
|
+
expect(readsWord(node('when', was), node('when', now), 'zzqx')).toBe(want);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('a parse as another command is not', () => {
|
|
186
|
+
expect(readsWord(node('when', {}), node('given', { a: 'zzqx' }), 'zzqx')).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
100
190
|
describe('the parse snapshot', () => {
|
|
101
191
|
it('shows a parse that moved', () => {
|
|
102
192
|
const parses = snapshotOf(results);
|
|
@@ -13,8 +13,13 @@
|
|
|
13
13
|
* generator normalizes: bdd ja `#box0 が 表示 前提` is `visible`). English is a lane like the others.
|
|
14
14
|
* `refused` when the render is null or the parse throws: the caller is told. `silent` otherwise: it
|
|
15
15
|
* reads, or compiles to, something else.
|
|
16
|
-
* - **leftover**: the same render plus one unknown word. `refused` is the expected outcome
|
|
17
|
-
*
|
|
16
|
+
* - **leftover**: the same render plus one unknown word. `refused` is the expected outcome: the parse
|
|
17
|
+
* throws, or the input does not compile. `pass` where the word is READ: the parse is the text's own
|
|
18
|
+
* with the word in one role (an open-text role extended by it, `where active zzqx`, or a role the
|
|
19
|
+
* text left empty, `list zzqx`), and the word is in the code (for a command that never compiles on
|
|
20
|
+
* its own, `englishDoesNotCompile`, the parse alone). `silent` otherwise: the input is accepted and
|
|
21
|
+
* read as something else (`when clicks zzqx`: actor `clicks`, action `zzqx`), or the word goes
|
|
22
|
+
* nowhere (voice `back zzqx` compiles as `back`).
|
|
18
23
|
*
|
|
19
24
|
* The outcomes are the command-shape gate's (hyperfixi `testing-framework`). A baseline lists every
|
|
20
25
|
* case that is not at its best outcome; `compare` reports a case worse than listed and one better than
|
|
@@ -93,7 +98,16 @@ export interface GateFixture {
|
|
|
93
98
|
oracle: 'compile' | 'node';
|
|
94
99
|
/** Keys are `<action>.<role>` or `<role>` (every action). The first value is the one the other cases use. */
|
|
95
100
|
values: Record<string, string[]>;
|
|
96
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Commands (bracket syntax) written besides the generated ones, where one role's value depends on
|
|
103
|
+
* another's and no single list fits both: behaviorspec's `[given subject:#box value:visible]` beside
|
|
104
|
+
* `[given subject:viewport value:1024x768]`.
|
|
105
|
+
*/
|
|
106
|
+
commands?: string[];
|
|
107
|
+
/**
|
|
108
|
+
* Actions that do not compile on their own (a gate may check every other one does). Leftover input
|
|
109
|
+
* in such a command is judged by its parse alone.
|
|
110
|
+
*/
|
|
97
111
|
englishDoesNotCompile?: string[];
|
|
98
112
|
}
|
|
99
113
|
|
|
@@ -132,6 +146,7 @@ export function commandsFor(schemas: readonly GateSchema[], fixture: GateFixture
|
|
|
132
146
|
});
|
|
133
147
|
}
|
|
134
148
|
}
|
|
149
|
+
for (const command of fixture.commands ?? []) out.add(command);
|
|
135
150
|
return [...out];
|
|
136
151
|
}
|
|
137
152
|
|
|
@@ -181,6 +196,35 @@ function describeRead(want: Record<string, string>, ref: GateNode, back: GateNod
|
|
|
181
196
|
|
|
182
197
|
const message = (e: unknown) => String((e as Error)?.message ?? e).slice(0, 160);
|
|
183
198
|
|
|
199
|
+
/** What `text` compiles to, or `undefined` when it does not compile. */
|
|
200
|
+
function codeOf(dsl: GateDSL, text: string, lang: string): string | undefined {
|
|
201
|
+
try {
|
|
202
|
+
const c = dsl.compile(text, lang);
|
|
203
|
+
return c.ok && typeof c.code === 'string' ? c.code : undefined;
|
|
204
|
+
} catch {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether `read`, the parse of a text plus `word`, is `back`, the text's own parse, with the word read
|
|
211
|
+
* into it: the same action and roles, except one role, whose value is its value in `back` followed by
|
|
212
|
+
* the word (an open-text role at the end: sql `where active zzqx`) or, where `back` has no such role,
|
|
213
|
+
* the word itself (todo `list zzqx`).
|
|
214
|
+
*/
|
|
215
|
+
export function readsWord(back: GateNode, read: GateNode, word: string): boolean {
|
|
216
|
+
if (read.action !== back.action) return false;
|
|
217
|
+
const was = roleValues(back);
|
|
218
|
+
const now = roleValues(read);
|
|
219
|
+
let changed = 0;
|
|
220
|
+
for (const role of new Set([...Object.keys(was), ...Object.keys(now)])) {
|
|
221
|
+
if (was[role] === now[role]) continue;
|
|
222
|
+
if (now[role] !== (role in was ? `${was[role]} ${word}` : word)) return false;
|
|
223
|
+
changed++;
|
|
224
|
+
}
|
|
225
|
+
return changed === 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
184
228
|
/** What a command (bracket syntax) compiles to; null when it does not compile on its own. */
|
|
185
229
|
export function referenceCode(dsl: GateDSL, source: string): string | null {
|
|
186
230
|
try {
|
|
@@ -253,18 +297,39 @@ export function runRoundTrip(
|
|
|
253
297
|
|
|
254
298
|
// Leftover input: only where the text itself reads, or a refusal would say nothing.
|
|
255
299
|
const extended = `${text} ${UNKNOWN_WORD}`;
|
|
300
|
+
let read: GateNode;
|
|
256
301
|
try {
|
|
257
|
-
|
|
258
|
-
results.push({
|
|
259
|
-
...at,
|
|
260
|
-
kind: 'leftover',
|
|
261
|
-
outcome: 'silent',
|
|
262
|
-
text: extended,
|
|
263
|
-
why: `accepted as ${read.action}`,
|
|
264
|
-
});
|
|
302
|
+
read = dsl.parse(extended, lang);
|
|
265
303
|
} catch {
|
|
266
304
|
results.push({ ...at, kind: 'leftover', outcome: 'refused', text: extended });
|
|
305
|
+
continue;
|
|
267
306
|
}
|
|
307
|
+
// Read with the word, the word must reach the code. Where the input does not compile, compile
|
|
308
|
+
// tells the caller (`refused`), except for a command that never compiles on its own (bdd's
|
|
309
|
+
// `and`), where only the parse can say.
|
|
310
|
+
const kept = readsWord(back, read, UNKNOWN_WORD);
|
|
311
|
+
const code = kept ? codeOf(dsl, extended, lang) : undefined;
|
|
312
|
+
const alone = fixture.englishDoesNotCompile?.includes(read.action) ?? false;
|
|
313
|
+
const [left, leftWhy]: [Outcome, string | undefined] = !kept
|
|
314
|
+
? [
|
|
315
|
+
'silent',
|
|
316
|
+
`accepted as ${read.action}; reads ${describeRead(roleValues(back), back, read)}`,
|
|
317
|
+
]
|
|
318
|
+
: code !== undefined
|
|
319
|
+
? code.includes(UNKNOWN_WORD)
|
|
320
|
+
? ['pass', undefined]
|
|
321
|
+
: ['silent', `accepted as ${read.action}; the word does not reach the code`]
|
|
322
|
+
: alone
|
|
323
|
+
? ['pass', undefined]
|
|
324
|
+
: ['refused', 'does not compile'];
|
|
325
|
+
results.push({
|
|
326
|
+
...at,
|
|
327
|
+
kind: 'leftover',
|
|
328
|
+
outcome: left,
|
|
329
|
+
text: extended,
|
|
330
|
+
...(leftWhy ? { why: leftWhy } : {}),
|
|
331
|
+
read: summarize(read),
|
|
332
|
+
});
|
|
268
333
|
}
|
|
269
334
|
}
|
|
270
335
|
return results;
|