import { describe, it, expect } from 'vitest' import { analyzeProse } from './prose' // These tests pin the rules-based prose checker's behavior. For an ESL writer a // *wrong* nudge costs trust faster than a missed one earns it, so every rule is // tested in two directions: a true-positive case that must fire, and at least // one near-miss "guard" case that must stay silent. Sentences are padded past // the 8-word floor that analyzeProse needs before it offers advice. const rulesFor = (text: string) => analyzeProse(text).map((h) => h.rule) // Assert a given rule appears among the hints for this text. function expectRule(text: string, rule: string) { expect(rulesFor(text), `expected "${rule}" for: ${text}`).toContain(rule) } // Assert the text produces no hints at all (false-positive guard). function expectClean(text: string) { expect(analyzeProse(text), `expected no hints for: ${text}`).toEqual([]) } describe('analyzeProse — floor', () => { it('stays quiet on very short drafts (under 8 English words)', () => { expect(analyzeProse('he go now')).toEqual([]) }) it('returns hints highest-priority first', () => { // This 36-word run-on also contains a lowercase "i"; run-on must lead. const hints = analyzeProse( 'yesterday i walked all the way to the market and i bought apples and oranges and i saw my friend there and we talked and laughed and walked back home together in the warm afternoon sun.', ) expect(hints[0].rule).toBe('runon') expect(hints.map((h) => h.rule)).toContain('cap-i') }) }) describe('run-ons', () => { it('flags a long, comma-heavy sentence', () => { expectRule( 'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.', 'runon', ) }) it('leaves a normal sentence alone', () => { expectClean('The garden was quiet in the morning and the rain fell softly on the leaves.') }) }) describe('comma splices', () => { it('flags two independent clauses joined by a comma', () => { expectRule('I finished the report, I sent it to my boss right away today.', 'splice') }) it('catches a splice with a verb not on any list', () => { expectRule('The garden was quiet in the morning, she poured a cup of tea slowly.', 'splice') }) it('does not flag an introductory transition (However, …)', () => { expectClean('However, we decided to stay home and rest for the entire weekend.') }) it('does not flag an introductory phrase (After dinner, …)', () => { expectClean('After dinner, I went home and read a book until I fell asleep.') }) it('does not flag a pronoun list after a comma', () => { expectClean('We invited my brother, you and your sister to the dinner party.') }) }) describe('unclear antecedents', () => { it('flags a vague "This + verb" opener after a prior sentence', () => { expectRule( 'We changed the whole schedule last week. This makes everyone confused about the new times.', 'antecedent', ) }) it('leaves "This" alone when it names its noun', () => { expectClean( 'We changed the whole schedule last week. This new schedule confuses everyone about the times.', ) }) }) describe('oxford comma', () => { it('flags a 3-item noun list missing the serial comma', () => { expectRule('My mother, my father and my sister came to visit us last summer.', 'oxford') }) it('flags a verb-phrase list missing the serial comma', () => { expectRule('We cleaned the kitchen, washed the dishes and took out the trash.', 'oxford') }) it('does not flag a list that already has the serial comma', () => { expectClean('I bought apples, bananas, and oranges at the store this morning.') }) it('does not flag a compound predicate as a list', () => { expectClean('After dinner, I went home and read a book until I fell asleep.') }) }) describe('a / an', () => { it('flags "a" before a vowel sound', () => { expectRule('She found a umbrella and a old book on the table by the window.', 'article') }) it('flags "an" before a consonant sound', () => { expectRule('He gave me an book and an pencil to use during the long exam.', 'article') }) it('respects sound exceptions (a university, an hour)', () => { expectClean('He is a university student and it took an hour to arrive here.') }) }) describe('uncountable plurals', () => { it('flags a pluralized mass noun', () => { expectRule('She gave me many informations about the new job opening today.', 'uncountable') }) it('leaves the singular mass noun alone', () => { expectClean('He shared some useful advice and helped me with my homework tonight.') }) }) describe('capitalization of proper nouns', () => { it('flags lowercase languages and days', () => { expectRule('I am learning english and french during my free time every week.', 'propercap') expectRule('We will meet on monday to discuss the final project together soon.', 'propercap') }) it('does not flag ambiguous homographs (may, march)', () => { expectClean('They may visit in the spring and march through the old town square.') }) it('leaves already-capitalized proper nouns alone', () => { expectClean('I will study English and Chinese together this coming summer break here.') }) }) describe('subject–verb agreement', () => { it('flags a bare verb after he/she/it', () => { expectRule('My brother is busy but he go to the gym every single morning.', 'sva') expectRule('She have a lovely garden full of roses behind her small house.', 'sva') }) it('flags a sentence-initial subject too', () => { expectRule('It make me happy whenever the sun comes out after the rain.', 'sva') }) it('does not flag causative/perception frames (let it go, make it work)', () => { expectClean('Please let it go and try to enjoy the rest of your day.') expectClean('We should make it work before the big meeting tomorrow afternoon.') }) }) describe('plural after a number', () => { it('flags a singular noun after a cardinal number', () => { expectRule('I bought three apple and some bread at the market this morning.', 'plural') }) it('does not flag an adjective between number and noun', () => { expectClean('We adopted two small dogs from the shelter near our apartment today.') }) it('does not flag irregular plurals (five people)', () => { expectClean('There were five people waiting patiently outside in the cold morning rain.') }) it('does not flag an already-plural noun', () => { expectClean('She has two cats and they sleep all day long on the couch.') }) }) describe('double determiner', () => { it('flags a stacked article + possessive', () => { expectRule('Please put the my book back on the shelf when you finish it.', 'doubledet') }) }) describe('there is + plural', () => { it('flags "there is" with a plural quantifier', () => { expectRule('There is many reasons to visit the coast during the warm months.', 'thereis') }) it('does not flag "there is some" with a mass noun', () => { expectClean('There is some water left in the bottle if you are thirsty now.') }) }) describe('its / it’s', () => { it("flags it's used as a possessive", () => { expectRule("The cat licked it's own paw while sitting quietly by the window.", 'its') }) it('flags its used where "it is" is meant', () => { expectRule('I think its a wonderful idea to travel together next year sometime.', 'its') }) it('leaves correct possessive "its" alone', () => { expectClean('The dog wagged its tail happily when we came home that evening.') }) }) describe('than / then', () => { it('flags a comparative followed by "then"', () => { expectRule('This cake tastes much better then the one we made last weekend.', 'than') }) it('leaves sequential "then" alone', () => { expectClean('We ate dinner and then we watched a long movie on the sofa.') }) }) describe('comma after a transition', () => { it('flags a sentence-initial transition with no comma', () => { expectRule('However we decided to stay home and rest for the whole weekend.', 'introcomma') }) it('does not flag a sequence word (Then …)', () => { expectClean('Then we walked to the park and fed the ducks by the pond.') }) }) describe('spacing around punctuation', () => { it('flags a missing space after a comma', () => { expectRule('I like apples,bananas and grapes from the little shop down the street.', 'space-after') }) it('flags a space before punctuation', () => { expectRule('She smiled warmly , then walked away without saying a single word today.', 'space-punct') }) it('does not flag thousands separators in numbers', () => { expectClean('The list had 1,000 items and cost us about 2,500 dollars in total.') }) }) describe('capitalization basics', () => { it('flags a lowercase standalone "i"', () => { expectRule('Yesterday i walked to the store and bought milk, eggs, and bread.', 'cap-i') }) it('flags a sentence that does not start with a capital', () => { expectRule('The sun set slowly. it was a beautiful evening down by the river.', 'cap-sentence') }) }) import { mechanicsFindings } from './prose' // mechanicsFindings is the applyable subset feeding the suggestion cards. Each // finding must carry an EXACT span (text.slice(from,to) === original) and a // replacement that actually changes the text — these become one-click edits. describe('mechanicsFindings — applyable fixes', () => { // Every finding's span must be exact, regardless of which rule produced it. function expectExactSpans(text: string) { const found = mechanicsFindings(text) for (const f of found) { expect(text.slice(f.from, f.to), `span for ${JSON.stringify(f)}`).toBe(f.original) expect(f.replacement).not.toBe(f.original) } return found } const one = (text: string, rulePred: (f: ReturnType[number]) => boolean) => expectExactSpans(text).find(rulePred) it('doubled word → single word, exact span', () => { const f = one('I saw the the cat in the garden today.', (f) => f.original === 'the the') expect(f?.replacement).toBe('the') }) it('finds BOTH doublings with distinct spans', () => { const found = mechanicsFindings('the the dog and the the cat ran around the yard.') .filter((f) => f.original === 'the the') expect(found).toHaveLength(2) expect(found[0].from).not.toBe(found[1].from) }) // Spans are widened to a distinctive phrase so they re-anchor by string in the // editor (a bare "a"/"i"/" is" would match the wrong spot). The replacement // carries the whole corrected phrase. it('lowercase standalone i is awareness-only (no card)', () => { // It still flags as a companion bubble (see analyzeProse), but a single // character can't be anchored, so it must NOT become a card. expect(mechanicsFindings('Yesterday i walked to the store and came home.').length).toBe(0) expect(rulesFor('Yesterday i walked to the store and came home.')).toContain('cap-i') }) it('a → an: spans the whole "a " phrase', () => { const f = one('She found a umbrella under the old wooden table.', (f) => f.original === 'a umbrella') expect(f?.replacement).toBe('an umbrella') }) it('uncountable plural → singular', () => { const f = one('She gave me many informations about the new job.', (f) => f.original === 'informations') expect(f?.replacement).toBe('information') }) it('lowercase proper noun → capitalized', () => { const f = one('I am learning english during my free time this year.', (f) => f.original === 'english') expect(f?.replacement).toBe('English') }) it('subject-verb agreement → corrects the verb, spans subject+verb', () => { const f = one('She have three cats and a dog at her house.', (f) => f.original === 'She have') expect(f?.replacement).toBe('She has') }) it('singular noun after a number → plural, spans number+noun', () => { const f = one('I bought five apple at the market this morning.', (f) => f.original === 'five apple') expect(f?.replacement).toBe('five apples') }) it('comparative + then → than, spans the phrase', () => { const f = one('This book is better then the one I read last week.', (f) => f.original === 'better then') expect(f?.replacement).toBe('better than') }) it('space before punctuation is removed (spans the word)', () => { const f = one('I love it , it makes me very happy indeed.', (f) => f.original === 'it ,') expect(f?.replacement).toBe('it,') }) it('missing space after a comma is added (spans both words)', () => { const f = one('I bought apples,bananas and pears at the store.', (f) => f.original === 'apples,bananas') expect(f?.replacement).toBe('apples, bananas') }) it('stacked determiner drops the article', () => { const f = one('She left the my book on the kitchen table again.', (f) => f.original === 'the my') expect(f?.replacement).toBe('my') }) it('there is + plural → there are, spans the phrase', () => { const f = one('There is many people waiting outside in the cold.', (f) => f.original === 'There is many') expect(f?.replacement).toBe('There are many') }) it("it's own → its own, spans the phrase", () => { const f = one("The cat licked it's own paw very slowly today.", (f) => f.original === "it's own") expect(f?.replacement).toBe('its own') }) it("its a → it's a, spans the phrase", () => { const f = one('I think its a wonderful day to go outside now.', (f) => f.original === 'its a') expect(f?.replacement).toBe("it's a") }) // Awareness-only rules never produce cards — they stay companion bubbles. it('run-ons and splices produce NO card findings', () => { const runOn = 'I went to the market and I bought some apples and then I saw my friend and we talked for a while and after that we went to the cafe and ordered coffee and cake together.' expect(mechanicsFindings(runOn).some((f) => f.original.length > 30)).toBe(false) const splice = 'I finished the report, I sent it to my boss right away today.' // no card spans the comma-join; only (if any) small word-level fixes expect(mechanicsFindings(splice).every((f) => f.to - f.from < 12)).toBe(true) }) it('clean prose yields no findings', () => { expect(mechanicsFindings('The garden was quiet and the rain fell softly on the leaves.')).toEqual([]) }) })