Files
petal/web/src/components/Companion/prose.test.ts
prosolis 69ecb79da1 Companion: rules-based, context-aware writing advice
Replace the kitten's random generic tips with a deterministic, LLM-free
prose checker that reads the actual document and surfaces one gentle,
bilingual note at a time — quoting a slice of her own sentence so the
advice is clearly about *this* draft.

Rules (all conservative, tuned for a Mandarin-native ESL writer):
run-ons, comma splices, unclear antecedents, Oxford comma, a/an,
uncountable plurals, capitalize languages/days/months, subject-verb
agreement, plural-after-number, double determiner, "there is" + plural,
its/it's, than/then, comma-after-transition, doubled words, lowercase
"i", sentence capitals, and spacing around punctuation.

Each rule is paired with false-positive guards (e.g. "a university",
"After dinner, I went home and read", existing Oxford lists) and pinned
by a 45-case Vitest suite (npm test).

Also: bubbles now linger proportional to how much there is to read
(zh+en) and pause-on-hover, so denser advice no longer vanishes mid-read.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 07:16:23 -07:00

251 lines
9.1 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')
})
})