From dd417c5a40bff89f5150ff17be58b349c05dc649 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:28:41 +0700 Subject: [PATCH 1/3] feat(shared-data): per-category rating criteria, weights and review form rules Phase P1 of the #1531 design (D1, D5). rules/rating-criteria.ts holds version 1 of the criteria lists and weights for smartphones, tablets, laptops, smartwatches, earbuds and services, the weighted review star at 0.1, the D5 check on criterion stars (half steps, at most two skipped, value or price never) and the map from the five legacy form keys. rules/review-form.ts holds the D5 text rules: verdict length, one to five pros and cons, line length, evasive cons, duplicates and a line given as both. No database change; wiring the web form is P3. Closes #1542 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/shared-data.test.ts | 205 +++++++++++++ packages/shared-data/src/rules/index.ts | 2 + .../shared-data/src/rules/rating-criteria.ts | 272 ++++++++++++++++++ packages/shared-data/src/rules/review-form.ts | 101 +++++++ 4 files changed, 580 insertions(+) create mode 100644 packages/shared-data/src/rules/rating-criteria.ts create mode 100644 packages/shared-data/src/rules/review-form.ts diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 79cfd9015..53a640fc2 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -1381,6 +1381,207 @@ 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.', + '...', + ]) { + 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', + ]) { + 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 }, + ] + ); +} + function main(): void { console.log('🧪 Running @repo/shared-data unit tests...'); testVersionAndZeroStrapiExports(); @@ -1413,6 +1614,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..143c28fc6 --- /dev/null +++ b/packages/shared-data/src/rules/rating-criteria.ts @@ -0,0 +1,272 @@ +/** + * 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 ( + (categorySlug ? RATING_CRITERIA[categorySlug] : undefined) ?? + RATING_CRITERIA.smartphones! + ); +} + +/** Criterion stars as stored in `rate.reviews.criteria_ratings`. */ +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 star from 1 to 5 in half steps. */ +function isStar(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= 1 && + value <= 5 && + 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[] = []; + for (const [key, star] of Object.entries(ratings)) { + if (!known.has(key)) issues.push({ kind: 'unknown_key', key }); + else if (star !== undefined && !isStar(star)) + issues.push({ kind: 'bad_star', key }); + } + let skipped = 0; + for (const criterion of criteria) { + if (ratings[criterion.key] !== undefined) 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. + */ +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 = ratings[criterion.key]; + if (!isStar(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. Earbuds labelled "screen" as +// soundstage and "camera" as ANC / mic; a key with no counterpart (a watch's +// camera, earbuds' performance) maps to nothing and its star is dropped. +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 = + (categorySlug ? LEGACY_MAP[categorySlug] : undefined) ?? + LEGACY_MAP.smartphones!; + const out: CriteriaRatings = {}; + for (const [legacyKey, key] of Object.entries(map)) { + const star = legacy[legacyKey]; + if ( + key && + typeof star === 'number' && + Number.isFinite(star) && + star >= 1 && + star <= 5 + ) + 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..b90ef2ec3 --- /dev/null +++ b/packages/shared-data/src/rules/review-form.ts @@ -0,0 +1,101 @@ +/** + * 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: 'evasive_con'; index: number } + | { kind: 'duplicate_point'; list: 'pros' | 'cons'; index: number } + | { kind: 'pro_and_con'; index: number }; + +// Canonical decomposition, so diacritics split off as marks. +const DECOMPOSED = 'NFD'; + +/** + * The form a line is compared in: lower case, no diacritics, "đ" as "d", + * punctuation as spaces and spaces collapsed. Only for comparing lines; + * what is stored and shown keeps the writer's text. + */ +export function comparablePoint(text: string): string { + const decomposed = text.normalize(DECOMPOSED); + return decomposed + .replace(/\p{M}+/gu, '') + .replace(/[đĐ]/g, 'd') + .toLowerCase() + .replace(/[\p{P}\p{S}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// A con that dodges the question ("không có", "chưa thấy", "không có gì để +// chê", "ko co", "none"), compared without diacritics. A real con, however +// small ("giá hơi cao"), never matches: the whole line must be the dodge. +const EVASIVE_CON = + /^(?:(?:hien\s+tai|tam\s+thoi|den\s+gio|van)\s+)?(?:khong|ko|k|chua|chang|hok|hong)(?:\s+(?:co|thay|gap|phat\s+hien))?(?:\s+(?:gi|j|nhuoc\s+diem|diem\s+tru))?(?:\s+(?:de|ma)\s+che)?(?:\s+(?:ca|het|nao|luon))?$|^(?:none|nothing|no\s+cons?|n\s*a|0|ok|tot|hoan\s+hao|perfect)$/; + +/** Whether a con line says only that there is no con. */ +export function isEvasiveCon(text: string): boolean { + const plain = comparablePoint(text); + return plain.length === 0 || EVASIVE_CON.test(plain); +} + +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 = point.trim().length; + if (length < REVIEW_POINT_MIN || length > REVIEW_POINT_MAX) + issues.push({ kind: 'point_length', list, index }); + const plain = comparablePoint(point); + if (plain && seen.has(plain)) + issues.push({ kind: 'duplicate_point', list, index }); + seen.add(plain); + }); +} + +/** + * 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 con that dodges, the + * same line twice in a list, and a line that is both a pro and a con. An + * empty result is a valid review. + */ +export function validateReviewText(review: ReviewText): ReviewTextIssue[] { + const issues: ReviewTextIssue[] = []; + const verdictLength = review.verdict.trim().length; + if (verdictLength < REVIEW_VERDICT_MIN || verdictLength > REVIEW_VERDICT_MAX) + issues.push({ kind: 'verdict_length', length: verdictLength }); + checkList('pros', review.pros, issues); + checkList('cons', review.cons, issues); + const pros = new Set(review.pros.map(comparablePoint)); + review.cons.forEach((con, index) => { + if (isEvasiveCon(con)) issues.push({ kind: 'evasive_con', index }); + else if (pros.has(comparablePoint(con))) + issues.push({ kind: 'pro_and_con', index }); + }); + return issues; +} From 890d2d01f4a13220e3f3c3d320ce353d56f2be25 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:34:44 +0700 Subject: [PATCH 2/3] fix(shared-data): harden rating criteria and review text rules after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviews of #1548. Prototype keys ("toString", "__proto__") no longer reach Object.prototype as a category or a rating; null is a skipped criterion; the weighted star counts any stored star from 1 to 5, so legacy 0.1-step stars are kept; hasRatingCriteria tells a known category from the phone fallback. The text rules count characters as the writer sees them (NFKC, no zero-width characters), reject lines with no words or one repeated character, keep diacritics when comparing lines, never throw on a malformed body, and catch more dodging cons ("không có ạ", "chả có gì", "không chê được gì", "mọi thứ đều ổn"). Refs #1542 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/shared-data.test.ts | 104 ++++++++++++++++ .../shared-data/src/rules/rating-criteria.ts | 84 ++++++++----- packages/shared-data/src/rules/review-form.ts | 111 +++++++++++++----- 3 files changed, 241 insertions(+), 58 deletions(-) diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index 53a640fc2..f7897ccca 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -1545,6 +1545,25 @@ function testRatingCriteriaAndReviewForm(): void { '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Ó', ]) { assert.ok(rules.isEvasiveCon(dodge), dodge); } @@ -1553,6 +1572,12 @@ function testRatingCriteriaAndReviewForm(): void { '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', ]) { assert.ok(!rules.isEvasiveCon(real), real); } @@ -1580,6 +1605,85 @@ function testRatingCriteriaAndReviewForm(): void { { 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); } function main(): void { diff --git a/packages/shared-data/src/rules/rating-criteria.ts b/packages/shared-data/src/rules/rating-criteria.ts index 143c28fc6..4baecc992 100644 --- a/packages/shared-data/src/rules/rating-criteria.ts +++ b/packages/shared-data/src/rules/rating-criteria.ts @@ -128,13 +128,38 @@ export function getRatingCriteria( categorySlug: string | null | undefined ): readonly RatingCriterion[] { return ( - (categorySlug ? RATING_CRITERIA[categorySlug] : undefined) ?? - RATING_CRITERIA.smartphones! + ownValue(RATING_CRITERIA, categorySlug) ?? RATING_CRITERIA.smartphones! ); } -/** Criterion stars as stored in `rate.reviews.criteria_ratings`. */ -export type CriteriaRatings = Partial>; +/** + * 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 } @@ -142,17 +167,21 @@ export type CriteriaRatingsIssue = | { kind: 'missing_required'; key: RatingCriterionKey } | { kind: 'too_many_skipped'; skipped: number }; -/** A star from 1 to 5 in half steps. */ -function isStar(value: unknown): value is 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 && - Number.isInteger(value * 2) + 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 @@ -166,14 +195,15 @@ export function validateCriteriaRatings( const criteria = getRatingCriteria(categorySlug); const known = new Set(criteria.map((criterion) => criterion.key)); const issues: CriteriaRatingsIssue[] = []; - for (const [key, star] of Object.entries(ratings)) { + 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 !== undefined && !isStar(star)) + else if (star != null && !isStar(star)) issues.push({ kind: 'bad_star', key }); } let skipped = 0; for (const criterion of criteria) { - if (ratings[criterion.key] !== undefined) continue; + if (ownValue(given, criterion.key) != null) continue; if (criterion.required) issues.push({ kind: 'missing_required', key: criterion.key }); else skipped += 1; @@ -186,7 +216,9 @@ export function validateCriteriaRatings( /** * 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. + * 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, @@ -195,8 +227,8 @@ export function weightedReviewStar( let sum = 0; let weights = 0; for (const criterion of getRatingCriteria(categorySlug)) { - const star = ratings[criterion.key]; - if (!isStar(star)) continue; + const star = ownValue(ratings, criterion.key); + if (!isStoredStar(star)) continue; sum += star * criterion.weight; weights += criterion.weight; } @@ -208,9 +240,12 @@ export function weightedReviewStar( export type LegacyCriterionKey = 'screen' | 'performance' | 'camera' | 'battery' | 'value'; -// What each legacy key rated per category. Earbuds labelled "screen" as -// soundstage and "camera" as ANC / mic; a key with no counterpart (a watch's -// camera, earbuds' performance) maps to nothing and its star is dropped. +// 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>> > = { @@ -253,20 +288,11 @@ export function mapLegacyCriteriaRatings( categorySlug: string | null | undefined, legacy: Partial> ): CriteriaRatings { - const map = - (categorySlug ? LEGACY_MAP[categorySlug] : undefined) ?? - LEGACY_MAP.smartphones!; + const map = ownValue(LEGACY_MAP, categorySlug) ?? LEGACY_MAP.smartphones!; const out: CriteriaRatings = {}; for (const [legacyKey, key] of Object.entries(map)) { - const star = legacy[legacyKey]; - if ( - key && - typeof star === 'number' && - Number.isFinite(star) && - star >= 1 && - star <= 5 - ) - out[key] = star; + 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 index b90ef2ec3..8072f35fd 100644 --- a/packages/shared-data/src/rules/review-form.ts +++ b/packages/shared-data/src/rules/review-form.ts @@ -24,39 +24,79 @@ 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 }; -// Canonical decomposition, so diacritics split off as marks. -const DECOMPOSED = 'NFD'; +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 a line is compared in: lower case, no diacritics, "đ" as "d", - * punctuation as spaces and spaces collapsed. Only for comparing lines; - * what is stored and shown keeps the writer's text. + * 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 decomposed = text.normalize(DECOMPOSED); - return decomposed - .replace(/\p{M}+/gu, '') - .replace(/[đĐ]/g, 'd') + 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 con that dodges the question ("không có", "chưa thấy", "không có gì để -// chê", "ko co", "none"), compared without diacritics. A real con, however -// small ("giá hơi cao"), never matches: the whole line must be the dodge. -const EVASIVE_CON = - /^(?:(?:hien\s+tai|tam\s+thoi|den\s+gio|van)\s+)?(?:khong|ko|k|chua|chang|hok|hong)(?:\s+(?:co|thay|gap|phat\s+hien))?(?:\s+(?:gi|j|nhuoc\s+diem|diem\s+tru))?(?:\s+(?:de|ma)\s+che)?(?:\s+(?:ca|het|nao|luon))?$|^(?:none|nothing|no\s+cons?|n\s*a|0|ok|tot|hoan\s+hao|perfect)$/; +/** 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; +} + +// A con that dodges the question, compared without diacritics: "không có", +// "chưa thấy ạ", "không có 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ì", "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"), never matches: the whole line must be the +// dodge. +const EVASIVE_CON = new RegExp( + [ + '^(?:(?:hien tai|tam thoi|den gio|van|thi|thuc su|that su) )*', + '(?:khong|ko|k|kh|khum|chua|chang|cha|hok|hong|hem)', + '(?: (?:co|thay|gap|phat hien(?: ra)?|che duoc|che))*', + '(?: (?:gi|j|nhuoc diem|diem tru|khuyet diem|diem yeu|van de|loi))*', + '(?: (?:de|ma|dang|phai|can) (?:che|phan nan))?', + '(?: (?:ca|het|nao|luon|a|nhe|nha|nhen|dau|gi|j))*$', + '|^(?:(?:moi thu|tat ca|all) (?:deu )?(?:on|tot|ok|good|fine)', + '|kcj|no|none|nothing(?: to complain(?: about)?)?|no cons?|no complaints?', + '|n a|0|ok|on|tot|hoan hao|perfect|10 10|5 sao)$', + ].join('') +); /** Whether a con line says only that there is no con. */ export function isEvasiveCon(text: string): boolean { - const plain = comparablePoint(text); - return plain.length === 0 || EVASIVE_CON.test(plain); + const plain = plainPoint(text); + return !hasContent(text) || EVASIVE_CON.test(plain); } function checkList( @@ -68,33 +108,46 @@ function checkList( issues.push({ kind: 'points_count', list, count: points.length }); const seen = new Set(); points.forEach((point, index) => { - const length = point.trim().length; + const length = visibleLength(point); if (length < REVIEW_POINT_MIN || length > REVIEW_POINT_MAX) issues.push({ kind: 'point_length', list, index }); - const plain = comparablePoint(point); - if (plain && seen.has(plain)) + 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(plain); + 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 con that dodges, the - * same line twice in a list, and a line that is both a pro and a con. An - * empty result is a valid review. + * 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 verdictLength = review.verdict.trim().length; + 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 }); - checkList('pros', review.pros, issues); - checkList('cons', review.cons, issues); - const pros = new Set(review.pros.map(comparablePoint)); - review.cons.forEach((con, index) => { + 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 (pros.has(comparablePoint(con))) + else if (proLines.has(comparablePoint(con))) issues.push({ kind: 'pro_and_con', index }); }); return issues; From d0e669415ba65086fa05cf4e7804e1d2169b4513 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:38:39 +0700 Subject: [PATCH 3/3] fix(shared-data): match dodging cons word by word with their own diacritics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 review of #1548. Stripping diacritics made "hỏng luôn", "không gập", "chưa có lời" and "mọi thứ đều ồn" read as dodges. Each dodge word now matches as typed with its own diacritics or with none, never with other ones. A pronoun may lead ("mình không thấy gì để chê"), and "không có ý kiến", "không có gì để nói", "rất tốt" count. A con longer than 150 characters is never a dodge, which keeps the check linear on an oversized request. Refs #1542 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/shared-data.test.ts | 23 ++++++++ packages/shared-data/src/rules/review-form.ts | 58 +++++++++++++------ 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/packages/shared-data/src/__tests__/shared-data.test.ts b/packages/shared-data/src/__tests__/shared-data.test.ts index f7897ccca..d09917e50 100644 --- a/packages/shared-data/src/__tests__/shared-data.test.ts +++ b/packages/shared-data/src/__tests__/shared-data.test.ts @@ -1564,6 +1564,16 @@ function testRatingCriteriaAndReviewForm(): void { '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); } @@ -1578,6 +1588,15 @@ function testRatingCriteriaAndReviewForm(): void { '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); } @@ -1684,6 +1703,10 @@ function testRatingCriteriaAndReviewForm(): void { }); // (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 { diff --git a/packages/shared-data/src/rules/review-form.ts b/packages/shared-data/src/rules/review-form.ts index 8072f35fd..3a4453c36 100644 --- a/packages/shared-data/src/rules/review-form.ts +++ b/packages/shared-data/src/rules/review-form.ts @@ -73,30 +73,52 @@ function hasContent(text: string): boolean { return letters.length >= 3 && new Set(letters).size >= 2; } -// A con that dodges the question, compared without diacritics: "không có", -// "chưa thấy ạ", "không có 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ì", "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"), never matches: the whole line must be the -// dodge. +// 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( [ - '^(?:(?:hien tai|tam thoi|den gio|van|thi|thuc su|that su) )*', - '(?:khong|ko|k|kh|khum|chua|chang|cha|hok|hong|hem)', - '(?: (?:co|thay|gap|phat hien(?: ra)?|che duoc|che))*', - '(?: (?:gi|j|nhuoc diem|diem tru|khuyet diem|diem yeu|van de|loi))*', - '(?: (?:de|ma|dang|phai|can) (?:che|phan nan))?', - '(?: (?:ca|het|nao|luon|a|nhe|nha|nhen|dau|gi|j))*$', - '|^(?:(?:moi thu|tat ca|all) (?:deu )?(?:on|tot|ok|good|fine)', - '|kcj|no|none|nothing(?: to complain(?: about)?)?|no cons?|no complaints?', - '|n a|0|ok|on|tot|hoan hao|perfect|10 10|5 sao)$', + `^(?:${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. */ +/** + * 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 plain = plainPoint(text); - return !hasContent(text) || EVASIVE_CON.test(plain); + const compared = comparablePoint(text); + if (compared.length > REVIEW_POINT_MAX) return false; + return !hasContent(text) || EVASIVE_CON.test(compared); } function checkList(