Files
petal/web/src/components/Companion/prose.test.ts
prosolis 96f68a91ee Add deterministic mechanics suggestion family (rule-based, no LLM)
Reuse the companion's prose.ts rules engine as the single source of
deterministic detection instead of duplicating it. Applyable rules now
also emit exact-span fixes (original -> replacement) that surface as
suggestion cards; awareness-only rules (run-ons, splices, ...) stay
companion bubbles. The companion hides fix-bearing hints so a span is
never both a bubble and a card.

Spans are widened to a distinctive phrase ("a old" -> "an old",
"She have" -> "She has") so they re-anchor by string in the editor; a
lone lowercase "i" stays awareness-only since a single char can't anchor.

Backend: detection lives client-side, so the new persist-only
POST /docs/{id}/mechanics endpoint receives findings and stores them as
the 'mechanics' family with their exact offsets. It honours
actioned-suppression, leaves the LLM families untouched, and a checkpoint
no longer wipes it. fetchPending dedupes spans with mechanics winning any
collision against an LLM card (its span is exact). Migration 0008 adds the
'mechanics' suggestion type.

Client renders the mechanics fixes immediately (no LLM wait) and the cards
use a calm sage "Tidy-up" accent.

Verified end-to-end in a real browser on millenia: detect -> persist ->
render -> accept applies the fix.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 20:43:37 -07:00

367 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('subjectverb 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 / its', () => {
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<typeof mechanicsFindings>[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 <noun>" 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([])
})
})