diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 79cfd9015..d09917e50 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -1381,6 +1381,334 @@ function testLlmReviewSummaryScope(): void { ); } +function testRatingCriteriaAndReviewForm(): void { + // D1: every list is weighted to 100, with unique keys, both labels and + // exactly one required criterion. + for (const slug of rules.RATING_CRITERIA_CATEGORIES) { + const criteria = rules.getRatingCriteria(slug); + assert.equal( + criteria.reduce((sum, criterion) => sum + criterion.weight, 0), + 100, + slug + ); + assert.equal(new Set(criteria.map((cr) => cr.key)).size, criteria.length); + for (const criterion of criteria) { + assert.ok(criterion.label.vi && criterion.label.en, criterion.key); + assert.ok(criterion.weight > 0, criterion.key); + } + assert.equal(criteria.filter((cr) => cr.required).length, 1, slug); + } + assert.equal(rules.RATING_CRITERIA_VERSION, 1); + // Earbuds rate sound, not a display; an unknown category rates as a phone. + const earbudKeys = rules.getRatingCriteria('earbuds').map((cr) => cr.key); + assert.ok(earbudKeys.includes('sound') && !earbudKeys.includes('display')); + assert.deepEqual( + rules.getRatingCriteria('cameras'), + rules.getRatingCriteria('smartphones') + ); + assert.deepEqual( + rules.getRatingCriteria(null), + rules.getRatingCriteria('smartphones') + ); + + // The review star is the weighted average, at one decimal: a phone with + // performance 5 (20), camera 4 (20), battery 3 (20), display 5 (15), + // design 4 (10) and value 4 (15) is 415 / 100 = 4.15, shown as 4.2. + const phone = { + performance: 5, + camera: 4, + battery: 3, + display: 5, + design: 4, + value: 4, + }; + assert.equal(rules.weightedReviewStar('smartphones', phone), 4.2); + // Skipped criteria leave the weights of the rated ones: (5*20 + 4*15) / 35. + assert.equal( + rules.weightedReviewStar('smartphones', { performance: 5, value: 4 }), + 4.6 + ); + // Unknown keys and bad stars are ignored; nothing valid gives null. + assert.equal( + rules.weightedReviewStar('smartphones', { screen: 5, value: 7 }), + null + ); + assert.equal(rules.weightedReviewStar('earbuds', {}), null); + + // D5: up to two criteria skipped, value never; stars 1-5 in half steps. + assert.deepEqual(rules.validateCriteriaRatings('smartphones', phone), []); + assert.deepEqual( + rules.validateCriteriaRatings('smartphones', { + performance: 4.5, + camera: 4, + battery: 3, + display: 5, + value: 4, + }), + [] + ); + assert.deepEqual( + rules.validateCriteriaRatings('smartphones', { + performance: 5, + camera: 4, + battery: 3, + display: 5, + design: 4, + }), + [{ kind: 'missing_required', key: 'value' }] + ); + assert.deepEqual( + rules.validateCriteriaRatings('smartphones', { + performance: 5, + camera: 4, + value: 4, + }), + [{ kind: 'too_many_skipped', skipped: 3 }] + ); + assert.deepEqual( + rules.validateCriteriaRatings('earbuds', { + sound: 5, + anc: 4.3, + battery: 0, + comfort: 5, + screen: 4, + value: 4, + }), + [ + { kind: 'bad_star', key: 'anc' }, + { kind: 'bad_star', key: 'battery' }, + { kind: 'unknown_key', key: 'screen' }, + ] + ); + assert.deepEqual( + rules.validateCriteriaRatings('services', { + service_quality: 5, + attitude: 4, + speed: 4, + }), + [{ kind: 'missing_required', key: 'price' }] + ); + + // Legacy five keys map per category; a key with no counterpart is dropped + // and nothing is inferred for the new criteria. + const legacy = { + screen: 4.3, + performance: 5, + camera: 4, + battery: 3, + value: 4, + }; + assert.deepEqual(rules.mapLegacyCriteriaRatings('smartphones', legacy), { + display: 4.3, + performance: 5, + camera: 4, + battery: 3, + value: 4, + }); + assert.deepEqual(rules.mapLegacyCriteriaRatings('earbuds', legacy), { + sound: 4.3, + anc: 4, + battery: 3, + value: 4, + }); + assert.deepEqual(rules.mapLegacyCriteriaRatings('smartwatches', legacy), { + display: 4.3, + battery: 3, + value: 4, + }); + assert.deepEqual( + rules.mapLegacyCriteriaRatings('laptops', { screen: 9, value: 'x' }), + {} + ); + + // D5 text: verdict, pros and cons. + const review = { + verdict: 'Hợp người chơi game, ai cần sạc nhanh nên cân nhắc.', + pros: ['Chơi game mượt, ít nóng', 'Ảnh đêm rõ'], + cons: ['Sạc chậm so với máy cùng giá'], + }; + assert.deepEqual(rules.validateReviewText(review), []); + assert.deepEqual( + rules.validateReviewText({ verdict: 'Tốt', pros: [], cons: [] }), + [ + { kind: 'verdict_length', length: 3 }, + { kind: 'points_count', list: 'pros', count: 0 }, + { kind: 'points_count', list: 'cons', count: 0 }, + ] + ); + for (const dodge of [ + 'Không có', + 'không có gì để chê', + 'Chưa thấy', + 'Hiện tại chưa thấy nhược điểm nào', + 'ko co', + 'Không có điểm trừ', + 'None.', + '...', + // Reviews of #1548. + 'Không có nhược điểm nào cả', + 'chua co nhuoc diem nao het', + 'Không có gì đáng chê', + 'không chê được gì', + 'Ko có gì để chê nha', + 'không có ạ', + 'Chả có gì', + 'Khum có', + 'kcj', + 'Chưa phát hiện ra', + 'Chưa gặp vấn đề gì', + 'Không có gì để phàn nàn', + 'Mọi thứ đều ổn', + 'no complaints', + '10/10', + 'aaaaa', + 'Khô​ng có', + 'KHÔNG CÓ', + // Round 2 of #1548. + 'Mình không thấy gì để chê', + 'Em chưa thấy gì', + 'Không có gì chê', + 'Không có điểm gì để chê', + 'Không có gì để nói', + 'Không có ý kiến', + 'Rất tốt', + 'Everything is fine', + 'hong co', + ]) { + assert.ok(rules.isEvasiveCon(dodge), dodge); + } + for (const real of [ + 'Giá hơi cao', + 'Không có củ sạc trong hộp', + 'Chưa có bản màu xanh', + 'Không chống nước', + 'Không có jack 3.5mm', + 'Chưa hỗ trợ eSIM', + 'Pin không trâu', + 'Không có NFC', + 'Chưa có gì nổi bật', + 'K có sạc', + // Round 2 of #1548: other diacritics are other words. + 'Hỏng hết', + 'Hỏng luôn', + 'Vẫn hỏng', + 'Không gập', + 'Chưa có lời', + 'Không có lõi', + 'Mọi thứ đều ồn', + 'Chả cá', + ]) { + assert.ok(!rules.isEvasiveCon(real), real); + } + assert.deepEqual( + rules.validateReviewText({ + ...review, + pros: ['Màn đẹp, sáng', 'màn đẹp sáng'], + cons: ['Không có gì', 'Màn đẹp, sáng!'], + }), + [ + { kind: 'duplicate_point', list: 'pros', index: 1 }, + { kind: 'evasive_con', index: 0 }, + { kind: 'pro_and_con', index: 1 }, + ] + ); + assert.deepEqual( + rules.validateReviewText({ + ...review, + pros: ['Tốt', 'x'.repeat(151), 'a', 'b1234', 'c1234', 'd1234'], + }), + [ + { kind: 'points_count', list: 'pros', count: 6 }, + { kind: 'point_length', list: 'pros', index: 0 }, + { kind: 'point_length', list: 'pros', index: 1 }, + { kind: 'point_length', list: 'pros', index: 2 }, + ] + ); + // Reviews of #1548: lines with no words, lengths as the writer sees them + // (three emoji are 3, a decomposed "Pin ổn" is 6), + // diacritics kept when comparing, and malformed bodies that never throw. + assert.deepEqual( + rules.validateReviewText({ + verdict: '.'.repeat(25), + pros: ['!!!!!!', '🔥🔥🔥🔥🔥', 'Màn hình sắc nét'], + cons: ['Màn hình sạc nét'], + }), + [ + { kind: 'no_content', list: 'verdict', index: 0 }, + { kind: 'no_content', list: 'pros', index: 0 }, + { kind: 'no_content', list: 'pros', index: 1 }, + ] + ); + assert.deepEqual( + rules.validateReviewText({ + ...review, + pros: ['😀😀😀', 'Pin ổn'.normalize('NFD')], + }), + [{ kind: 'point_length', list: 'pros', index: 0 }] + ); + assert.deepEqual( + rules.validateReviewText( + null as unknown as Parameters[0] + ), + [ + { kind: 'verdict_length', length: 0 }, + { kind: 'points_count', list: 'pros', count: 0 }, + { kind: 'points_count', list: 'cons', count: 0 }, + ] + ); + + // Reviews of #1548: prototype keys are not categories or ratings, null is a + // skipped criterion, and legacy 0.1-step stars count in the review star. + for (const slug of ['toString', '__proto__', 'constructor']) { + assert.deepEqual( + rules.getRatingCriteria(slug), + rules.getRatingCriteria('smartphones'), + slug + ); + assert.equal(rules.hasRatingCriteria(slug), false, slug); + assert.deepEqual( + rules.validateCriteriaRatings(slug, phone), + [], + `validate ${slug}` + ); + assert.deepEqual( + rules.mapLegacyCriteriaRatings(slug, { value: 5 }), + { value: 5 }, + `legacy ${slug}` + ); + } + assert.equal(rules.hasRatingCriteria('earbuds'), true); + assert.equal(rules.hasRatingCriteria('service'), false); + assert.equal( + rules.weightedReviewStar( + 'smartphones', + Object.create({ value: 5 }) as Record + ), + null + ); + assert.deepEqual( + rules.validateCriteriaRatings('smartphones', { + ...phone, + design: null, + camera: null, + }), + [] + ); + const legacyStars = rules.mapLegacyCriteriaRatings('smartphones', { + screen: 1.3, + performance: 5, + camera: 5, + battery: 5, + value: 5, + }); + // (1.3*15 + 5*20 + 5*20 + 5*20 + 5*15) / 90 = 394.5 / 90 = 4.38. + assert.equal(rules.weightedReviewStar('smartphones', legacyStars), 4.4); + // An oversized con is never a dodge and is checked in linear time. + const started = Date.now(); + assert.ok(!rules.isEvasiveCon('khong ' + 'gi '.repeat(50_000) + 'x')); + assert.ok(Date.now() - started < 1000, 'linear on an oversized con'); +} + function main(): void { console.log('🧪 Running @repo/shared-data unit tests...'); testVersionAndZeroStrapiExports(); @@ -1413,6 +1741,10 @@ function main(): void { console.log(' ✅ [9/9] Category spec templates (DOS/Rate#1142) verified'); testLlmReviewSummaryScope(); console.log(' ✅ [10/10] LLM review summary scope (DOS/Rate#1244) verified'); + testRatingCriteriaAndReviewForm(); + console.log( + ' ✅ [11/11] Rating criteria and review form rules (DOS/Rate#1542) verified' + ); console.log('🎉 All @repo/shared-data tests passed!'); } diff --git a/packages/shared-data/src/rules/index.ts b/packages/shared-data/src/rules/index.ts index ed684a623..3abdc626f 100644 --- a/packages/shared-data/src/rules/index.ts +++ b/packages/shared-data/src/rules/index.ts @@ -11,3 +11,5 @@ export * from './listing-price-floor.js'; export * from './spec-template.js'; export * from './model-key.js'; export * from './review-summary.js'; +export * from './rating-criteria.js'; +export * from './review-form.js'; diff --git a/packages/shared-data/src/rules/rating-criteria.ts b/packages/shared-data/src/rules/rating-criteria.ts new file mode 100644 index 000000000..4baecc992 --- /dev/null +++ b/packages/shared-data/src/rules/rating-criteria.ts @@ -0,0 +1,298 @@ +/** + * Rating criteria per category, with weights (DOS/Rate#1542, design #1531, D1). + * + * A Rate review rates each criterion of its category in stars, and its own + * star is the weighted average of the criteria it rated, as in JReviews. + * The lists are versioned: a review keeps the version it was written with, so + * a later change of weights never rewrites stored reviews. Plain data and + * functions only, like the rest of `rules`. + */ +import type { SpecGroupKey } from './spec-template.js'; + +/** Bump whenever a list, a key or a weight changes. */ +export const RATING_CRITERIA_VERSION = 1; + +export type RatingCriterionKey = + | 'performance' + | 'camera' + | 'battery' + | 'display' + | 'design' + | 'value' + | 'sound' + | 'software' + | 'keyboard' + | 'thermals' + | 'health' + | 'apps' + | 'anc' + | 'comfort' + | 'calls' + | 'service_quality' + | 'price' + | 'attitude' + | 'speed'; + +export interface RatingCriterion { + key: RatingCriterionKey; + label: { vi: string; en: string }; + /** Share of the review star, in percent; a category's weights sum to 100. */ + weight: number; + /** The spec group shown beside the score, when one fits. */ + specGroup: SpecGroupKey | null; + /** A criterion every review must rate ("Đáng tiền", D5). */ + required: boolean; +} + +/** The most criteria a review may leave unrated ("Chưa dùng tới", D5). */ +export const MAX_SKIPPED_CRITERIA = 2; + +const c = ( + key: RatingCriterionKey, + vi: string, + en: string, + weight: number, + specGroup: SpecGroupKey | null, + required = false +): RatingCriterion => ({ key, label: { vi, en }, weight, specGroup, required }); + +const VALUE = c('value', 'Đáng tiền', 'Value for money', 15, null, true); + +const RATING_CRITERIA: Readonly> = { + smartphones: [ + c('performance', 'Hiệu năng', 'Performance', 20, 'hardware'), + c('camera', 'Camera', 'Camera', 20, 'camera'), + c('battery', 'Pin và sạc', 'Battery and charging', 20, 'battery'), + c('display', 'Màn hình', 'Display', 15, 'display'), + c('design', 'Thiết kế và độ bền', 'Design and build', 10, 'design'), + VALUE, + ], + tablets: [ + c('display', 'Màn hình', 'Display', 20, 'display'), + c('performance', 'Hiệu năng', 'Performance', 20, 'hardware'), + c('battery', 'Pin', 'Battery', 15, 'battery'), + c('sound', 'Loa và giải trí', 'Speakers and media', 10, 'multimedia'), + c('software', 'Phần mềm và phụ kiện', 'Software and accessories', 10, null), + c('design', 'Thiết kế', 'Design', 10, 'design'), + VALUE, + ], + laptops: [ + c('performance', 'Hiệu năng', 'Performance', 25, 'hardware'), + c('display', 'Màn hình', 'Display', 15, 'display'), + c('battery', 'Pin', 'Battery', 15, 'battery'), + c('keyboard', 'Bàn phím và touchpad', 'Keyboard and touchpad', 10, null), + c('thermals', 'Tản nhiệt và độ ồn', 'Thermals and noise', 10, 'hardware'), + c('design', 'Thiết kế và độ bền', 'Design and build', 10, 'design'), + VALUE, + ], + smartwatches: [ + c('health', 'Theo dõi sức khoẻ', 'Health tracking', 25, null), + c('battery', 'Pin', 'Battery', 20, 'battery'), + c('design', 'Thiết kế và đeo', 'Design and fit', 15, 'design'), + c( + 'apps', + 'Ứng dụng và kết nối', + 'Apps and connectivity', + 15, + 'connectivity' + ), + c('display', 'Màn hình', 'Display', 10, 'display'), + VALUE, + ], + earbuds: [ + c('sound', 'Chất âm', 'Sound quality', 30, 'multimedia'), + c('anc', 'Chống ồn', 'Noise cancelling', 15, 'multimedia'), + c('battery', 'Pin', 'Battery', 15, 'battery'), + c('comfort', 'Đeo thoải mái', 'Comfort', 15, 'design'), + c('calls', 'Mic và gọi', 'Microphone and calls', 10, 'multimedia'), + VALUE, + ], + // Services (entity_type = 'service'): price is what every review must rate. + services: [ + c('service_quality', 'Chất lượng dịch vụ', 'Service quality', 35, null), + c('price', 'Giá cả', 'Price', 25, null, true), + c('attitude', 'Thái độ phục vụ', 'Staff attitude', 20, null), + c('speed', 'Tốc độ', 'Speed', 20, null), + ], +}; + +/** Categories with their own criteria list. */ +export const RATING_CRITERIA_CATEGORIES: readonly string[] = + Object.keys(RATING_CRITERIA); + +/** + * The criteria of a category, in display order. A category without its own + * list (or none given) rates as a smartphone, the catalog's default category. + */ +export function getRatingCriteria( + categorySlug: string | null | undefined +): readonly RatingCriterion[] { + return ( + ownValue(RATING_CRITERIA, categorySlug) ?? RATING_CRITERIA.smartphones! + ); +} + +/** + * Whether a category has its own criteria list. An API that must not rate a + * new category ("tvs") or a typo ("service") as a phone checks this first. + */ +export function hasRatingCriteria( + categorySlug: string | null | undefined +): boolean { + return ownValue(RATING_CRITERIA, categorySlug) !== undefined; +} + +/** + * A record's own entry: "toString" or "__proto__" as a category slug or a + * rating key never reaches Object.prototype. + */ +function ownValue( + record: Readonly> | null | undefined, + key: string | null | undefined +): T | undefined { + return record && key && Object.prototype.hasOwnProperty.call(record, key) + ? record[key] + : undefined; +} + +/** + * Criterion stars as stored in `rate.reviews.criteria_ratings`. A criterion + * the writer left out ("Chưa dùng tới") is missing, undefined or null. + */ +export type CriteriaRatings = Partial>; + +export type CriteriaRatingsIssue = + | { kind: 'unknown_key'; key: string } + | { kind: 'bad_star'; key: string } + | { kind: 'missing_required'; key: RatingCriterionKey } + | { kind: 'too_many_skipped'; skipped: number }; + +/** A stored star: any number from 1 to 5 (legacy reviews kept 0.1 steps). */ +function isStoredStar(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= 1 && + value <= 5 + ); +} + +/** A star the form gives: 1 to 5 in half steps. */ +function isStar(value: unknown): value is number { + return isStoredStar(value) && Number.isInteger(value * 2); +} + +/** + * What is wrong with a review's criterion stars under D5: a key the category + * does not have, a star outside 1-5 in half steps, a required criterion left + * out, or more than MAX_SKIPPED_CRITERIA criteria left out. An empty list is + * a valid review. + */ +export function validateCriteriaRatings( + categorySlug: string | null | undefined, + ratings: CriteriaRatings +): CriteriaRatingsIssue[] { + const criteria = getRatingCriteria(categorySlug); + const known = new Set(criteria.map((criterion) => criterion.key)); + const issues: CriteriaRatingsIssue[] = []; + const given = ratings && typeof ratings === 'object' ? ratings : {}; + for (const [key, star] of Object.entries(given)) { + if (!known.has(key)) issues.push({ kind: 'unknown_key', key }); + else if (star != null && !isStar(star)) + issues.push({ kind: 'bad_star', key }); + } + let skipped = 0; + for (const criterion of criteria) { + if (ownValue(given, criterion.key) != null) continue; + if (criterion.required) + issues.push({ kind: 'missing_required', key: criterion.key }); + else skipped += 1; + } + if (skipped > MAX_SKIPPED_CRITERIA) + issues.push({ kind: 'too_many_skipped', skipped }); + return issues; +} + +/** + * A review's own star: the weighted average of the criteria it rated, at one + * decimal (design 2.2). Keys the category does not have and stars outside + * 1-5 are ignored; null when nothing valid is rated. Any star from 1 to 5 + * counts, so a legacy review's 0.1-step stars do too: half steps are a rule + * of the form (validateCriteriaRatings), not of stored data. + */ +export function weightedReviewStar( + categorySlug: string | null | undefined, + ratings: CriteriaRatings +): number | null { + let sum = 0; + let weights = 0; + for (const criterion of getRatingCriteria(categorySlug)) { + const star = ownValue(ratings, criterion.key); + if (!isStoredStar(star)) continue; + sum += star * criterion.weight; + weights += criterion.weight; + } + if (weights === 0) return null; + return Math.round((sum * 10) / weights) / 10; +} + +/** The five keys of the first review form, before the per-category lists. */ +export type LegacyCriterionKey = + 'screen' | 'performance' | 'camera' | 'battery' | 'value'; + +// What each legacy key rated per category. The product page labels an +// earbud's "screen" score soundstage and its "camera" score ANC / mic, so +// they map to sound and anc. A key with no counterpart (a tablet's, laptop's +// or watch's camera, a watch's or earbud's performance) maps to nothing and +// its star is dropped. A mapped legacy review can rate fewer criteria than +// validateCriteriaRatings asks of a new one: that check is for new reviews. +const LEGACY_MAP: Readonly< + Record>> +> = { + smartphones: { + screen: 'display', + performance: 'performance', + camera: 'camera', + battery: 'battery', + value: 'value', + }, + tablets: { + screen: 'display', + performance: 'performance', + battery: 'battery', + value: 'value', + }, + laptops: { + screen: 'display', + performance: 'performance', + battery: 'battery', + value: 'value', + }, + smartwatches: { screen: 'display', battery: 'battery', value: 'value' }, + earbuds: { + screen: 'sound', + camera: 'anc', + battery: 'battery', + value: 'value', + }, + services: {}, +}; + +/** + * A review written with the five legacy keys, in the category's keys. A + * criterion the legacy form did not ask stays unrated: nothing is inferred. + * The review API stored legacy stars at 0.1, so any star from 1 to 5 + * carries over as it is. + */ +export function mapLegacyCriteriaRatings( + categorySlug: string | null | undefined, + legacy: Partial> +): CriteriaRatings { + const map = ownValue(LEGACY_MAP, categorySlug) ?? LEGACY_MAP.smartphones!; + const out: CriteriaRatings = {}; + for (const [legacyKey, key] of Object.entries(map)) { + const star = ownValue(legacy, legacyKey); + if (key && isStoredStar(star)) out[key] = star; + } + return out; +} diff --git a/packages/shared-data/src/rules/review-form.ts b/packages/shared-data/src/rules/review-form.ts new file mode 100644 index 000000000..3a4453c36 --- /dev/null +++ b/packages/shared-data/src/rules/review-form.ts @@ -0,0 +1,176 @@ +/** + * The text a Rate review must carry (DOS/Rate#1542, design #1531, D5): a + * verdict, at least one pro and at least one con. The web form, the review + * API and the importer's checks share these rules, so a review rejected in + * one place is rejected everywhere. Plain data and functions only. + */ + +export const REVIEW_VERDICT_MIN = 20; +export const REVIEW_VERDICT_MAX = 300; +export const REVIEW_POINT_MIN = 5; +export const REVIEW_POINT_MAX = 150; +/** Pros and cons each: at least one, at most five. */ +export const REVIEW_POINTS_MAX = 5; +/** Pros and cons of one criterion: at most three each, none required. */ +export const REVIEW_CRITERION_POINTS_MAX = 3; + +export interface ReviewText { + verdict: string; + pros: readonly string[]; + cons: readonly string[]; +} + +export type ReviewTextIssue = + | { kind: 'verdict_length'; length: number } + | { kind: 'points_count'; list: 'pros' | 'cons'; count: number } + | { kind: 'point_length'; list: 'pros' | 'cons'; index: number } + | { kind: 'no_content'; list: 'verdict' | 'pros' | 'cons'; index: number } + | { kind: 'evasive_con'; index: number } + | { kind: 'duplicate_point'; list: 'pros' | 'cons'; index: number } + | { kind: 'pro_and_con'; index: number }; + +const EMPTY = ''; +// Compatibility composition: fullwidth letters ("KHÔNG") fold to plain +// ones and Vietnamese letters stay whole. +const COMPOSED = 'NFKC'; +// Compatibility decomposition, so diacritics split off as marks. +const DECOMPOSED = 'NFKD'; + +/** + * The form two lines are compared in: lower case, no zero-width characters, + * punctuation and symbols as spaces, spaces collapsed. Diacritics stay + * ("sạc" and "sắc" differ, design 5.1). Only for comparing lines; what is + * stored and shown keeps the writer's text. + */ +export function comparablePoint(text: string): string { + const composed = String(text ?? EMPTY).normalize(COMPOSED); + return composed + .replace(/\p{Cf}+/gu, '') + .toLowerCase() + .replace(/[\p{P}\p{S}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** A compared line without diacritics, "đ" as "d": for spotting dodges. */ +function plainPoint(text: string): string { + const decomposed = comparablePoint(text).normalize(DECOMPOSED); + return decomposed.replace(/\p{M}+/gu, '').replace(/đ/g, 'd'); +} + +/** Length in characters as the writer sees them (code points, composed). */ +function visibleLength(text: string): number { + const composed = String(text ?? EMPTY).normalize(COMPOSED); + return [...composed.replace(/\p{Cf}+/gu, '').trim()].length; +} + +/** + * Whether a line has words in it: at least three letters or digits, and + * not one character repeated ("aaaaa", "....", "🔥🔥🔥🔥🔥"). + */ +function hasContent(text: string): boolean { + const letters = comparablePoint(text).match(/[\p{L}\p{N}]/gu) ?? []; + return letters.length >= 3 && new Set(letters).size >= 2; +} + +// Each word of a dodge as typed with its diacritics or without any ("không" or +// "khong"), but never with other ones: "hỏng" (broken), "gập", "lời" and "ồn" +// are real words, not "hông", "gặp", "lỗi" and "ổn" typed loosely. +const anyOf = (words: readonly string[]): string => + `(?:${[...new Set(words.flatMap((word) => [word, plainPoint(word)]))].join('|')})`; + +// A con that dodges the question: "không có", "chưa thấy ạ", "mình không thấy +// gì để chê", "Không có nhược điểm nào cả", "chả có gì", "khum có", "không chê +// được gì", "chưa gặp vấn đề gì", "không có ý kiến", "mọi thứ đều ổn", "none", +// "10/10". A real con, however small ("giá hơi cao", "không có jack 3.5mm", +// "chưa có gì nổi bật", "hỏng luôn"), never matches: the whole line must be +// the dodge. +const EVASIVE_CON = new RegExp( + [ + `^(?:${anyOf(['hiện tại', 'tạm thời', 'đến giờ', 'vẫn', 'thì', 'thực sự', 'thật sự', 'mình', 'em', 'tôi', 'tui', 'anh', 'chị', 'e', 'm'])} )*`, + anyOf([ + 'không', + 'ko', + 'k', + 'kh', + 'khum', + 'chưa', + 'chẳng', + 'chả', + 'hok', + 'hông', + 'hem', + ]), + `(?: ${anyOf(['có', 'thấy', 'gặp', 'phát hiện', 'phát hiện ra', 'chê được', 'chê'])})*`, + `(?: ${anyOf(['gì', 'j', 'nhược điểm', 'điểm trừ', 'khuyết điểm', 'điểm yếu', 'vấn đề', 'lỗi', 'điểm', 'ý kiến'])})*`, + `(?:(?: ${anyOf(['để', 'mà', 'đáng', 'phải', 'cần'])})? ${anyOf(['chê', 'phàn nàn', 'nói', 'kể'])})?`, + `(?: ${anyOf(['cả', 'hết', 'nào', 'luôn', 'ạ', 'nhé', 'nha', 'nhen', 'đâu'])})*$`, + `|^(?:${anyOf(['mọi thứ', 'tất cả', 'all'])} (?:${anyOf(['đều'])} )?${anyOf(['ổn', 'tốt', 'ok', 'good', 'fine'])}`, + `|${anyOf(['kcj', 'no', 'none', 'nothing', 'nothing to complain', 'nothing to complain about', 'no con', 'no cons', 'no complaint', 'no complaints', 'everything is fine', 'n a', '0', 'ok', 'ổn', 'tốt', 'rất tốt', 'tuyệt vời', 'hoàn hảo', 'perfect', '10 10', '5 sao'])})$`, + ].join('') +); + +/** + * Whether a con line says only that there is no con. A dodge is short: a + * line longer than a con may be is never one, which also keeps the check + * linear on an oversized request. + */ +export function isEvasiveCon(text: string): boolean { + const compared = comparablePoint(text); + if (compared.length > REVIEW_POINT_MAX) return false; + return !hasContent(text) || EVASIVE_CON.test(compared); +} + +function checkList( + list: 'pros' | 'cons', + points: readonly string[], + issues: ReviewTextIssue[] +): void { + if (points.length < 1 || points.length > REVIEW_POINTS_MAX) + issues.push({ kind: 'points_count', list, count: points.length }); + const seen = new Set(); + points.forEach((point, index) => { + const length = visibleLength(point); + if (length < REVIEW_POINT_MIN || length > REVIEW_POINT_MAX) + issues.push({ kind: 'point_length', list, index }); + else if (list === 'pros' && !hasContent(point)) + issues.push({ kind: 'no_content', list, index }); + const compared = comparablePoint(point); + if (compared && seen.has(compared)) + issues.push({ kind: 'duplicate_point', list, index }); + seen.add(compared); + }); +} + +function asLines(value: unknown): string[] { + return Array.isArray(value) ? value.map((line) => String(line ?? '')) : []; +} + +/** + * What is wrong with a Rate review's text under D5 (design 4.2): verdict + * length, the number and length of pros and cons, a line with no words, a + * con that dodges, the same line twice in a list, and a line that is both a + * pro and a con. Lengths count characters as the writer sees them, like + * Postgres char_length. An empty result is a valid review; a malformed body + * gives issues and never throws. + */ +export function validateReviewText(review: ReviewText): ReviewTextIssue[] { + const issues: ReviewTextIssue[] = []; + const verdict = String(review?.verdict ?? ''); + const pros = asLines(review?.pros); + const cons = asLines(review?.cons); + const verdictLength = visibleLength(verdict); + if (verdictLength < REVIEW_VERDICT_MIN || verdictLength > REVIEW_VERDICT_MAX) + issues.push({ kind: 'verdict_length', length: verdictLength }); + else if (!hasContent(verdict)) + issues.push({ kind: 'no_content', list: 'verdict', index: 0 }); + checkList('pros', pros, issues); + checkList('cons', cons, issues); + const proLines = new Set(pros.map(comparablePoint)); + cons.forEach((con, index) => { + if (isEvasiveCon(con)) issues.push({ kind: 'evasive_con', index }); + else if (proLines.has(comparablePoint(con))) + issues.push({ kind: 'pro_and_con', index }); + }); + return issues; +}