calliope-ts 0.0.1 → 0.0.2
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/README.md +111 -205
- package/dist/index.js +9 -1
- package/package.json +2 -2
- package/src/index.ts +7 -2
- package/tests/basic.test.ts +696 -0
- package/tests/epg64.meter.train.txt +8139 -0
- package/tests/litlab-sample-2016.txt +1738 -0
- package/tests/prosodic.meter.train.txt +13192 -0
- package/tsconfig.json +29 -0
- package/vitest.config.d.ts +2 -0
- package/vitest.config.js +14 -0
- package/vitest.config.ts +15 -0
- package/.claude/settings.local.json +0 -8
- package/.opencode/skills/calliope-ts/SKILL.md +0 -74
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseDocument } from '../src/parser.js';
|
|
3
|
+
import {
|
|
4
|
+
assignLexicalStress,
|
|
5
|
+
applyCompoundStress,
|
|
6
|
+
applyNuclearStress,
|
|
7
|
+
assignRelativeStresses,
|
|
8
|
+
} from '../src/stress.js';
|
|
9
|
+
import { buildPhonologicalHierarchy } from '../src/phonological.js';
|
|
10
|
+
import { extractKeyStresses, scoreMeters } from '../src/scansion.js';
|
|
11
|
+
import {
|
|
12
|
+
scandroidCorralWeird,
|
|
13
|
+
scandroidMaximizeNormal,
|
|
14
|
+
stressToMarks,
|
|
15
|
+
} from '../src/scandroid.js';
|
|
16
|
+
import { analyzeText, feedMultilineEvent, newMLState, ML_IDLE_MS, type MLEvent } from '../src/index.js';
|
|
17
|
+
import type { PhonologicalScansionDetail } from '../src/types.js';
|
|
18
|
+
import { classifyRhymePair, detectScheme } from '../src/rhyme.js';
|
|
19
|
+
import { analyzeStanzas } from '../src/index.js';
|
|
20
|
+
|
|
21
|
+
/** Drive the multi-line input reducer with a synthetic event sequence. */
|
|
22
|
+
function runMultiline(events: MLEvent[]): { result: string; lines: string[] } {
|
|
23
|
+
const st = newMLState();
|
|
24
|
+
let result = 'continue';
|
|
25
|
+
for (const ev of events) {
|
|
26
|
+
result = feedMultilineEvent(st, ev);
|
|
27
|
+
if (result !== 'continue') break;
|
|
28
|
+
}
|
|
29
|
+
return { result, lines: st.lines };
|
|
30
|
+
}
|
|
31
|
+
const FAST = ML_IDLE_MS - 50; // burst-speed (paste)
|
|
32
|
+
const SLOW = ML_IDLE_MS + 400; // deliberate keystroke
|
|
33
|
+
/** A whole line of pasted text: its chars (burst), then a burst-speed newline. */
|
|
34
|
+
function pasteLine(text: string, firstGap = FAST): MLEvent[] {
|
|
35
|
+
const evs: MLEvent[] = [];
|
|
36
|
+
[...text].forEach((c, i) => evs.push({ kind: 'char', str: c, gap: i === 0 ? firstGap : FAST }));
|
|
37
|
+
evs.push({ kind: 'return', gap: FAST });
|
|
38
|
+
return evs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function scanLine(line: string): PhonologicalScansionDetail {
|
|
42
|
+
const results = analyzeText(line, false);
|
|
43
|
+
return results[0].phonologicalScansion;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function runPipeline(line: string) {
|
|
47
|
+
const doc = parseDocument(line);
|
|
48
|
+
const sent = doc.sentences[0];
|
|
49
|
+
assignLexicalStress(sent.words);
|
|
50
|
+
const ius = buildPhonologicalHierarchy(sent);
|
|
51
|
+
applyCompoundStress(ius);
|
|
52
|
+
applyNuclearStress(ius);
|
|
53
|
+
assignRelativeStresses(sent.words, ius);
|
|
54
|
+
const keys = extractKeyStresses(ius, sent.words);
|
|
55
|
+
const result = scoreMeters(keys, sent.words, ius);
|
|
56
|
+
return { result, sent, ius, keys };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasEmptyFeet(s: string): boolean {
|
|
60
|
+
return s.includes('||') || s.startsWith('|') || s.endsWith('|');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('parser', () => {
|
|
64
|
+
it('parses a simple sentence into ClsDocument', () => {
|
|
65
|
+
const doc = parseDocument('I love you');
|
|
66
|
+
expect(doc.sentences).toHaveLength(1);
|
|
67
|
+
const sent = doc.sentences[0];
|
|
68
|
+
expect(sent.words).toHaveLength(3);
|
|
69
|
+
expect(sent.words[0].word).toBe('i');
|
|
70
|
+
expect(sent.words[0].lexicalClass).toBeTruthy();
|
|
71
|
+
expect(sent.dependencies).toHaveLength(3);
|
|
72
|
+
const rootDep = sent.dependencies.find(d => d.governorIndex === 0);
|
|
73
|
+
expect(rootDep).toBeTruthy();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('contraction handling', () => {
|
|
78
|
+
it("merges we'll into a single word", () => {
|
|
79
|
+
const r = scanLine("In Petersburg once more we'll be united");
|
|
80
|
+
expect(r).toBeDefined();
|
|
81
|
+
expect(r.meterName).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
it("merges we've into a single word", () => {
|
|
84
|
+
const r = scanLine("Like in a place where we've entombed the sun");
|
|
85
|
+
expect(r).toBeDefined();
|
|
86
|
+
});
|
|
87
|
+
it("merges I've into a single word", () => {
|
|
88
|
+
const doc = parseDocument("I've returned to my city, I know it to tears");
|
|
89
|
+
const w = doc.sentences[0].words.find(x => x.word === "i've");
|
|
90
|
+
expect(w).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
it("merges don't into a single word", () => {
|
|
93
|
+
const doc = parseDocument("don't you think the sun is brighter now");
|
|
94
|
+
const w = doc.sentences[0].words.find(x => x.word === "don't");
|
|
95
|
+
expect(w).toBeDefined();
|
|
96
|
+
});
|
|
97
|
+
it("merges it's into a single word", () => {
|
|
98
|
+
const doc = parseDocument("it's a nice day");
|
|
99
|
+
const w = doc.sentences[0].words.find(x => x.word === "it's");
|
|
100
|
+
expect(w).toBeDefined();
|
|
101
|
+
expect(doc.sentences[0].words.length).toBe(4);
|
|
102
|
+
});
|
|
103
|
+
it("merges can't into a single word", () => {
|
|
104
|
+
const doc = parseDocument("I can't do it");
|
|
105
|
+
const w = doc.sentences[0].words.find(x => x.word === "can't");
|
|
106
|
+
expect(w).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
it("merges I'm into a single word", () => {
|
|
109
|
+
const doc = parseDocument("I'm here now");
|
|
110
|
+
const w = doc.sentences[0].words.find(x => x.word === "i'm");
|
|
111
|
+
expect(w).toBeDefined();
|
|
112
|
+
expect(doc.sentences[0].words.length).toBe(3);
|
|
113
|
+
});
|
|
114
|
+
it("merges might've into a single word", () => {
|
|
115
|
+
const doc = parseDocument("I might've done that");
|
|
116
|
+
const w = doc.sentences[0].words.find(x => x.word === "might've");
|
|
117
|
+
expect(w).toBeDefined();
|
|
118
|
+
});
|
|
119
|
+
it('does not merge non-contraction host+clitic sequences', () => {
|
|
120
|
+
const doc = parseDocument('we will go');
|
|
121
|
+
const sent = doc.sentences[0];
|
|
122
|
+
expect(sent.words[0].word).toBe('we');
|
|
123
|
+
expect(sent.words[1].word).toBe('will');
|
|
124
|
+
});
|
|
125
|
+
it('a contraction line has correct syllable count', () => {
|
|
126
|
+
const doc = parseDocument("In Petersburg once more we'll be united");
|
|
127
|
+
assignLexicalStress(doc.sentences[0].words);
|
|
128
|
+
const sylCount = doc.sentences[0].words.reduce((a, w) => a + w.syllables.length, 0);
|
|
129
|
+
expect(sylCount).toBe(11);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('stress', () => {
|
|
134
|
+
function makeWords() {
|
|
135
|
+
return [
|
|
136
|
+
{ text: 'city', pos: 'NN', isContent: true },
|
|
137
|
+
{ text: 'hall', pos: 'NN', isContent: true },
|
|
138
|
+
{ text: 'without', pos: 'IN', isContent: false },
|
|
139
|
+
{ text: 'permission', pos: 'NN', isContent: true },
|
|
140
|
+
{ text: 'the', pos: 'DT', isContent: false },
|
|
141
|
+
].map((w, i) => ({
|
|
142
|
+
index: i + 1,
|
|
143
|
+
lexicalClass: w.pos,
|
|
144
|
+
lexicalDetails: '',
|
|
145
|
+
lexicalPlural: false,
|
|
146
|
+
position: '',
|
|
147
|
+
word: w.text,
|
|
148
|
+
absoluteIndex: i,
|
|
149
|
+
isContent: w.isContent,
|
|
150
|
+
syllables: [],
|
|
151
|
+
phraseStress: 0,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mockIus(words: any[]): any {
|
|
156
|
+
return [{ phonologicalPhrases: [{ cliticGroups: [{ tokens: words }] }] }];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it('assignLexicalStress sets syllables with stress numbers', () => {
|
|
160
|
+
const words = makeWords();
|
|
161
|
+
assignLexicalStress(words);
|
|
162
|
+
for (const w of words) {
|
|
163
|
+
expect(w.syllables.length).toBeGreaterThan(0);
|
|
164
|
+
}
|
|
165
|
+
expect(words[0].syllables.some(s => s.stress === 2)).toBe(true);
|
|
166
|
+
expect(words[4].syllables.some(s => s.stress === 2)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('applyCompoundStress adjusts adjacent noun-noun compounds', () => {
|
|
170
|
+
const words = makeWords();
|
|
171
|
+
assignLexicalStress(words);
|
|
172
|
+
applyCompoundStress(mockIus(words));
|
|
173
|
+
const cityMax = Math.max(...words[0].syllables.map(s => s.stress));
|
|
174
|
+
const hallMax = Math.max(...words[1].syllables.map(s => s.stress));
|
|
175
|
+
expect(cityMax).toBe(2);
|
|
176
|
+
expect(hallMax).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('applyNuclearStress boosts rightmost content word', () => {
|
|
180
|
+
const words = makeWords();
|
|
181
|
+
assignLexicalStress(words);
|
|
182
|
+
applyNuclearStress(mockIus(words));
|
|
183
|
+
const rightStress = words[3].syllables.reduce((a, b) => Math.max(a, b.stress), 0);
|
|
184
|
+
const leftStress = words[0].syllables.reduce((a, b) => Math.max(a, b.stress), 0);
|
|
185
|
+
expect(rightStress).toBeGreaterThanOrEqual(leftStress);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('assignRelativeStresses converts numeric to symbolic levels', () => {
|
|
189
|
+
const words = makeWords();
|
|
190
|
+
assignLexicalStress(words);
|
|
191
|
+
if (words[1] && words[4]) {
|
|
192
|
+
const dep = {
|
|
193
|
+
governor: words[1], dependent: words[4],
|
|
194
|
+
governorIndex: 2, dependentIndex: 5, dependentType: 'det',
|
|
195
|
+
governorName: 'hall', dependentName: 'the', index: 5,
|
|
196
|
+
};
|
|
197
|
+
words[1].dependency = dep as any;
|
|
198
|
+
words[4].dependency = dep as any;
|
|
199
|
+
}
|
|
200
|
+
assignRelativeStresses(words, mockIus(words));
|
|
201
|
+
for (const w of words) {
|
|
202
|
+
for (const s of w.syllables) {
|
|
203
|
+
expect(['x', 'w', 'n', 'm', 's']).toContain(s.relativeStress);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('phonological', () => {
|
|
210
|
+
it('builds IU/PP/CP hierarchy for a simple sentence', () => {
|
|
211
|
+
const doc = parseDocument('The planet orbits a star');
|
|
212
|
+
const sent = doc.sentences[0];
|
|
213
|
+
assignLexicalStress(sent.words);
|
|
214
|
+
const ius = buildPhonologicalHierarchy(sent);
|
|
215
|
+
expect(ius.length).toBeGreaterThan(0);
|
|
216
|
+
expect(ius[0].phonologicalPhrases.length).toBeGreaterThan(0);
|
|
217
|
+
expect(ius[0].phonologicalPhrases[0].cliticGroups.length).toBeGreaterThan(0);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('scansion', () => {
|
|
222
|
+
it('Shall I compare thee? — iambic', () => {
|
|
223
|
+
const { result } = runPipeline("Shall I compare thee to a summer's day?");
|
|
224
|
+
expect(result.meterName).toBe('iambic');
|
|
225
|
+
// 'x' = zero-provision clitics ("to", and the article "a" — whose old 'n'
|
|
226
|
+
// came from the letter-name EY1 dictionary anomaly, fixed 2026-06-12).
|
|
227
|
+
expect(result.scansion).toBe('nn|ws|nx|xm|ws');
|
|
228
|
+
expect(result.footCount).toBeGreaterThan(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('The Assyrian came down — anapestic', () => {
|
|
232
|
+
const { result } = runPipeline('The Assyrian came down like the wolf on the fold');
|
|
233
|
+
expect(result.meterName).toBe('anapestic');
|
|
234
|
+
// 'x' = the three reduced "the" clitics (DT); the monosyllabic prepositions
|
|
235
|
+
// "like"/"on" floor at 'w' (clitic-floor rule) rather than 'n', so the
|
|
236
|
+
// anapestic upbeats "like·the·WOLF / on·the·FOLD" read w·x·s.
|
|
237
|
+
expect(result.scansion).toBe('xws|wsm|wxs|wxs');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('I\'ve returned to my city — anapestic', () => {
|
|
241
|
+
const { result } = runPipeline("I've returned to my city, I know it to tears");
|
|
242
|
+
expect(result.meterName).toBe('anapestic');
|
|
243
|
+
// 'x' = reduced clitics "to", "it", "to"; the possessive "my" (PRP$) floors
|
|
244
|
+
// at 'w' (clitic-floor rule) rather than 'n': "to·my·CIT(y)" = x·w·s.
|
|
245
|
+
expect(result.scansion).toBe('nwm|xws|wns|xxs');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('through Eden took — iambic', () => {
|
|
249
|
+
const { result } = runPipeline('Through Eden took their solitary way');
|
|
250
|
+
expect(result.meterName).toBe('iambic');
|
|
251
|
+
// Clitic-floor: the preposition "Through" (IN) → 'w' and the possessive
|
|
252
|
+
// "their" (PRP$) → 'x' (floored to 'w', then governance-demoted under
|
|
253
|
+
// "solitary"), giving clean rising feet "through·E / …their·SOL".
|
|
254
|
+
expect(result.scansion).toBe('wm|ws|xm|wn|ws');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('free verse for nonsensical empty input', () => {
|
|
258
|
+
const result = scoreMeters([], []);
|
|
259
|
+
expect(result.meterName).toBe('free verse');
|
|
260
|
+
expect(result.scansion).toBe('');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("You've returned here — anapestic (Mandelstam, 'Leningrad')", () => {
|
|
264
|
+
// Translation of Mandelstam's anapestic-tetrameter "Ты вернулся сюда…":
|
|
265
|
+
// you've·re·TURNED | here·so·SWAL | low·then·FAST | as·you·MIGHT. Resolved by
|
|
266
|
+
// (1) treating the line-final modal "might" as the final beat ("endings
|
|
267
|
+
// strict"), (2) the trailing-function-run rule that keeps "as you" as upbeat,
|
|
268
|
+
// (3) the coarse rising-onset cue, and (4) the project's slight ternary bias.
|
|
269
|
+
const { result } = runPipeline("You've returned here, so swallow then, fast as you might");
|
|
270
|
+
expect(result.meterName).toBe('anapestic');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('And onto shores — iambic', () => {
|
|
274
|
+
const { result } = runPipeline('And onto shores, with scorching fishes');
|
|
275
|
+
expect(result.meterName).toBe('iambic');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('We all live — anapestic', () => {
|
|
279
|
+
const { result } = runPipeline('We all live, underneath us no country we sense');
|
|
280
|
+
expect(result.meterName).toBe('anapestic');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('Woolen gray hat — dactylic', () => {
|
|
284
|
+
const { result } = runPipeline('Woolen gray hat and the crimson stars shining');
|
|
285
|
+
expect(result.meterName).toBe('dactylic');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('keeps making blue holes — amphibrachic', () => {
|
|
289
|
+
const { result } = runPipeline('keeps making blue holes in the waterproof gloss');
|
|
290
|
+
expect(result.meterName).toBe('amphibrachic');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('This ivy resembles — amphibrachic', () => {
|
|
294
|
+
const { result } = runPipeline('This ivy resembles the eyes of the deaf.');
|
|
295
|
+
expect(result.meterName).toBe('amphibrachic');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('We charged at the foe — anapestic', () => {
|
|
299
|
+
const { result } = runPipeline('We charged at the foe, and we camped on the heath,');
|
|
300
|
+
expect(result.meterName).toBe('anapestic');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('All this time — trochaic', () => {
|
|
304
|
+
const { result } = runPipeline("All this time, you'd softly utter, years on end");
|
|
305
|
+
expect(result.meterName).toBe('trochaic');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('The strong wine flows down fast — bacchic', () => {
|
|
309
|
+
const { result } = runPipeline('The strong wine flows down fast');
|
|
310
|
+
expect(result.meterName).toBe('bacchic');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('But he gave no one else a laugher\'s license — iambic (Tarlinskaja)', () => {
|
|
314
|
+
const { result } = runPipeline("But he gave no one else a laugher's license.");
|
|
315
|
+
expect(result.meterName).toBe('iambic');
|
|
316
|
+
// Tarlinskaja's example of a stress-modulated iambic pentameter: a trochaic
|
|
317
|
+
// inversion (gave·no), a spondee (one·else), a he·gave clash (· silent beat),
|
|
318
|
+
// and a feminine ending (laugher's·li·cense → wsw). Every foot is a real
|
|
319
|
+
// 2–3 syllable foot — unlike the prior key's spurious 4-syllable "mwsw".
|
|
320
|
+
// ("a" reads 'x' since the letter-name EY1 anomaly fix, 2026-06-12.)
|
|
321
|
+
expect(result.scansion).toBe('wm|-sn|sm|xm|wsw');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('expert baseline', () => {
|
|
326
|
+
it('If hairs be wires — iambic', () => {
|
|
327
|
+
const { result } = runPipeline('If hairs be wires, black wires grow on her head');
|
|
328
|
+
expect(result.meterName).toBe('iambic');
|
|
329
|
+
// 'x' = reduced clitic "if" (IN); "be" floors at 'w' not 'x' (a function
|
|
330
|
+
// VERB is reducible but never maximally reduced — 2026-06-12 rule); the
|
|
331
|
+
// preposition "on" (IN) and possessive "her" (PRP$) floor at 'w' (clitic-
|
|
332
|
+
// floor rule) rather than 'n', so the close reads "grow·on / her·HEAD".
|
|
333
|
+
expect(result.scansion).toBe('xm|ws|mm|sw|ws');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('Through Eden took — iambic', () => {
|
|
337
|
+
const { result } = runPipeline('Through Eden took their solitary way');
|
|
338
|
+
expect(result.meterName).toBe('iambic');
|
|
339
|
+
// Clitic-floor: "Through" (IN) → 'w', "their" (PRP$) → 'x' (see above).
|
|
340
|
+
expect(result.scansion).toBe('wm|ws|xm|wn|ws');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('There they are my fifty men — trochaic', () => {
|
|
344
|
+
const { result } = runPipeline('There they are my fifty men and women');
|
|
345
|
+
expect(result.meterName).toBe('trochaic');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('What had I given — iambic', () => {
|
|
349
|
+
const { result } = runPipeline('What had I given to hear the soft sweep');
|
|
350
|
+
expect(result.meterName).toBe('iambic');
|
|
351
|
+
// 'x' = reduced clitics "to" and "the".
|
|
352
|
+
expect(result.scansion).toBe('nn|nm|wxm|xm|s');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('Lies the subject of all verse — trochaic', () => {
|
|
356
|
+
const { result } = runPipeline('Lies the subject of all verse');
|
|
357
|
+
expect(result.meterName).toBe('trochaic');
|
|
358
|
+
// 'x' = reduced clitics "the" (DT) and "of" (IN); "all" (PDT) stays content 'n'.
|
|
359
|
+
expect(result.scansion).toBe('sx|sw|xn|s');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('scansion quality', () => {
|
|
365
|
+
const lines = [
|
|
366
|
+
'If hairs be wires, black wires grow on her head',
|
|
367
|
+
'Through Eden took their solitary way',
|
|
368
|
+
'What had I given to hear the soft sweep',
|
|
369
|
+
"Shall I compare thee to a summer's day?",
|
|
370
|
+
'Gone were but the Winter, come were but the Spring',
|
|
371
|
+
'The Assyrian came down like the wolf on the fold',
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
it('no metered scansion has empty feet', () => {
|
|
375
|
+
for (const line of lines) {
|
|
376
|
+
const { result } = runPipeline(line);
|
|
377
|
+
if (result.meterName !== 'free verse') {
|
|
378
|
+
expect(hasEmptyFeet(result.scansion)).toBe(false);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('metered scansions contain foot breaks', () => {
|
|
384
|
+
for (const line of lines) {
|
|
385
|
+
const { result } = runPipeline(line);
|
|
386
|
+
expect(result.scansion).toContain('|');
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe('phonological notation', () => {
|
|
392
|
+
it('includes CP, PP, IU boundary brackets', () => {
|
|
393
|
+
const r = scanLine('The planet orbits a star');
|
|
394
|
+
expect(r.all).toMatch(/\[/);
|
|
395
|
+
expect(r.all).toMatch(/\{/);
|
|
396
|
+
expect(r.all).toMatch(/</);
|
|
397
|
+
expect(r.keyStresses).toMatch(/\[/);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('includes stress symbols w m s n', () => {
|
|
401
|
+
const r = scanLine('The planet orbits a star');
|
|
402
|
+
expect(r.all).toMatch(/[wmsn]/);
|
|
403
|
+
expect(r.keyStresses).toMatch(/[wmsn]/);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('multi-line input (paste-friendly)', () => {
|
|
408
|
+
it('paste WITH trailing newline + one Enter submits, keeping stanza breaks', () => {
|
|
409
|
+
// Two stanzas of two lines, paste ends in a newline, then a deliberate Enter.
|
|
410
|
+
const evs: MLEvent[] = [
|
|
411
|
+
...pasteLine('line one', SLOW), // first char gap is large (since reader just opened)
|
|
412
|
+
...pasteLine('line two'),
|
|
413
|
+
{ kind: 'return', gap: FAST }, // blank line within the burst = stanza break
|
|
414
|
+
...pasteLine('line three'),
|
|
415
|
+
...pasteLine('line four'), // burst ends with a trailing newline
|
|
416
|
+
{ kind: 'return', gap: SLOW }, // the user's single deliberate Enter → submit
|
|
417
|
+
];
|
|
418
|
+
const { result, lines } = runMultiline(evs);
|
|
419
|
+
expect(result).toBe('submit');
|
|
420
|
+
// The blank stanza break is preserved between the two couplets.
|
|
421
|
+
expect(lines.filter(l => l.trim() === '').length).toBe(1);
|
|
422
|
+
expect(lines.filter(l => l.trim() !== '')).toEqual(['line one', 'line two', 'line three', 'line four']);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('paste WITHOUT trailing newline + one Enter submits, flushing the last line', () => {
|
|
426
|
+
const evs: MLEvent[] = [
|
|
427
|
+
...pasteLine('first line', SLOW),
|
|
428
|
+
// last line has no burst newline; the user just presses Enter once:
|
|
429
|
+
...[...'last line'].map((c, i) => ({ kind: 'char', str: c, gap: i === 0 ? FAST : FAST } as MLEvent)),
|
|
430
|
+
{ kind: 'return', gap: SLOW }, // deliberate Enter after the paste burst → submit + flush
|
|
431
|
+
];
|
|
432
|
+
const { result, lines } = runMultiline(evs);
|
|
433
|
+
expect(result).toBe('submit');
|
|
434
|
+
expect(lines.filter(l => l.trim() !== '')).toEqual(['first line', 'last line']);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('Esc cancels (returns to menu)', () => {
|
|
438
|
+
const { result } = runMultiline([...pasteLine('something'), { kind: 'escape' }]);
|
|
439
|
+
expect(result).toBe('cancel');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('slow hand-typing: a blank line finishes', () => {
|
|
443
|
+
const evs: MLEvent[] = [
|
|
444
|
+
...[...'typed line'].map((c) => ({ kind: 'char', str: c, gap: SLOW } as MLEvent)),
|
|
445
|
+
{ kind: 'return', gap: SLOW }, // end of the typed line (no burst seen) → line break
|
|
446
|
+
{ kind: 'return', gap: SLOW }, // blank line → submit
|
|
447
|
+
];
|
|
448
|
+
const { result, lines } = runMultiline(evs);
|
|
449
|
+
expect(result).toBe('submit');
|
|
450
|
+
expect(lines.filter(l => l.trim() !== '')).toEqual(['typed line']);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
describe('scandroid', () => {
|
|
455
|
+
it('Corral the Weird scans regular iambic', () => {
|
|
456
|
+
const result = scandroidCorralWeird('x/x/x/x/x/', 5);
|
|
457
|
+
expect(result.footlist.length).toBe(5);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('Maximize Normal works on regular iambic', () => {
|
|
461
|
+
const result = scandroidMaximizeNormal('x/x/x/x/x/', 5);
|
|
462
|
+
expect(result.footlist.length).toBeGreaterThan(0);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('stressToMarks converts correctly', () => {
|
|
466
|
+
expect(stressToMarks(['w', 's', 'w', 's'])).toBe('x/x/');
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('end‑to‑end', () => {
|
|
471
|
+
const poem = `Shall I compare thee to a summer's day?
|
|
472
|
+
Thou art more lovely and more temperate.
|
|
473
|
+
Rough winds do shake the darling buds of May,
|
|
474
|
+
And summer's lease hath all too short a date.`;
|
|
475
|
+
|
|
476
|
+
it('analyzeText returns results for each line', () => {
|
|
477
|
+
const results = analyzeText(poem, false);
|
|
478
|
+
expect(results.length).toBe(4);
|
|
479
|
+
expect(results[0].phonologicalScansion.meterName).toBe('iambic');
|
|
480
|
+
expect(results[1].phonologicalScansion.meterName).toBe('iambic');
|
|
481
|
+
expect(results[2].phonologicalScansion.meterName).toBe('iambic');
|
|
482
|
+
expect(results[3].phonologicalScansion.meterName).toBe('iambic');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('analyzeText with Scandroid includes Scandroid results', () => {
|
|
486
|
+
const results = analyzeText(poem, true);
|
|
487
|
+
for (const r of results) {
|
|
488
|
+
expect(r.phonologicalScansion.meterName).toBeDefined();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('handles free verse line gracefully', () => {
|
|
493
|
+
// A famously free-verse line (Williams): there is no canonical scansion, so we
|
|
494
|
+
// assert graceful handling — a defined meter and a non-empty, foot-delimited
|
|
495
|
+
// scansion covering every syllable — rather than a brittle exact string.
|
|
496
|
+
const results = analyzeText('so much depends upon a red wheel barrow', false);
|
|
497
|
+
const d = results[0].phonologicalScansion;
|
|
498
|
+
expect(d.meter).toBeDefined();
|
|
499
|
+
expect(d.scansion.length).toBeGreaterThan(0);
|
|
500
|
+
expect(d.scansion).toContain('|');
|
|
501
|
+
// syllable letters in the scansion must equal the line's syllable count
|
|
502
|
+
const sylLetters = (d.scansion.match(/[xwnms]/g) || []).length;
|
|
503
|
+
const totalSyls = results[0].sentence.words.reduce((a, w) => a + w.syllables.length, 0);
|
|
504
|
+
expect(sylLetters).toBe(totalSyls);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe('structural fixes (2026-06-10 audit)', () => {
|
|
509
|
+
it('quotation marks are not prosodic breaks: quoted word scans like unquoted', () => {
|
|
510
|
+
// Quotes fragmented the IU hierarchy and flipped this line to "trochaic
|
|
511
|
+
// hexameter"; quoted and unquoted twins must scan identically.
|
|
512
|
+
const quoted = analyzeText('I wonder why we call them "wisdom" teeth.', false)[0].phonologicalScansion;
|
|
513
|
+
const plain = analyzeText('I wonder why we call them wisdom teeth.', false)[0].phonologicalScansion;
|
|
514
|
+
expect(quoted.meterName).toBe('iambic');
|
|
515
|
+
expect(quoted.footCount).toBe(5);
|
|
516
|
+
expect(quoted.meterName).toBe(plain.meterName);
|
|
517
|
+
expect(quoted.footCount).toBe(plain.footCount);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('the LINE is the scansion domain: internal stops do not fragment the meter', () => {
|
|
521
|
+
// Three grammatical sentences, one verse line (Woolley) — one iambic pentameter,
|
|
522
|
+
// not trimeter + monometer + monometer fragments.
|
|
523
|
+
const results = analyzeText("You'll slurp potato soup. No straws! Suck gauze.", false);
|
|
524
|
+
expect(results.length).toBe(1);
|
|
525
|
+
expect(results[0].phonologicalScansion.meterName).toBe('iambic');
|
|
526
|
+
expect(results[0].phonologicalScansion.footCount).toBe(5);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('mid-line exclamation keeps the line whole (Stallings-aligned)', () => {
|
|
530
|
+
const results = analyzeText('Oh, bloody day! Extractions take their toll;', false);
|
|
531
|
+
expect(results.length).toBe(1);
|
|
532
|
+
expect(results[0].phonologicalScansion.meterName).toBe('iambic');
|
|
533
|
+
expect(results[0].phonologicalScansion.footCount).toBe(5);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('single anacrusis upbeat does not inflate the foot count', () => {
|
|
537
|
+
// 10 syllables with a leading extrametrical upbeat: pentameter, never hexameter.
|
|
538
|
+
const d = analyzeText('The woods around it have it--it is theirs.', false)[0].phonologicalScansion;
|
|
539
|
+
expect(d.footCount).toBe(5);
|
|
540
|
+
expect(d.meter).not.toContain('hexameter');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('double upbeat fills a foot slot: Hiawatha stays tetrameter', () => {
|
|
544
|
+
const d = analyzeText('By the shores of Gitche Gumee,', false)[0].phonologicalScansion;
|
|
545
|
+
expect(d.meterName).toBe('trochaic');
|
|
546
|
+
expect(d.footCount).toBe(4);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe('syllable-count integrity (2026-06-10 audit)', () => {
|
|
551
|
+
const sylCount = (line: string) => {
|
|
552
|
+
const r = analyzeText(line, false)[0];
|
|
553
|
+
return r.sentence.words.reduce((a, w) => a + w.syllables.length, 0);
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
it('"am" is one syllable (not the letter-name A.M. anomaly)', () => {
|
|
557
|
+
expect(sylCount('I am too absent-spirited to count;')).toBe(10);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('"us" is one syllable (not the letter-name U.S. anomaly)', () => {
|
|
561
|
+
expect(sylCount('Just for a handful of silver he left us,')).toBe(11);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("who'll / there's / she's re-merge to one syllable each", () => {
|
|
565
|
+
expect(sylCount("who'll barely recognize your puffy face.")).toBe(10);
|
|
566
|
+
expect(sylCount("and there's a light she's seen")).toBe(6);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it("archaic -'d preterite does not swallow the next word", () => {
|
|
570
|
+
const r = analyzeText("And heav'n knows wand'ring ev'ry fix'd star", false)[0];
|
|
571
|
+
const words = r.sentence.words.map(w => w.word.toLowerCase());
|
|
572
|
+
expect(words).toContain("fix'd");
|
|
573
|
+
expect(words).toContain('star');
|
|
574
|
+
expect(words).not.toContain('would');
|
|
575
|
+
expect(sylCount("And heav'n knows wand'ring ev'ry fix'd star")).toBe(9);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("th'-elision keeps the host's stress (th'ex-PENSE, not TH'EX-pense)", () => {
|
|
579
|
+
const r = analyzeText("Th'expense of spirit in a waste of shame", false)[0];
|
|
580
|
+
const w = r.sentence.words.find(x => x.word.toLowerCase().startsWith("th'"))!;
|
|
581
|
+
expect(w.syllables.length).toBe(2);
|
|
582
|
+
const rank: Record<string, number> = { x: 0, w: 1, n: 2, m: 3, s: 4 };
|
|
583
|
+
expect(rank[w.syllables[1].relativeStress!]).toBeGreaterThan(rank[w.syllables[0].relativeStress!]);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("'tis / 'twas are weak aphaeresis clitics, not stressed proper nouns", () => {
|
|
587
|
+
const r = analyzeText("'Tis true, 'twas night", false)[0];
|
|
588
|
+
const tis = r.sentence.words.find(w => w.word.toLowerCase().includes('tis'))!;
|
|
589
|
+
const twas = r.sentence.words.find(w => w.word.toLowerCase().includes('twas'))!;
|
|
590
|
+
expect(['x', 'w']).toContain(tis.syllables[0].relativeStress);
|
|
591
|
+
expect(['x', 'w']).toContain(twas.syllables[0].relativeStress);
|
|
592
|
+
});
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
describe('rhyme & form layer (2026-06-12)', () => {
|
|
596
|
+
it('classifies canonical rhyme pairs (LYRICAL typology)', () => {
|
|
597
|
+
expect(classifyRhymePair('grace', 'face')).toMatchObject({ type: 'perfect', structure: 'masculine' });
|
|
598
|
+
expect(classifyRhymePair('picky', 'tricky')).toMatchObject({ type: 'perfect', structure: 'feminine' });
|
|
599
|
+
expect(classifyRhymePair('belief', 'leaf')).toMatchObject({ type: 'rich' }); // homophone tails
|
|
600
|
+
expect(classifyRhymePair('dame', 'grain')).toMatchObject({ type: 'family' }); // M/N nasals
|
|
601
|
+
expect(classifyRhymePair('love', 'move')).toMatchObject({ type: 'consonant' }); // para-rhyme
|
|
602
|
+
expect(classifyRhymePair('shaken', 'shaken')).toMatchObject({ type: 'identical' });
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('detects ABCB with unrhymed lines marked', () => {
|
|
606
|
+
const rs = detectScheme(['Mariner', 'three', 'eye', 'me']);
|
|
607
|
+
expect(rs.map(r => r.letter).join('')).toBe('\u00b7A\u00b7A');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('Coleridge quatrain \u2192 ballad stanza (rhyme + 4\u00b73 gate)', () => {
|
|
611
|
+
const r = analyzeStanzas('It is an ancient Mariner,\nAnd he stoppeth one of three.\nBy thy long grey beard and glittering eye,\nNow wherefore stopp\u2019st thou me?');
|
|
612
|
+
expect(r[0][0].phonologicalScansion.formNote).toBe('ballad stanza (ABCB, 4\u00b73)');
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it('Pope couplet \u2192 couplet', () => {
|
|
616
|
+
const r = analyzeStanzas('True wit is nature to advantage dressed,\nWhat oft was thought, but ne\u2019er so well expressed.');
|
|
617
|
+
expect(r[0][0].phonologicalScansion.formNote).toBe('couplet');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('Frost (Mending Wall opening) \u2192 blank verse', () => {
|
|
621
|
+
const r = analyzeStanzas("Something there is that doesn't love a wall,\nThat sends the frozen-ground-swell under it,\nAnd spills the upper boulders in the sun;\nAnd makes gaps even two can pass abreast.");
|
|
622
|
+
expect(r[0][0].phonologicalScansion.formNote).toBe('blank verse');
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('Sonnet 130 \u2192 Shakespearean Sonnet, full scheme', () => {
|
|
626
|
+
const text = "My mistress' eyes are nothing like the sun;\nCoral is far more red than her lips' red;\nIf snow be white, why then her breasts are dun;\nIf hairs be wires, black wires grow on her head.\nI have seen roses damask'd, red and white,\nBut no such roses see I in her cheeks;\nAnd in some perfumes is there more delight\nThan in the breath that from my mistress reeks.\nI love to hear her speak, yet well I know\nThat music hath a far more pleasing sound;\nI grant I never saw a goddess go;\nMy mistress, when she treads, walks on the ground.\nAnd yet, by heaven, I think my love as rare\nAs any she belied with false compare.";
|
|
627
|
+
const r = analyzeStanzas(text);
|
|
628
|
+
expect(r[0][0].phonologicalScansion.formNote).toBe('Shakespearean Sonnet');
|
|
629
|
+
expect(r[0].map(l => l.phonologicalScansion.rhyme!.letter).join('')).toBe('ABABCDCDEFEFGG');
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('Lear limerick \u2192 limerick (AABBA, ternary)', () => {
|
|
633
|
+
const r = analyzeStanzas('There was an Old Man with a beard,\nWho said, It is just as I feared!\nTwo Owls and a Hen,\nFour Larks and a Wren,\nHave all built their nests in my beard!');
|
|
634
|
+
expect(r[0][0].phonologicalScansion.formNote).toMatch(/^limerick/);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe('continuity rename (2026-06-14)', () => {
|
|
639
|
+
it('near-tie line adopts the stanza-dominant meter; standalone kept as note', () => {
|
|
640
|
+
// Exile opening: L2 standalone reads dactylic (spondaic "book-" anacrusis
|
|
641
|
+
// seizes a beat), but the stanza is amphibrachic — continuity renames it.
|
|
642
|
+
const r = analyzeStanzas(
|
|
643
|
+
'He happens to be a French poet, that thin,\n' +
|
|
644
|
+
'book-carrying man with a bristly gray chin;\n' +
|
|
645
|
+
'you meet him whenever you go');
|
|
646
|
+
const d = r[0][1].phonologicalScansion;
|
|
647
|
+
expect(d.meterName).toBe('amphibrachic');
|
|
648
|
+
expect(d.standaloneMeter).toMatch(/dactylic/);
|
|
649
|
+
expect(d.consensusMeter).toBeUndefined();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('accentual stanzas are exempt: Wyatt keeps 4-beat accentual, no renames', () => {
|
|
653
|
+
const wyatt =
|
|
654
|
+
'They fle from me that sometyme did me seke\n' +
|
|
655
|
+
'With naked fote stalking in my chambre.\n' +
|
|
656
|
+
'I have sene theim gentill tame and meke\n' +
|
|
657
|
+
'That nowe are wyld and do not remembre\n' +
|
|
658
|
+
'That sometyme they put theimself in daunger\n' +
|
|
659
|
+
'To take bred at my hand; and nowe they raunge\n' +
|
|
660
|
+
'Besely seking with a continuell chaunge.';
|
|
661
|
+
const r = analyzeStanzas(wyatt);
|
|
662
|
+
for (const l of r[0]) {
|
|
663
|
+
expect(l.phonologicalScansion.rhythmNote).toBe('4-beat accentual');
|
|
664
|
+
expect(l.phonologicalScansion.standaloneMeter).toBeUndefined();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
describe('parse-correction layer (2026-06-14)', () => {
|
|
670
|
+
it('staged pipeline repairs "I had quit the programming paradigm"', () => {
|
|
671
|
+
const doc = parseDocument('I had quit the programming paradigm');
|
|
672
|
+
const words = doc.sentences[0].words;
|
|
673
|
+
expect(words.find(w => w.word === 'i')!.lexicalClass).toBe('PRP');
|
|
674
|
+
expect(words.find(w => w.word === 'quit')!.lexicalClass).toBe('VBN');
|
|
675
|
+
const deps = doc.sentences[0].dependencies;
|
|
676
|
+
const prog = deps.find(d => d.dependentName === 'programming')!;
|
|
677
|
+
expect(prog.dependentType).toBe('amod');
|
|
678
|
+
expect(prog.governorName).toBe('paradigm');
|
|
679
|
+
const det = deps.find(d => d.dependentName === 'the')!;
|
|
680
|
+
expect(det.dependentType).toBe('det');
|
|
681
|
+
expect(det.governorName).toBe('paradigm');
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it('archaic forms get verse-correct tags (thy/PRP$ floors at w)', () => {
|
|
685
|
+
const doc = parseDocument('By thy long grey beard and glittering eye,');
|
|
686
|
+
const thy = doc.sentences[0].words.find(w => w.word === 'thy')!;
|
|
687
|
+
expect(thy.lexicalClass).toBe('PRP$');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it('context-guarded gerund fix: "they bicycle through" keeps the verb', () => {
|
|
691
|
+
const doc = parseDocument('young beauties, all legs, as they bicycle through');
|
|
692
|
+
const bike = doc.sentences[0].words.find(w => w.word === 'bicycle')!;
|
|
693
|
+
expect(bike.lexicalClass).toBe('VBP');
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|